From 6f7bc5b6f8ad290513e82166eaada0223900cccc Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Wed, 10 Jun 2026 18:58:51 +0530 Subject: [PATCH 01/24] Add Campfire hook installation and session restore MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Campfire (the collaborative pi-based harness) becomes a first-class agent: - cmux hooks campfire install/uninstall writes a native extension to ${CAMPFIRE_CODING_AGENT_DIR:-~/.campfire/agent}/extensions/ cmux-campfire-session.ts; opt out per process with CMUX_CAMPFIRE_HOOKS_DISABLED=1 - the extension records the HOST role only (CAMPFIRE_SESSION_ROLE); a joiner is an ephemeral view whose argv carries the invite URL, a capability token that is never persisted or replayed - launch capture normalizes the bun-compiled argv (drops the bunfs virtual entry) and tags kind=campfire so restore runs campfire --session instead of mis-resuming as plain pi - the extension subscribes to campfire's in-process observer bridge (Symbol.for campfire.observer.v1) and surfaces driver-actionable collaborative moments — a joiner waiting in the lobby, a capability ask — as cmux notifications - sanitizer policy preserves --relay/--model config flags and drops prompts, session selectors, --join-as, and invite URLs; environment policy replays CAMPFIRE_* config roots, never secrets, and drops the self-managed PI_PACKAGE_DIR so an upgraded binary is not pinned to a stale asset cache - Vault and Task Manager detect campfire processes (compiled binary and bun dev invocations) with sessions under ~/.campfire/agent/sessions - docs, en+ja (and 18 more locales) CLI strings, Swift + Python tests, CI hookup Verification: - swift test --package-path Packages/CMUXAgentLaunch (82 tests) - xcodebuild test -only-testing:cmuxTests/CampfireSupportTests (4 tests) - CMUX_CLI_BIN=... python3 tests/test_campfire_extension_install.py - xcodebuild build (full app, tagged derived data) - ./scripts/check-pbxproj.sh && ./scripts/lint-pbxproj-test-wiring.sh Co-Authored-By: Claude Fable 5 --- .github/workflows/ci.yml | 1 + CLI/CMUXCLI+AgentHookDefinitions.swift | 9 + CLI/CMUXCLI+CampfireExtension.swift | 415 ++++++ CLI/cmux.swift | 11 +- .../AgentLaunchEnvironmentPolicy.swift | 17 + .../AgentLaunchSanitizer.swift | 2 + .../AgentLaunchSanitizerPrimaryPolicies.swift | 16 + .../CMUXAgentLaunch/AgentResumeArgv.swift | 2 + .../AgentLaunchEnvironmentPolicyTests.swift | 32 + .../AgentLaunchSanitizerTests.swift | 61 + .../AgentResumeArgvTests.swift | 1 + Resources/Localizable.xcstrings | 1125 +++++++++++++++++ Sources/TaskManagerTypes.swift | 8 + Sources/VaultAgentProcessScanner.swift | 20 + Sources/VaultAgentRegistry.swift | 16 + cmux.xcodeproj/project.pbxproj | 8 + cmuxTests/CampfireSupportTests.swift | 181 +++ docs/agent-hooks.md | 8 +- docs/feed.md | 3 +- docs/vault.md | 15 +- tests/test_campfire_extension_install.py | 641 ++++++++++ .../[locale]/docs/session-restore/page.tsx | 6 + 22 files changed, 2593 insertions(+), 5 deletions(-) create mode 100644 CLI/CMUXCLI+CampfireExtension.swift create mode 100644 cmuxTests/CampfireSupportTests.swift create mode 100644 tests/test_campfire_extension_install.py diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 328ec2354a5a..9a56e6a87d85 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -482,6 +482,7 @@ jobs: CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_claude_hook_clear_running_status.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_pi_extension_install.py CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_omp_extension_install.py + CMUX_CLI_BIN="$CLI_BIN" python3 tests/test_campfire_extension_install.py tests-build-and-lag: # Build the full cmux scheme and run the lag regression on macOS CI. diff --git a/CLI/CMUXCLI+AgentHookDefinitions.swift b/CLI/CMUXCLI+AgentHookDefinitions.swift index 0ed445c5bc2d..172f4eee5df3 100644 --- a/CLI/CMUXCLI+AgentHookDefinitions.swift +++ b/CLI/CMUXCLI+AgentHookDefinitions.swift @@ -208,6 +208,15 @@ extension CMUXCLI { hookMarker: "cmux hooks omp", format: .flat, events: [] ), + AgentHookDef( + name: "campfire", displayName: "Campfire", statusKey: "campfire", + configDir: ".campfire/agent", configFile: "extensions/cmux-campfire-session.ts", + createConfigDirIfMissing: true, + configDirResolver: { CMUXCLI.resolvedCampfireAgentDirectory().path }, + sessionStoreSuffix: "campfire", disableEnvVar: "CMUX_CAMPFIRE_HOOKS_DISABLED", + hookMarker: "cmux hooks campfire", format: .flat, + events: [] + ), AgentHookDef( name: "amp", displayName: "Amp", statusKey: "amp", configDir: ".config/amp", configFile: "plugins/cmux-session.ts", diff --git a/CLI/CMUXCLI+CampfireExtension.swift b/CLI/CMUXCLI+CampfireExtension.swift new file mode 100644 index 000000000000..73d25beba68e --- /dev/null +++ b/CLI/CMUXCLI+CampfireExtension.swift @@ -0,0 +1,415 @@ +import Foundation + +extension CMUXCLI { + private static let campfireExtensionMarker = "cmux-campfire-session-extension-marker" + private static let campfireExtensionFilename = "cmux-campfire-session.ts" + private static let campfireExtensionSource = #""" +// cmux-campfire-session-extension-marker v1 +// Bridges Campfire session lifecycle events into cmux's restorable session store, +// and Campfire's collaborative moments (join requests, capability asks) into cmux +// notifications. Installed by `cmux hooks campfire install` or `cmux hooks setup`. +// DO NOT EDIT MANUALLY. cmux upgrades this file in place. + +import { spawn } from "node:child_process"; +import * as fs from "node:fs"; +import * as path from "node:path"; +import type { AgentEndEvent, ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent"; + +function firstString(...values: unknown[]): string | null { + for (const value of values) { + if (typeof value === "string" && value.trim().length > 0) return value.trim(); + } + return null; +} + +function resolveExecutable(name: string): string { + const pathEnv = process.env.PATH || ""; + for (const dir of pathEnv.split(path.delimiter)) { + if (!dir) continue; + const candidate = path.join(dir, name); + try { + fs.accessSync(candidate, fs.constants.X_OK); + if (fs.statSync(candidate).isFile()) return candidate; + } catch (_) {} + } + return name; +} + +function looksLikeBunfsEntry(value: string): boolean { + // A bun-compiled binary inserts its embedded entrypoint at argv[1] as a + // virtual path (/$bunfs/root/... or a ~BUN marker). It is not a real file + // and must never be recorded in a launch command. + const normalized = value.replaceAll("\\", "/"); + return normalized.includes("$bunfs") || normalized.includes("~BUN") || normalized.includes("%7EBUN"); +} + +function looksLikeCampfireExecutable(value: string): boolean { + return path.basename(value).toLowerCase() === "campfire" && !looksLikeBunfsEntry(value); +} + +function looksLikeCampfireScript(value: string): boolean { + const normalized = value.replaceAll("\\", "/").toLowerCase(); + const base = path.basename(normalized); + return ( + (base === "campfire.ts" || base === "campfire.js" || base === "campfire") && + (normalized.includes("/campfire") || normalized.includes("packages/session")) + ); +} + +function looksLikeJavaScriptRuntime(value: string): boolean { + const base = path.basename(value).toLowerCase(); + return base === "node" || base === "bun" || base === "deno" || base === "tsx" || base === "ts-node"; +} + +function normalizedLaunchArgv(): string[] { + const raw = Array.isArray(process.argv) ? process.argv.map((value) => String(value)) : []; + if (raw.length === 0) return [resolveExecutable("campfire")]; + if (looksLikeCampfireExecutable(raw[0])) { + // Compiled binary: drop the bunfs virtual entry at argv[1] when present. + if (raw.length > 1 && looksLikeBunfsEntry(raw[1])) return [raw[0], ...raw.slice(2)]; + return raw; + } + if (raw.length > 1 && looksLikeJavaScriptRuntime(raw[0]) && looksLikeCampfireScript(raw[1])) { + return [resolveExecutable("campfire"), ...raw.slice(2)]; + } + return [resolveExecutable("campfire"), ...raw.slice(1)]; +} + +function base64NulSeparated(values: string[]): string { + const bytes: Buffer[] = []; + for (const value of values) { + bytes.push(Buffer.from(String(value), "utf8")); + bytes.push(Buffer.from([0])); + } + return Buffer.concat(bytes).toString("base64"); +} + +function hookEnvironment(cwd: string): NodeJS.ProcessEnv { + const env: NodeJS.ProcessEnv = { ...process.env }; + if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) { + const argv = normalizedLaunchArgv(); + env.CMUX_AGENT_LAUNCH_KIND = "campfire"; + env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("campfire"); + env.CMUX_AGENT_LAUNCH_ARGV_B64 = base64NulSeparated(argv); + env.CMUX_AGENT_LAUNCH_CWD = cwd || process.cwd(); + } + return env; +} + +interface HookInvocation { + cmux: string; + cwd: string; + payload: string; + env: NodeJS.ProcessEnv; +} + +function eventName(subcommand: string): string { + switch (subcommand) { + case "session-start": + return "SessionStart"; + case "prompt-submit": + return "UserPromptSubmit"; + case "stop": + return "Stop"; + case "notification": + return "Notification"; + default: + return subcommand; + } +} + +function textFromContent(content: unknown): string | null { + if (typeof content === "string") return content; + if (!Array.isArray(content)) return null; + const parts: string[] = []; + for (const block of content) { + if (!block || typeof block !== "object") continue; + const typed = block as { type?: unknown; text?: unknown }; + if (typed.type === "text" && typeof typed.text === "string") parts.push(typed.text); + } + return parts.join("\n") || null; +} + +function lastAssistantMessage(event: AgentEndEvent): string | undefined { + for (let index = event.messages.length - 1; index >= 0; index -= 1) { + const message = event.messages[index]; + if (!message || typeof message !== "object") continue; + const typed = message as { role?: unknown; content?: unknown }; + if (typed.role !== "assistant") continue; + const text = firstString(textFromContent(typed.content)); + if (text) return text; + } + return undefined; +} + +function hookInvocation(subcommand: string, ctx: ExtensionContext, extra: Record = {}): HookInvocation | null { + if (process.env.CMUX_CAMPFIRE_HOOKS_DISABLED === "1") return null; + if (!process.env.CMUX_SURFACE_ID) return null; + // Newer campfire ships this integration natively (its built-in cmux bridge + // publishes the flag below). Defer to it so nothing double-fires; this + // installed file then only serves campfire versions without the native + // bridge. + if ((globalThis as Record)[Symbol.for("campfire.cmux.bridge.v1")]) return null; + // Only the HOST runs the agent and is restorable. A joiner is an ephemeral + // view whose argv carries the invite URL — a capability token that must + // never be persisted or replayed — so anything but an explicit host role + // records nothing. + if (process.env.CAMPFIRE_SESSION_ROLE !== "host") return null; + + const sessionId = firstString(ctx.sessionManager.getSessionId()); + if (!sessionId) return null; + + const cwd = firstString(ctx.cwd, process.cwd()) || process.cwd(); + const payload: Record = { + session_id: sessionId, + cwd, + hook_event_name: eventName(subcommand), + event: eventName(subcommand), + ...extra, + }; + const cmux = process.env.CMUX_CAMPFIRE_CMUX_BIN || "cmux"; + return { + cmux, + cwd, + payload: JSON.stringify(payload), + env: hookEnvironment(cwd), + }; +} + +async function sendHook(subcommand: string, ctx: ExtensionContext, extra: Record = {}): Promise { + const invocation = hookInvocation(subcommand, ctx, extra); + if (!invocation) return; + await new Promise((resolve) => { + let settled = false; + const settle = () => { + if (settled) return; + settled = true; + resolve(); + }; + try { + const child = spawn(invocation.cmux, ["hooks", "campfire", subcommand], { + env: invocation.env, + stdio: ["pipe", "ignore", "ignore"], + detached: true, + }); + child.on("error", settle); + child.stdin.on("error", settle); + child.stdin.on("finish", settle); + child.unref(); + child.stdin.end(invocation.payload); + } catch (_) { + settle(); + } + }); +} + +// Campfire publishes collaborative moments (join requests, capability asks, +// relay health) on a versioned in-process bridge; see campfire's +// docs/observers.md. Payloads are summaries by construction — names, counts, +// capability ids — never prompt text or invite URLs. +interface CampfireObserverEvent { + type: string; + displayName?: string; + capability?: string; + reason?: string; +} + +const OBSERVER_KEY = Symbol.for("campfire.observer.v1"); + +function observerBridge(): { listeners: Set<(event: CampfireObserverEvent) => void> } { + const holder = globalThis as Record void> } | undefined>; + const existing = holder[OBSERVER_KEY]; + if (existing) return existing; + const created = { listeners: new Set<(event: CampfireObserverEvent) => void>() }; + holder[OBSERVER_KEY] = created; + return created; +} + +function capabilityLabel(capability: string | undefined): string { + switch (capability) { + case "queue:add": + return "queue a prompt"; + case "queue:run-now": + return "run a prompt now"; + case "session:interrupt": + return "interrupt the agent"; + case "shell:exec": + return "run a shell command"; + case "tools:contribute": + return "add tools or skills"; + case "files:list": + return "browse files"; + default: + return capability || "do something"; + } +} + +function observerNotification(event: CampfireObserverEvent): string | null { + const name = firstString(event.displayName) || "Someone"; + switch (event.type) { + case "join.requested": + return `${name} is waiting to join the campfire session`; + case "permission.asked": + return `${name} asked to ${capabilityLabel(event.capability)}`; + case "relay.error": + return `Campfire relay error: ${firstString(event.reason) || "connection failed"}`; + default: + return null; + } +} + +export default function cmuxCampfireSessionExtension(api: ExtensionAPI) { + let activeContext: ExtensionContext | null = null; + + api.on("session_start", async (_event, ctx) => { + activeContext = ctx; + await sendHook("session-start", ctx); + }); + + api.on("before_agent_start", async (event, ctx) => { + activeContext = ctx; + await sendHook("prompt-submit", ctx, { prompt: event.prompt }); + }); + + api.on("agent_end", async (event, ctx) => { + activeContext = ctx; + await sendHook("stop", ctx, { last_assistant_message: lastAssistantMessage(event) }); + }); + + observerBridge().listeners.add((event) => { + const ctx = activeContext; + if (!ctx) return; + const message = observerNotification(event); + if (!message) return; + void sendHook("notification", ctx, { message, title: "Campfire" }); + }); +} +"""# + + static func resolvedCampfireAgentDirectory(environment: [String: String] = ProcessInfo.processInfo.environment) -> URL { + if let agentRoot = nonEmptyCampfireEnvironmentValue("CAMPFIRE_CODING_AGENT_DIR", in: environment) { + return URL( + fileURLWithPath: NSString(string: agentRoot).expandingTildeInPath, + isDirectory: true + ) + } + + let home = nonEmptyCampfireEnvironmentValue("HOME", in: environment) ?? NSHomeDirectory() + return URL(fileURLWithPath: NSString(string: home).expandingTildeInPath, isDirectory: true) + .appendingPathComponent(".campfire", isDirectory: true) + .appendingPathComponent("agent", isDirectory: true) + } + + private static func nonEmptyCampfireEnvironmentValue(_ name: String, in environment: [String: String]) -> String? { + let trimmed = environment[name]?.trimmingCharacters(in: .whitespacesAndNewlines) ?? "" + return trimmed.isEmpty ? nil : trimmed + } + + private func campfireExtensionURL() -> URL { + return Self.resolvedCampfireAgentDirectory() + .appendingPathComponent("extensions", isDirectory: true) + .appendingPathComponent(Self.campfireExtensionFilename, isDirectory: false) + } + + private func existingCampfireExtensionContents(at url: URL, fileManager: FileManager = .default) throws -> String { + guard fileManager.fileExists(atPath: url.path) else { return "" } + do { + return try String(contentsOf: url, encoding: .utf8) + } catch { + let message = String.localizedStringWithFormat( + String( + localized: "cli.hooks.campfire.error.readFailed", + defaultValue: "Failed to read %@" + ), + url.path + ) + throw CLIError(message: "\(message): \(String(describing: error))") + } + } + + func installCampfireExtensionHooks(_ _: AgentHookDef) throws { + let extensionURL = campfireExtensionURL() + let fileManager = FileManager.default + let skipConfirm = ProcessInfo.processInfo.arguments.contains("--yes") + || ProcessInfo.processInfo.arguments.contains("-y") + let existing = try existingCampfireExtensionContents(at: extensionURL, fileManager: fileManager) + if existing == Self.campfireExtensionSource { + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.campfire.alreadyUpToDate", + defaultValue: "Campfire hooks already up to date at %@" + ), + extensionURL.path + )) + return + } + if !existing.isEmpty, !existing.contains(Self.campfireExtensionMarker) { + throw CLIError(message: String.localizedStringWithFormat( + String( + localized: "cli.hooks.campfire.error.notCmuxExtension", + defaultValue: "%@ exists and is not a cmux extension; leaving it alone" + ), + extensionURL.path + )) + } + if !skipConfirm { + Self.printInstallPreview( + path: extensionURL.path, + oldContent: existing, + newContent: Self.campfireExtensionSource, + fallbackContent: Self.campfireExtensionSource + ) + print(String(localized: "cli.hooks.campfire.confirmProceed", defaultValue: "\nProceed? [y/N] "), terminator: "") + guard readLine()?.lowercased().hasPrefix("y") == true else { + print(String(localized: "cli.hooks.campfire.aborted", defaultValue: "Aborted.")) + return + } + } + try fileManager.createDirectory( + at: extensionURL.deletingLastPathComponent(), + withIntermediateDirectories: true + ) + try Self.campfireExtensionSource.write(to: extensionURL, atomically: true, encoding: .utf8) + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.campfire.installed", + defaultValue: "Campfire hooks installed at %@" + ), + extensionURL.path + )) + } + + func uninstallCampfireExtensionHooks(_ _: AgentHookDef) throws { + let extensionURL = campfireExtensionURL() + let fm = FileManager.default + guard fm.fileExists(atPath: extensionURL.path) else { + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.campfire.noneFound", + defaultValue: "No Campfire cmux extension found at %@" + ), + extensionURL.path + )) + return + } + let existing = try existingCampfireExtensionContents(at: extensionURL, fileManager: fm) + guard existing.contains(Self.campfireExtensionMarker) else { + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.campfire.refuseRemoveMissingMarker", + defaultValue: "Refusing to remove %@: missing cmux marker" + ), + extensionURL.path + )) + return + } + try fm.removeItem(at: extensionURL) + print(String.localizedStringWithFormat( + String( + localized: "cli.hooks.campfire.removed", + defaultValue: "Removed Campfire cmux extension from %@" + ), + extensionURL.path + )) + } +} diff --git a/CLI/cmux.swift b/CLI/cmux.swift index 6d6d327e8215..a304a8731b5a 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -13018,7 +13018,7 @@ struct CMUXCLI { agent. Claude Code hooks are injected automatically by the cmux Claude wrapper. Agents: - codex, grok, opencode, pi, omp, amp, cursor, gemini, kiro, antigravity (alias: agy), rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder + codex, grok, opencode, pi, omp, campfire, amp, cursor, gemini, kiro, antigravity (alias: agy), rovodev (alias: rovo), hermes-agent, copilot, codebuddy, factory, qoder Hook targets: setup Install hooks for all supported agents on PATH @@ -13033,6 +13033,7 @@ struct CMUXCLI { ~/.config/opencode/plugins/cmux-feed.js ~/.pi/agent/extensions/cmux-session.ts ~/.omp/agent/extensions/cmux-omp-session.ts + ~/.campfire/agent/extensions/cmux-campfire-session.ts ~/.config/amp/plugins/cmux-session.ts ~/.kiro/agents/cmux.json See docs/agent-hooks.md for the full integration matrix. @@ -26582,6 +26583,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { try installOmpExtensionHooks(def) return } + if def.name == "campfire" { + try installCampfireExtensionHooks(def) + return + } if def.name == "amp" { try installAmpExtensionHooks(def) return @@ -26943,6 +26948,10 @@ export default function cmuxPiSessionExtension(pi: ExtensionAPI) { try uninstallOmpExtensionHooks(def) return } + if def.name == "campfire" { + try uninstallCampfireExtensionHooks(def) + return + } if def.name == "amp" { try uninstallAmpExtensionHooks(def) return diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index 41a0fb3c0ff9..7346c54e0783 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -29,6 +29,15 @@ public enum AgentLaunchEnvironmentPolicy { "HERMES_CODEX_BASE_URL", ] + /// Keys campfire computes itself on every boot. Replaying a captured + /// PI_PACKAGE_DIR would pin a resumed campfire to the previous binary's + /// extracted asset cache (version+fingerprint keyed) after an upgrade, so + /// it is dropped for campfire resumes specifically; pi/omp keep it (Nix + /// installs rely on it). + private static let campfireManagedEnvironmentKeys: Set = [ + "PI_PACKAGE_DIR", + ] + private static let safeEnvironmentKeys: Set = [ // AMP_API_KEY is intentionally NOT allowlisted: it's a secret. // Amp resolves auth from ~/.config/amp/settings.json on resume. @@ -38,6 +47,9 @@ public enum AgentLaunchEnvironmentPolicy { "AMP_URL", "ANTHROPIC_BASE_URL", "ANTHROPIC_MODEL", + "CAMPFIRE_CODING_AGENT_DIR", + "CAMPFIRE_CODING_AGENT_SESSION_DIR", + "CAMPFIRE_RELAY_URL", "CLAUDE_CONFIG_DIR", "CMUX_CUSTOM_CLAUDE_PATH", "CMUX_ROVODEV_SESSIONS_DIR", @@ -96,6 +108,11 @@ public enum AgentLaunchEnvironmentPolicy { result.removeValue(forKey: key) } } + if kind == "campfire" { + for key in campfireManagedEnvironmentKeys { + result.removeValue(forKey: key) + } + } return result } diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift index f51721451447..7627a4e9cdb5 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift @@ -86,6 +86,8 @@ public enum AgentLaunchSanitizer { return preserveOptions(args, policy: grokPolicy) case "pi", "omp": return preserveOptions(args, policy: piPolicy) + case "campfire": + return preserveOptions(args, policy: campfirePolicy) case "amp": // Strip the `threads continue ` resume sub-subcommand if the // captured launch already started by resuming a thread, so we diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift index ba903f9e4b04..e4715b3368bf 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift @@ -301,6 +301,22 @@ extension AgentLaunchSanitizer { ] ) + /// Campfire embeds vanilla pi and forwards unrecognized flags to it, so its + /// policy is pi's plus the campfire-only surface. `--relay` is safe to + /// replay (a relay URL, not a credential); `--join-as`/`--name` are + /// joiner-only display names that make no sense on a host resume. An invite + /// URL is a positional argument and is dropped by the default positional + /// handling — it carries the lobby capability token and must never be + /// persisted or replayed. + static let campfirePolicy: Policy = { + var policy = piPolicy + policy.valueOptions.formUnion(["--relay", "--join-as", "--name"]) + policy.nonRestorableCommands.insert("init") + policy.droppedOptions.formUnion(["--join-as", "--name", "--auto-exit"]) + policy.droppedOptionPrefixes.append(contentsOf: ["--join-as=", "--name="]) + return policy + }() + static let ampPolicy = Policy( valueOptions: [ "--effort", diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift index 53f3eb08f3ed..5bcfd49b3a25 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentResumeArgv.swift @@ -200,6 +200,8 @@ public struct AgentResumeArgv: Sendable, Equatable { return withOption("pi", executable: "pi", option: "--session", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "omp": return withOption("omp", executable: "omp", option: "--session", sessionId: sessionId, executablePath: executablePath, arguments: arguments) + case "campfire": + return withOption("campfire", executable: "campfire", option: "--session", sessionId: sessionId, executablePath: executablePath, arguments: arguments) case "amp": let parts = commandParts(executablePath: executablePath, arguments: arguments, fallbackExecutable: "amp") guard let preserved = AgentLaunchSanitizer.preservedArguments(kind: "amp", args: parts.tail) else { return nil } diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift index 105fb02c1317..00a78afe0b23 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift @@ -19,4 +19,36 @@ struct AgentLaunchEnvironmentPolicyTests { "PI_CONFIG_DIR": ".custom-omp", ]) } + + @Test("Preserves Campfire config roots and drops its self-managed package dir") + func preservesCampfireConfigRootsAndDropsManagedPackageDir() { + let selected = AgentLaunchEnvironmentPolicy.selectedEnvironment( + from: [ + "OPENAI_API_KEY": "secret-should-not-persist", + "CAMPFIRE_CODING_AGENT_DIR": "/tmp/campfire-agent", + "CAMPFIRE_CODING_AGENT_SESSION_DIR": "/tmp/campfire-sessions", + "CAMPFIRE_RELAY_URL": "wss://relay.example/ws", + // Campfire recomputes its extracted pi asset cache on every + // boot; replaying a captured path would pin a resumed session + // to the previous binary's cache after an upgrade. + "PI_PACKAGE_DIR": "/tmp/stale-pi-cache", + ], + kind: "campfire" + ) + + #expect(selected == [ + "CAMPFIRE_CODING_AGENT_DIR": "/tmp/campfire-agent", + "CAMPFIRE_CODING_AGENT_SESSION_DIR": "/tmp/campfire-sessions", + "CAMPFIRE_RELAY_URL": "wss://relay.example/ws", + ]) + } + + @Test("Keeps PI_PACKAGE_DIR for pi and omp resumes") + func keepsPiPackageDirForPiKinds() { + let selected = AgentLaunchEnvironmentPolicy.selectedEnvironment( + from: ["PI_PACKAGE_DIR": "/nix/store/pi-package"], + kind: "pi" + ) + #expect(selected["PI_PACKAGE_DIR"] == "/nix/store/pi-package") + } } diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift index 4870c028a243..92daa9dcccbf 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift @@ -456,6 +456,67 @@ struct AgentLaunchSanitizerTests { ) } + @Test("Drops Campfire session selectors, invite URLs, and joiner flags") + func dropsCampfireSessionSelectorsInviteUrlsAndJoinerFlags() { + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "campfire", + "--session", + "old-session", + "--relay", + "wss://relay.example/ws", + "--model", + "anthropic/claude-sonnet-4-5", + "initial prompt should not replay", + ], + launcher: "campfire", + fallbackKind: "campfire" + ) == [ + "campfire", + "--relay", + "wss://relay.example/ws", + "--model", + "anthropic/claude-sonnet-4-5", + ] + ) + // An invite URL is a lobby capability token. It must never be + // persisted or replayed, in any argv position. + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + [ + "campfire", + "https://relay.example/j/6bbb595d#lk=secret-lobby-token", + "--join-as", + "alice", + ], + launcher: "campfire", + fallbackKind: "campfire" + ) == ["campfire"] + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["campfire", "--join-as", "alice", "--theme", "dark"], + launcher: "campfire", + fallbackKind: "campfire" + ) == ["campfire", "--theme", "dark"] + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["campfire", "--session=old-session", "--relay=wss://relay.example/ws"], + launcher: "campfire", + fallbackKind: "campfire" + ) == ["campfire", "--relay=wss://relay.example/ws"] + ) + #expect( + AgentLaunchSanitizer.sanitizedLaunchArguments( + ["campfire", "init", "--auto-exit"], + launcher: "campfire", + fallbackKind: "campfire" + ) == nil + ) + } + @Test("Rejects noninteractive Antigravity launches") func rejectsNoninteractiveAntigravityLaunches() { #expect( diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift index 2dfa9354b1c1..45ddacf12aee 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentResumeArgvTests.swift @@ -8,6 +8,7 @@ struct AgentResumeArgvTests { ("grok", "grok", ["grok", "-r", "SID"]), ("pi", "pi", ["pi", "--session", "SID"]), ("omp", "omp", ["omp", "--session", "SID"]), + ("campfire", "campfire", ["campfire", "--session", "SID"]), ("cursor", "cursor-agent", ["cursor-agent", "--resume", "SID"]), ("gemini", "gemini", ["gemini", "--resume", "SID"]), ("antigravity", "agy", ["agy", "--conversation", "SID"]), diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 1f5cf73432e2..38e06d880fe1 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -190861,6 +190861,1131 @@ } } } + }, + "cli.hooks.campfire.aborted": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "أُلغيَت العملية." + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Prekinuto." + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Afbrudt." + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Abgebrochen." + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Aborted." + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Operación cancelada." + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Opération annulée." + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Operazione annullata." + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "中止しました。" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានបោះបង់។" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "중단했습니다." + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Avbrutt." + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Przerwano." + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Abortado." + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Операция прервана." + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ยกเลิกแล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "İptal edildi." + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Операцію скасовано." + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已中止。" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已中止。" + } + } + } + }, + "cli.hooks.campfire.alreadyUpToDate": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "خطافات Campfire محدَّثة بالفعل في %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Campfire hookovi su već ažurirani u %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Campfire-hooks er allerede opdaterede i %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Campfire-Hooks sind in %@ bereits auf dem neuesten Stand" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Campfire hooks already up to date at %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Los hooks de Campfire ya están actualizados en %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Les hooks Campfire sont déjà à jour dans %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Gli hook di Campfire sono già aggiornati in %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の Campfire フックはすでに最新です" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "hooks របស់ Campfire នៅ %@ ទាន់សម័យរួចហើយ" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@의 Campfire 훅은 이미 최신 상태입니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Campfire-hooks er allerede oppdatert i %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Hooki Campfire są już aktualne w %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Os hooks do Campfire já estão atualizados em %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хуки Campfire в %@ уже актуальны" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ฮุก Campfire ที่ %@ เป็นเวอร์ชันล่าสุดอยู่แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Campfire kancaları %@ konumunda zaten güncel" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Хуки Campfire у %@ вже актуальні" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 中的 Campfire hooks 已是最新" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 中的 Campfire hooks 已是最新" + } + } + } + }, + "cli.hooks.campfire.confirmProceed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "\nهل تريد المتابعة؟ [y/N] " + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "\nNastaviti? [y/N] " + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "\nFortsæt? [y/N] " + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "\nFortfahren? [y/N] " + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "\nProceed? [y/N] " + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "\n¿Continuar? [y/N] " + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "\nContinuer ? [y/N] " + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "\nProcedere? [y/N] " + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "\n続行しますか? [y/N] " + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "\nបន្តឬ? [y/N] " + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "\n계속하시겠습니까? [y/N] " + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "\nFortsette? [y/N] " + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "\nKontynuować? [y/N] " + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "\nProsseguir? [y/N] " + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "\nПродолжить? [y/N] " + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "\nดำเนินการต่อหรือไม่? [y/N] " + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "\nDevam edilsin mi? [y/N] " + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "\nПродовжити? [y/N] " + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "\n是否继续? [y/N] " + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "\n是否繼續? [y/N] " + } + } + } + }, + "cli.hooks.campfire.error.notCmuxExtension": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "%@ موجود وليس امتداد cmux؛ سيتم تركه كما هو" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "%@ postoji i nije cmux ekstenzija; ostavljam ga netaknutim" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "%@ findes og er ikke en cmux-udvidelse; lader den være" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ existiert und ist keine cmux-Erweiterung; wird unverändert belassen" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ exists and is not a cmux extension; leaving it alone" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "%@ existe y no es una extensión de cmux; se dejará intacto" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "%@ existe et n’est pas une extension cmux ; aucun changement n’y sera apporté" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "%@ esiste e non è un'estensione cmux; verrà lasciato invariato" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ は存在しますが cmux 拡張ではありません。そのままにします" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "%@ មានរួចហើយ ហើយមិនមែនជា​ផ្នែកបន្ថែម cmux ទេ; ទុកវាដូចដើម" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@이(가) 존재하지만 cmux 확장이 아니므로 그대로 둡니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "%@ finnes og er ikke en cmux-utvidelse; lar den være i fred" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "%@ istnieje i nie jest rozszerzeniem cmux; pozostawiam bez zmian" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "%@ existe e não é uma extensão do cmux; será deixado como está" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "%@ существует и не является расширением cmux; оставляем без изменений" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "%@ มีอยู่แล้วและไม่ใช่ส่วนขยาย cmux; จะปล่อยไว้ตามเดิม" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ mevcut ve bir cmux uzantısı değil; olduğu gibi bırakılıyor" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "%@ існує і не є розширенням cmux; залишаємо без змін" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "%@ 已存在且不是 cmux 扩展;将保持不变" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "%@ 已存在且不是 cmux 擴充功能;將維持原樣" + } + } + } + }, + "cli.hooks.campfire.error.readFailed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "فشل في قراءة %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Čitanje %@ nije uspjelo" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Kunne ikke læse %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Fehler beim Lesen von %@" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Failed to read %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se pudo leer %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Échec de la lecture de %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Impossibile leggere %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の読み取りに失敗しました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "អាន %@ មិនបានសម្រេច" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@을(를) 읽지 못했습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Klarte ikke å lese %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie udało się odczytać %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Falha ao ler %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Не удалось прочитать %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "อ่าน %@ ไม่สำเร็จ" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ okunamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Не вдалося прочитати %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "读取 %@ 失败" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "讀取 %@ 失敗" + } + } + } + }, + "cli.hooks.campfire.installed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم تثبيت خطافات Campfire في %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Campfire hookovi su instalirani u %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Campfire-hooks er installeret i %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Campfire-Hooks wurden in %@ installiert" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Campfire hooks installed at %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Hooks de Campfire instalados en %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Hooks Campfire installés dans %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Hook di Campfire installati in %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Campfire フックを %@ にインストールしました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានដំឡើង hooks របស់ Campfire នៅ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에 Campfire 훅을 설치했습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Campfire-hooks er installert i %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Zainstalowano hooki Campfire w %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Hooks do Campfire instalados em %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Хуки Campfire установлены в %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ติดตั้งฮุก Campfire ที่ %@ แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Campfire kancaları %@ konumuna kuruldu" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Хуки Campfire встановлено в %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已在 %@ 安装 Campfire hooks" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已在 %@ 安裝 Campfire hooks" + } + } + } + }, + "cli.hooks.campfire.noneFound": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "لم يتم العثور على امتداد Campfire cmux في %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Nijedna Campfire cmux ekstenzija nije pronađena u %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Ingen Campfire cmux-udvidelse fundet i %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Keine Campfire-cmux-Erweiterung in %@ gefunden" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "No Campfire cmux extension found at %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "No se encontró ninguna extensión Campfire cmux en %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Aucune extension Campfire cmux trouvée dans %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Nessuna estensione Campfire cmux trovata in %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ に Campfire cmux 拡張が見つかりません" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "រកមិនឃើញ​ផ្នែកបន្ថែម Campfire cmux នៅ %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에서 Campfire cmux 확장을 찾을 수 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fant ingen Campfire cmux-utvidelse i %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Nie znaleziono rozszerzenia Campfire cmux w %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Nenhuma extensão Campfire cmux encontrada em %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расширение Campfire cmux в %@ не найдено" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ไม่พบส่วนขยาย Campfire cmux ที่ %@" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ konumunda Campfire cmux uzantısı bulunamadı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розширення Campfire cmux у %@ не знайдено" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "未在 %@ 找到 Campfire cmux 扩展" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "未在 %@ 找到 Campfire cmux 擴充功能" + } + } + } + }, + "cli.hooks.campfire.refuseRemoveMissingMarker": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تم رفض إزالة %@: علامة cmux مفقودة" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Odbijam uklanjanje %@: nedostaje cmux oznaka" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Nægter at fjerne %@: manglende cmux-markør" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "%@ wird nicht entfernt: cmux-Markierung fehlt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Refusing to remove %@: missing cmux marker" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se rechaza eliminar %@: falta el marcador de cmux" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Suppression de %@ refusée : marqueur cmux manquant" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Rimozione di %@ rifiutata: marcatore cmux mancante" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ の削除を拒否しました: cmux マーカーがありません" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បដិសេធមិនលុប %@៖ បាត់សញ្ញាសម្គាល់ cmux" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@ 삭제를 거부합니다: cmux 마커가 없습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Nekter å fjerne %@: manglende cmux-markør" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Odmowa usunięcia %@: brak znacznika cmux" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Recusando remover %@: marcador do cmux ausente" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Отказ в удалении %@: отсутствует маркер cmux" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ปฏิเสธที่จะลบ %@: ไม่มีเครื่องหมาย cmux" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "%@ kaldırılmayı reddediliyor: cmux işareti eksik" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Відмовлено у видаленні %@: відсутній маркер cmux" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "拒绝移除 %@:缺少 cmux 标记" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "拒絕移除 %@:缺少 cmux 標記" + } + } + } + }, + "cli.hooks.campfire.removed": { + "extractionState": "manual", + "localizations": { + "ar": { + "stringUnit": { + "state": "translated", + "value": "تمت إزالة امتداد Campfire cmux من %@" + } + }, + "bs": { + "stringUnit": { + "state": "translated", + "value": "Campfire cmux ekstenzija je uklonjena iz %@" + } + }, + "da": { + "stringUnit": { + "state": "translated", + "value": "Campfire cmux-udvidelse blev fjernet fra %@" + } + }, + "de": { + "stringUnit": { + "state": "translated", + "value": "Campfire-cmux-Erweiterung aus %@ entfernt" + } + }, + "en": { + "stringUnit": { + "state": "translated", + "value": "Removed Campfire cmux extension from %@" + } + }, + "es": { + "stringUnit": { + "state": "translated", + "value": "Se eliminó la extensión Campfire cmux de %@" + } + }, + "fr": { + "stringUnit": { + "state": "translated", + "value": "Extension Campfire cmux supprimée de %@" + } + }, + "it": { + "stringUnit": { + "state": "translated", + "value": "Estensione Campfire cmux rimossa da %@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ から Campfire cmux 拡張を削除しました" + } + }, + "km": { + "stringUnit": { + "state": "translated", + "value": "បានលុប​ផ្នែកបន្ថែម Campfire cmux ចេញពី %@" + } + }, + "ko": { + "stringUnit": { + "state": "translated", + "value": "%@에서 Campfire cmux 확장을 제거했습니다" + } + }, + "nb": { + "stringUnit": { + "state": "translated", + "value": "Fjernet Campfire cmux-utvidelse fra %@" + } + }, + "pl": { + "stringUnit": { + "state": "translated", + "value": "Usunięto rozszerzenie Campfire cmux z %@" + } + }, + "pt-BR": { + "stringUnit": { + "state": "translated", + "value": "Extensão Campfire cmux removida de %@" + } + }, + "ru": { + "stringUnit": { + "state": "translated", + "value": "Расширение Campfire cmux удалено из %@" + } + }, + "th": { + "stringUnit": { + "state": "translated", + "value": "ลบส่วนขยาย Campfire cmux ออกจาก %@ แล้ว" + } + }, + "tr": { + "stringUnit": { + "state": "translated", + "value": "Campfire cmux uzantısı %@ konumundan kaldırıldı" + } + }, + "uk": { + "stringUnit": { + "state": "translated", + "value": "Розширення Campfire cmux видалено з %@" + } + }, + "zh-Hans": { + "stringUnit": { + "state": "translated", + "value": "已从 %@ 移除 Campfire cmux 扩展" + } + }, + "zh-Hant": { + "stringUnit": { + "state": "translated", + "value": "已從 %@ 移除 Campfire cmux 擴充功能" + } + } + } } } } diff --git a/Sources/TaskManagerTypes.swift b/Sources/TaskManagerTypes.swift index f3818d4bc07c..3870359385cc 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -587,6 +587,14 @@ struct CmuxTaskManagerCodingAgentDefinition: Equatable { directBasenames: ["omp"], argumentNeedles: ["@oh-my-pi/pi-coding-agent"] ), + CmuxTaskManagerCodingAgentDefinition( + id: "campfire", + displayName: "Campfire", + assetName: nil, + launchKinds: ["campfire"], + directBasenames: ["campfire"], + argumentNeedles: ["packages/session/bin/campfire.ts", "dist/campfire"] + ), CmuxTaskManagerCodingAgentDefinition( id: "pi", displayName: "Pi", diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 019adaec36f0..e2f8fc825e01 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -1070,6 +1070,7 @@ enum PiSessionLocator { ?? process.environment["PI_CODING_AGENT_SESSION_DIR"] ?? configuredSessionDirectory(for: registration) ?? ompAgentSessionsRoot(for: process, registration: registration) + ?? campfireAgentSessionsRoot(for: process, registration: registration) ?? registration.sessionDirectory ?? defaultSessionsRoot() let expandedRoot = (sessionRoot as NSString).expandingTildeInPath @@ -1105,12 +1106,31 @@ enum PiSessionLocator { return (agentRoot as NSString).appendingPathComponent("sessions") } + private static func campfireAgentSessionsRoot( + for process: VaultObservedAgentProcess, + registration: CmuxVaultAgentRegistration + ) -> String? { + guard registration.id == "campfire" else { return nil } + if let sessionRoot = nonEmptyEnvironmentValue("CAMPFIRE_CODING_AGENT_SESSION_DIR", in: process.environment) { + return NSString(string: sessionRoot).expandingTildeInPath + } + guard let agentRoot = nonEmptyEnvironmentValue("CAMPFIRE_CODING_AGENT_DIR", in: process.environment) else { + return nil + } + let expandedAgentRoot = NSString(string: agentRoot).expandingTildeInPath + return (expandedAgentRoot as NSString).appendingPathComponent("sessions") + } + private static func configuredSessionDirectory(for registration: CmuxVaultAgentRegistration) -> String? { guard let sessionDirectory = registration.sessionDirectory else { return nil } if registration.id == "omp", sessionDirectory == CmuxVaultAgentRegistration.builtInOmp.sessionDirectory { return nil } + if registration.id == "campfire", + sessionDirectory == CmuxVaultAgentRegistration.builtInCampfire.sessionDirectory { + return nil + } return sessionDirectory } diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index 95d31f489830..4918cfcc4b25 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -141,6 +141,21 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { ) } + static var builtInCampfire: CmuxVaultAgentRegistration { + CmuxVaultAgentRegistration( + id: "campfire", + name: "Campfire", + detect: CmuxVaultAgentDetectRule( + processName: "campfire", + alternateArgvContains: ["campfire"] + ), + sessionIdSource: .piSessionFile, + resumeCommand: "{{executable}} --session {{sessionId}}", + cwd: .preserve, + sessionDirectory: "~/.campfire/agent/sessions" + ) + } + static var builtInAntigravity: CmuxVaultAgentRegistration { CmuxVaultAgentRegistration( id: "antigravity", @@ -380,6 +395,7 @@ struct CmuxVaultAgentRegistry: Sendable { var registrations = [ CmuxVaultAgentRegistration.builtInPi, CmuxVaultAgentRegistration.builtInOmp, + CmuxVaultAgentRegistration.builtInCampfire, CmuxVaultAgentRegistration.builtInAntigravity, CmuxVaultAgentRegistration.builtInGrok, ] diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index bcaca40b6d76..cc3dff77872c 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -107,6 +107,7 @@ C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */; }; A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35530000000000000102 /* BundledCLILinkageTests.swift */; }; + 0A0F00550000000000000003 /* CampfireSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0F00550000000000000004 /* CampfireSupportTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; }; C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */; }; @@ -147,6 +148,7 @@ 53750003A0B1C2D3E4F50003 /* CmuxAuthRuntime in Frameworks */ = {isa = PBXBuildFile; productRef = 53750002A0B1C2D3E4F50002 /* CmuxAuthRuntime */; }; B9000063A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */; }; B9000068A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */; }; + B9000074A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000075A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift */; }; B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */; }; B9000035A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */; }; B9000064A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */ = {isa = PBXBuildFile; fileRef = B9000065A1B2C3D4E5F60719 /* CMUXCLI+Events.swift */; }; @@ -891,6 +893,7 @@ C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebContentProcessTests.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; C0DE35530000000000000102 /* BundledCLILinkageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledCLILinkageTests.swift; sourceTree = ""; }; + 0A0F00550000000000000004 /* CampfireSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CampfireSupportTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = ""; }; C13519000000000000000008 /* ClaudeConfigDirectoryPathTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = ClaudeConfigDirectoryPathTests.swift; sourceTree = ""; }; @@ -926,6 +929,7 @@ D35110010000000000000002 /* CmuxApplicationSupportDirectories.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CmuxApplicationSupportDirectories.swift; sourceTree = ""; }; B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AgentHookDefinitions.swift"; sourceTree = ""; }; B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+AmpExtension.swift"; sourceTree = ""; }; + B9000075A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+CampfireExtension.swift"; sourceTree = ""; }; B9000051A1B2C3D4E5F60719 /* CMUXCLI+Config.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Config.swift"; sourceTree = ""; }; B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+DocsSettings.swift"; sourceTree = ""; }; B9000065A1B2C3D4E5F60719 /* CMUXCLI+Events.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CMUXCLI+Events.swift"; sourceTree = ""; }; @@ -2088,6 +2092,7 @@ B9000062A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift */, B9000069A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift */, B9000073A1B2C3D4E5F60719 /* CMUXCLI+OmpExtension.swift */, + B9000075A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift */, B9000070A1B2C3D4E5F60719 /* CMUXCLI+Memory.swift */, B9000031A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift */, B900002DA1B2C3D4E5F60719 /* CMUXCLI+ThemeSupport.swift */, @@ -2268,6 +2273,7 @@ C0DE35530000000000000102 /* BundledCLILinkageTests.swift */, C37800000000000000000002 /* VMSSHCommandTests.swift */, 0A0F00550000000000000002 /* OmpSupportTests.swift */, + 0A0F00550000000000000004 /* CampfireSupportTests.swift */, A5E01204A1B2C3D4E5F60718 /* OpenCodeHookRegressionTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, C1A2B3C4D5E6F70800000002 /* CmuxConfigTests.swift */, @@ -3106,6 +3112,7 @@ D35110010000000000000003 /* CmuxApplicationSupportDirectories.swift in Sources */, B9000063A1B2C3D4E5F60719 /* CMUXCLI+AgentHookDefinitions.swift in Sources */, B9000068A1B2C3D4E5F60719 /* CMUXCLI+AmpExtension.swift in Sources */, + B9000074A1B2C3D4E5F60719 /* CMUXCLI+CampfireExtension.swift in Sources */, B9000050A1B2C3D4E5F60719 /* CMUXCLI+Config.swift in Sources */, B9000035A1B2C3D4E5F60719 /* CMUXCLI+DocsSettings.swift in Sources */, B9000064A1B2C3D4E5F60719 /* CMUXCLI+Events.swift in Sources */, @@ -3211,6 +3218,7 @@ 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */, C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */, + 0A0F00550000000000000003 /* CampfireSupportTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */, C13519000000000000000007 /* ClaudeConfigDirectoryPathTests.swift in Sources */, diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift new file mode 100644 index 000000000000..72cc541913b9 --- /dev/null +++ b/cmuxTests/CampfireSupportTests.swift @@ -0,0 +1,181 @@ +import Foundation +import Testing + +#if canImport(cmux_DEV) +@testable import cmux_DEV +#elseif canImport(cmux) +@testable import cmux +#endif + +@Suite("Campfire support") +struct CampfireSupportTests { + @Test func directProcessDetectionUsesExplicitSessionSelectorsBeforeLatestFallback() throws { + struct Selector { + let name: String + let arguments: [String] + } + + let selectors = [ + Selector(name: "--session value", arguments: ["--session", "explicit-campfire-session"]), + Selector(name: "--session=value", arguments: ["--session=explicit-campfire-session"]), + ] + + for selector in selectors { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-explicit-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + let explicit = try Self.writeSessionFile( + id: "explicit-campfire-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 1_000) + ) + let latest = try Self.writeSessionFile( + id: "latest-campfire-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let selectorComment = Comment(rawValue: selector.name) + let detected = try #require(Self.detectedCampfireSnapshot( + arguments: ["/Users/example/.local/bin/campfire"] + selector.arguments, + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + ), selectorComment) + + #expect(detected.kind == RestorableAgentKind.custom("campfire"), selectorComment) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(explicit.path), selectorComment) + #expect(Self.normalizedPath(detected.sessionId) != Self.normalizedPath(latest.path), selectorComment) + #expect(detected.workingDirectory == workspace.path, selectorComment) + } + } + + @Test func directProcessDetectionUsesCampfireAgentDirectorySessionsWhenNoSessionDirectoryIsSet() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-agent-dir-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let agentRoot = root.appendingPathComponent("campfire-agent", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = agentRoot + .appendingPathComponent("sessions", isDirectory: true) + .appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + let latest = try Self.writeSessionFile( + id: "campfire-agent-dir-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = try #require(Self.detectedCampfireSnapshot( + arguments: ["/Users/example/.local/bin/campfire"], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_DIR": agentRoot.path, + ] + )) + + #expect(detected.kind == RestorableAgentKind.custom("campfire")) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.workingDirectory == workspace.path) + } + + @Test func taskManagerClassifiesCampfireCompiledBinaryAndDevInvocation() throws { + let compiled = try #require(CmuxTaskManagerCodingAgentDefinition.matchingDefinition( + processName: "campfire", + processPath: "/Users/example/.local/bin/campfire", + arguments: ["/Users/example/.local/bin/campfire", "--relay", "wss://relay.example/ws"], + environment: [:] + )) + #expect(compiled.id == "campfire") + + let dev = try #require(CmuxTaskManagerCodingAgentDefinition.matchingDefinition( + processName: "bun", + processPath: "/opt/homebrew/bin/bun", + arguments: [ + "/opt/homebrew/bin/bun", + "/Users/example/campfire/packages/session/bin/campfire.ts", + ], + environment: [:] + )) + #expect(dev.id == "campfire") + } + + @Test func builtInCampfireRegistrationResumesWithBareSessionId() throws { + let registration = CmuxVaultAgentRegistration.builtInCampfire + #expect(registration.id == "campfire") + #expect(registration.resumeCommand == "{{executable}} --session {{sessionId}}") + #expect(registration.sessionDirectory == "~/.campfire/agent/sessions") + } + + private static func detectedCampfireSnapshot( + processName: String = "campfire", + processPath: String? = "/Users/example/.local/bin/campfire", + arguments: [String], + environment: [String: String], + registration: CmuxVaultAgentRegistration = .builtInCampfire + ) -> SessionRestorableAgentSnapshot? { + let workspaceId = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! + let panelId = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! + let processId = 4243 + let panelKey = RestorableAgentSessionIndex.PanelKey(workspaceId: workspaceId, panelId: panelId) + let processSnapshot = CmuxTopProcessSnapshot( + processes: [ + CmuxTopProcessInfo( + pid: processId, + parentPID: 1, + name: processName, + path: processPath, + ttyDevice: nil, + cmuxWorkspaceID: workspaceId, + cmuxSurfaceID: panelId, + cmuxAttributionReason: "cmux-test", + processGroupID: nil, + terminalProcessGroupID: nil, + cpuPercent: 0, + residentBytes: 0, + virtualBytes: 0, + threadCount: 1 + ) + ], + sampledAt: Date(timeIntervalSince1970: 0), + includesProcessDetails: true + ) + return RestorableAgentSessionIndex.processDetectedSnapshots( + registry: CmuxVaultAgentRegistry(registrations: [registration]), + fileManager: FileManager.default, + processSnapshot: processSnapshot, + capturedAt: 42, + processArgumentsProvider: { requestedProcessId in + guard requestedProcessId == processId else { return nil } + return CmuxTopProcessArguments(arguments: arguments, environment: environment) + } + )[panelKey]?.snapshot + } + + private static func normalizedPath(_ path: String) -> String { + URL(fileURLWithPath: path).resolvingSymlinksInPath().path + } + + private static func makeTemporaryDirectory(prefix: String) throws -> URL { + let url = FileManager.default.temporaryDirectory.resolvingSymlinksInPath() + .appendingPathComponent(prefix + UUID().uuidString, isDirectory: true) + try FileManager.default.createDirectory(at: url, withIntermediateDirectories: true) + return url + } + + private static func writeSessionFile(id: String, in directory: URL, modifiedAt: Date) throws -> URL { + let url = directory.appendingPathComponent("\(id).jsonl", isDirectory: false) + try "{}\n".write(to: url, atomically: true, encoding: .utf8) + try FileManager.default.setAttributes([.modificationDate: modifiedAt], ofItemAtPath: url.path) + return url + } +} diff --git a/docs/agent-hooks.md b/docs/agent-hooks.md index 60d9010a5ead..3b978c64e038 100644 --- a/docs/agent-hooks.md +++ b/docs/agent-hooks.md @@ -11,7 +11,7 @@ cmux hooks setup --agent cmux hooks uninstall ``` -Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `cursor`, `gemini`, `kiro`, `rovodev` (or `rovo`), `copilot`, `codebuddy`, `factory`, and `qoder`. `cmux hooks setup` skips agents whose binary is not on `PATH` and prints a summary. +Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `campfire`, `amp`, `cursor`, `gemini`, `kiro`, `rovodev` (or `rovo`), `copilot`, `codebuddy`, `factory`, and `qoder`. `cmux hooks setup` skips agents whose binary is not on `PATH` and prints a summary. ## Integrations @@ -23,6 +23,7 @@ Supported agent names are `codex`, `grok`, `opencode`, `pi`, `omp`, `amp`, `curs | OpenCode | `opencode` | `~/.config/opencode/plugins/cmux-session.js`, `~/.config/opencode/plugins/cmux-feed.js` | `opencode --session ` | plugin event bus | | Pi | `pi` | `~/.pi/agent/extensions/cmux-session.ts` | `pi --session ` | none | | OMP | `omp` | `~/.omp/agent/extensions/cmux-omp-session.ts` or `$PI_CODING_AGENT_DIR/extensions/cmux-omp-session.ts` | `omp --session ` | none | +| Campfire | `campfire` | `~/.campfire/agent/extensions/cmux-campfire-session.ts` or `$CAMPFIRE_CODING_AGENT_DIR/extensions/cmux-campfire-session.ts` | `campfire --session ` | none | | Amp | `amp` | `~/.config/amp/plugins/cmux-session.ts` | `amp threads continue ` | none | | Cursor CLI | `cursor-agent` | `~/.cursor/hooks.json` | `cursor-agent --resume ` | beforeShellExecution | | Gemini | `gemini` | `~/.gemini/settings.json` | `gemini --resume ` | PreToolUse | @@ -132,6 +133,7 @@ and browser state. Restored agent terminals stay idle until you resume them manu | OpenCode | `OPENCODE_CONFIG_DIR` | `CMUX_OPENCODE_HOOKS_DISABLED=1` | | Pi | `PI_CODING_AGENT_DIR` | `CMUX_PI_HOOKS_DISABLED=1` | | OMP | `PI_CODING_AGENT_DIR` for the full agent directory; otherwise `PI_CONFIG_DIR` for the config root | `CMUX_OMP_HOOKS_DISABLED=1` | +| Campfire | `CAMPFIRE_CODING_AGENT_DIR` | `CMUX_CAMPFIRE_HOOKS_DISABLED=1` | | Amp | none | `CMUX_AMP_HOOKS_DISABLED=1` | | Cursor CLI | none | `CMUX_CURSOR_HOOKS_DISABLED=1` | | Gemini | none | `CMUX_GEMINI_HOOKS_DISABLED=1` | @@ -146,6 +148,8 @@ Pi uses Pi's extension system, not the legacy Pi hooks API. The installed extens OMP uses OMP's native extension system. OMP native extension discovery scans `${PI_CODING_AGENT_DIR:-~/${PI_CONFIG_DIR:-.omp}/agent}/extensions/`, so cmux installs OMP's extension with a distinct `cmux-omp-session.ts` filename and does not reuse Pi's `cmux-session.ts`. +Campfire ships this integration natively: current campfire versions include a built-in cmux bridge, so no install step is needed (like Claude Code via the cmux wrapper) — `cmux hooks campfire install` exists for older campfire versions, and the installed extension defers to the native bridge when both are present. Campfire embeds vanilla Pi under a `.campfire` white-label, so its extension discovery scans `${CAMPFIRE_CODING_AGENT_DIR:-~/.campfire/agent}/extensions/`. The cmux extension records only the HOST role (`CAMPFIRE_SESSION_ROLE=host`); a joiner is an ephemeral view whose argv carries the invite URL — a capability token that is never persisted or replayed. The extension also subscribes to campfire's in-process observer bridge and surfaces driver-actionable collaborative moments (a joiner waiting in the lobby, a capability ask) as cmux notifications. + Kiro stores hooks inside agent configuration files. The cmux installer creates or updates a `cmux` agent config with lifecycle, tool, and completion hooks; merge the generated `hooks` block into another Kiro agent config if you want the same cmux notifications on that agent. Kiro Feed verbosity follows **Settings > Automation > Kiro Notification Level** or `automation.kiroNotificationLevel` in `cmux.json`. `minimal` keeps actionable approval cards only, `standard` also keeps mutating tool events, and `verbose` keeps every Kiro tool event. @@ -154,6 +158,6 @@ Kiro Feed verbosity follows **Settings > Automation > Kiro Notification Level** Run `cmux hooks install --yes` to reinstall one integration. Run `cmux hooks uninstall --yes` before editing generated files by hand. -If Feed shows nothing, confirm the terminal has `CMUX_SURFACE_ID` and the hook file contains a `cmux hooks feed --source ` command or OpenCode feed plugin. Pi, OMP, and Rovo Dev currently provide lifecycle and restore hooks only, so they do not create Feed approval cards. Amp's bundled plugin reports live tab-status updates (idle / thinking / running / reading / done / error / interrupted) and lifecycle restore but does not create Feed approval cards. +If Feed shows nothing, confirm the terminal has `CMUX_SURFACE_ID` and the hook file contains a `cmux hooks feed --source ` command or OpenCode feed plugin. Pi, OMP, Campfire, and Rovo Dev currently provide lifecycle and restore hooks only, so they do not create Feed approval cards. Amp's bundled plugin reports live tab-status updates (idle / thinking / running / reading / done / error / interrupted) and lifecycle restore but does not create Feed approval cards. If relaunch does not resume an agent, check `~/.cmuxterm/-hook-sessions.json` for the saved session and verify the agent's resume command still works outside cmux. diff --git a/docs/feed.md b/docs/feed.md index 43d77f1a87c2..8e646c6bab8b 100644 --- a/docs/feed.md +++ b/docs/feed.md @@ -86,6 +86,7 @@ Installs supported agent hooks whose binaries are on `PATH`. See [Agent hook int | Qoder | `~/.qoder/settings.json` | PreToolUse | | Pi | `~/.pi/agent/extensions/cmux-session.ts` | lifecycle only | | OMP | `~/.omp/agent/extensions/cmux-omp-session.ts` or `$PI_CODING_AGENT_DIR/extensions/cmux-omp-session.ts` | lifecycle only | +| Campfire | `~/.campfire/agent/extensions/cmux-campfire-session.ts` or `$CAMPFIRE_CODING_AGENT_DIR/extensions/cmux-campfire-session.ts` | lifecycle + collaborative notifications | | Rovo Dev | `~/.rovodev/config.yml` | lifecycle only | Individual agents: @@ -99,7 +100,7 @@ cmux hooks uninstall Agents without a binary on `PATH` are skipped at install time, and `cmux hooks setup` prints a summary line naming the ones it skipped. Use `cmux hooks setup --agent ` or `cmux hooks setup ` to install one integration, and `cmux hooks uninstall --agent ` or `cmux hooks uninstall ` to remove one. Rovo Dev accepts either `rovodev` or `rovo`. -Pi, OMP, and Rovo Dev provide lifecycle and session-restore hooks only; they do not install a Feed permission bridge. +Pi, OMP, Campfire, and Rovo Dev provide lifecycle and session-restore hooks only; they do not install a Feed permission bridge. Campfire additionally surfaces collaborative moments (a joiner waiting in the lobby, a capability ask awaiting the driver) as cmux notifications. ## Decision semantics diff --git a/docs/vault.md b/docs/vault.md index 9527d1e85c62..1a6e8c546259 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -4,7 +4,7 @@ Vault restores built-in agent sessions and can also read custom agent registrati `cmux.json`. Registrations define how cmux detects a running terminal process, where the agent's native session id comes from, and which command resumes that session. -Pi Coding Agent and OMP are registered by default: +Pi Coding Agent, OMP, and Campfire are registered by default: ```jsonc { @@ -32,6 +32,17 @@ Pi Coding Agent and OMP are registered by default: "resumeCommand": "{{executable}} --session {{sessionId}}", "cwd": "preserve", "sessionDirectory": "~/.omp/agent/sessions" + }, + { + "id": "campfire", + "name": "Campfire", + "detect": { + "processName": "campfire" + }, + "sessionIdSource": { "type": "piSessionFile" }, + "resumeCommand": "{{executable}} --session {{sessionId}}", + "cwd": "preserve", + "sessionDirectory": "~/.campfire/agent/sessions" } ] } @@ -66,6 +77,8 @@ Supported `resumeCommand` placeholders are `{{sessionId}}`, `{{sessionPath}}`, instead of `pi --continue` so Vault reopens the exact saved session. OMP accepts `--session`, `--resume`, and `-r` for existing sessions; Vault emits `omp --session ` so relaunch reopens the exact saved OMP session. +Campfire resumes with `campfire --session `; only the driver's host session is restorable (joiners are ephemeral views), and resuming restores the conversation in a fresh collaborative session with a new invite link. + `resumeCommand` must include either `{{sessionId}}` or `{{sessionPath}}`, for example `pi --session {{sessionId}}`. diff --git a/tests/test_campfire_extension_install.py b/tests/test_campfire_extension_install.py new file mode 100644 index 000000000000..7412ae4934ac --- /dev/null +++ b/tests/test_campfire_extension_install.py @@ -0,0 +1,641 @@ +#!/usr/bin/env python3 +""" +Regression test: the generated Campfire extension installs cleanly, emits cmux +hook calls with complete payloads for the HOST role only, normalizes the +bun-compiled launch argv, bridges campfire observer events to notifications, +and persists restorable hook sessions without secrets or invite URLs. +""" + +from __future__ import annotations + +import base64 +import json +import os +import shutil +import subprocess +import socket +import tempfile +import time +import threading +from pathlib import Path + +from claude_teams_test_utils import resolve_cmux_cli + + +def make_executable(path: Path, content: str) -> None: + path.write_text(content, encoding="utf-8") + path.chmod(0o755) + + +def wait_for_text(path: Path, expected_count: int, timeout: float = 5.0) -> str: + deadline = time.monotonic() + timeout + while time.monotonic() < deadline: + if path.exists(): + text = path.read_text(encoding="utf-8") + if len([line for line in text.splitlines() if line.strip()]) >= expected_count: + return text + time.sleep(0.05) + return path.read_text(encoding="utf-8") if path.exists() else "" + + +class MockCmuxSocket: + def __init__(self, path: Path, workspace_id: str, surface_id: str) -> None: + self.path = path + self.workspace_id = workspace_id + self.surface_id = surface_id + self._messages: list[str] = [] + self._lock = threading.Lock() + self._stop = threading.Event() + self._server: socket.socket | None = None + self._thread: threading.Thread | None = None + + def __enter__(self) -> "MockCmuxSocket": + try: + self.path.unlink() + except FileNotFoundError: + pass + server = socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) + server.bind(str(self.path)) + server.listen(16) + server.settimeout(0.1) + self._server = server + self._thread = threading.Thread(target=self._serve, daemon=True) + self._thread.start() + return self + + def __exit__(self, *_exc: object) -> None: + self._stop.set() + try: + with socket.socket(socket.AF_UNIX, socket.SOCK_STREAM) as client: + client.connect(str(self.path)) + except OSError: + pass + if self._thread is not None: + self._thread.join(timeout=2) + if self._server is not None: + self._server.close() + try: + self.path.unlink() + except FileNotFoundError: + pass + + def messages(self) -> list[str]: + with self._lock: + return list(self._messages) + + def _serve(self) -> None: + assert self._server is not None + while not self._stop.is_set(): + try: + conn, _addr = self._server.accept() + except TimeoutError: + continue + except OSError: + return + self._handle(conn) + + def _handle(self, conn: socket.socket) -> None: + with conn: + reader = conn.makefile("rb") + while True: + line_bytes = reader.readline() + if not line_bytes: + return + line = line_bytes.decode("utf-8", errors="replace").rstrip("\n") + if line: + with self._lock: + self._messages.append(line) + response = self._response(line) + try: + conn.sendall(response.encode("utf-8") + b"\n") + except BrokenPipeError: + return + + def _response(self, line: str) -> str: + try: + payload = json.loads(line) + except json.JSONDecodeError: + return "OK" + request_id = payload.get("id") or "unknown" + method = payload.get("method") + if method == "surface.list": + result = { + "surfaces": [ + { + "id": self.surface_id, + "ref": "surface:1", + "focused": True, + } + ] + } + elif method == "surface.resume.set": + result = {"ok": True} + elif method == "feed.push": + result = {} + else: + result = {} + return json.dumps({"id": request_id, "ok": True, "result": result}, separators=(",", ":")) + + +def json_rpc_messages(messages: list[str], method: str) -> list[dict[str, object]]: + matches: list[dict[str, object]] = [] + for line in messages: + try: + payload = json.loads(line) + except json.JSONDecodeError: + continue + if payload.get("method") == method: + matches.append(payload) + return matches + + +def verify_hook_persistence(cli_path: str, root: Path, base_env: dict[str, str]) -> bool: + hook_state_dir = root / "hook-state" + workspace = root / "hook-workspace" + hook_state_dir.mkdir() + workspace.mkdir() + workspace_id = "11111111-1111-1111-1111-111111111111" + surface_id = "22222222-2222-2222-2222-222222222222" + session_id = "campfire-hook-session-123" + socket_path = Path("/tmp") / f"cmux-campfire-hook-{os.getpid()}-{time.monotonic_ns()}.sock" + launch_argv = [ + "/Users/example/.local/bin/campfire", + "--session", + "old-session", + "--relay", + "wss://relay.example/ws", + "--model", + "anthropic/claude-sonnet-4-5", + "initial prompt should not persist", + ] + hook_env = base_env.copy() + hook_env.pop("CAMPFIRE_CODING_AGENT_DIR", None) + hook_env.update( + { + "PWD": str(workspace), + "CMUX_SOCKET_PATH": str(socket_path), + "CMUX_WORKSPACE_ID": workspace_id, + "CMUX_SURFACE_ID": surface_id, + "CMUX_AGENT_HOOK_STATE_DIR": str(hook_state_dir), + "CMUX_AGENT_LAUNCH_KIND": "campfire", + "CMUX_AGENT_LAUNCH_EXECUTABLE": launch_argv[0], + "CMUX_AGENT_LAUNCH_ARGV_B64": base64.b64encode( + b"".join(value.encode("utf-8") + b"\0" for value in launch_argv) + ).decode("ascii"), + "CMUX_AGENT_LAUNCH_CWD": str(workspace), + "CMUX_CLI_SENTRY_DISABLED": "1", + "CAMPFIRE_RELAY_URL": "wss://relay.example/ws", + "PI_PACKAGE_DIR": "/tmp/stale-pi-cache-should-not-persist", + "OPENAI_API_KEY": "secret-should-not-persist", + } + ) + hook_input = json.dumps( + { + "session_id": session_id, + "cwd": str(workspace), + "hook_event_name": "SessionStart", + }, + separators=(",", ":"), + ) + + with MockCmuxSocket(socket_path, workspace_id=workspace_id, surface_id=surface_id) as server: + result = subprocess.run( + [cli_path, "hooks", "campfire", "session-start"], + input=hook_input, + capture_output=True, + text=True, + check=False, + env=hook_env, + timeout=20, + ) + if result.returncode != 0 or result.stdout != "{}\n": + print("FAIL: campfire session-start hook persistence command failed") + print(f"exit={result.returncode}") + print(f"stdout={result.stdout.strip()}") + print(f"stderr={result.stderr.strip()}") + return False + deadline = time.monotonic() + 5 + while time.monotonic() < deadline: + if json_rpc_messages(server.messages(), "surface.resume.set"): + break + time.sleep(0.05) + messages = server.messages() + + store_path = hook_state_dir / "campfire-hook-sessions.json" + if not store_path.exists(): + print(f"FAIL: campfire hook did not write {store_path}") + return False + try: + store = json.loads(store_path.read_text(encoding="utf-8")) + session = store["sessions"][session_id] + except Exception as exc: + print(f"FAIL: campfire hook session store did not contain {session_id}: {exc}") + print(store_path.read_text(encoding="utf-8")) + return False + + expected_fields = { + "sessionId": session_id, + "workspaceId": workspace_id, + "surfaceId": surface_id, + "cwd": str(workspace), + } + for key, expected in expected_fields.items(): + if session.get(key) != expected: + print(f"FAIL: campfire hook session {key} expected {expected!r}, got {session.get(key)!r}") + return False + + launch_command = session.get("launchCommand") + if not isinstance(launch_command, dict): + print(f"FAIL: campfire hook did not persist launch metadata: {session!r}") + return False + expected_arguments = [ + "/Users/example/.local/bin/campfire", + "--relay", + "wss://relay.example/ws", + "--model", + "anthropic/claude-sonnet-4-5", + ] + if launch_command.get("launcher") != "campfire" or launch_command.get("executablePath") != launch_argv[0]: + print(f"FAIL: campfire hook persisted wrong launcher metadata: {launch_command!r}") + return False + if launch_command.get("arguments") != expected_arguments: + print(f"FAIL: campfire hook persisted unsanitized launch arguments: {launch_command!r}") + return False + if launch_command.get("workingDirectory") != str(workspace): + print(f"FAIL: campfire hook persisted wrong working directory: {launch_command!r}") + return False + if launch_command.get("environment") != {"CAMPFIRE_RELAY_URL": "wss://relay.example/ws"}: + print(f"FAIL: campfire hook persisted wrong resume environment: {launch_command!r}") + return False + persisted = json.dumps(session, sort_keys=True) + if "secret-should-not-persist" in persisted: + print(f"FAIL: campfire hook persisted secret environment data: {session!r}") + return False + if "stale-pi-cache-should-not-persist" in persisted: + print(f"FAIL: campfire hook persisted self-managed PI_PACKAGE_DIR: {session!r}") + return False + + resume_sets = json_rpc_messages(messages, "surface.resume.set") + if len(resume_sets) != 1: + print(f"FAIL: expected one surface.resume.set, saw {messages!r}") + return False + params = resume_sets[0].get("params") + if not isinstance(params, dict): + print(f"FAIL: surface.resume.set missing params: {resume_sets[0]!r}") + return False + if params.get("kind") != "campfire" or params.get("checkpoint_id") != session_id or params.get("auto_resume") is not True: + print(f"FAIL: surface.resume.set had wrong Campfire binding params: {params!r}") + return False + command = params.get("command") + if not isinstance(command, str) or "--session" not in command or session_id not in command: + print(f"FAIL: surface.resume.set command cannot resume Campfire session: {params!r}") + return False + return True + + +def main() -> int: + bun = shutil.which("bun") + if bun is None: + print("SKIP: bun not found") + return 0 + + try: + cli_path = resolve_cmux_cli() + except Exception as exc: + print(f"FAIL: {exc}") + return 1 + + with tempfile.TemporaryDirectory(prefix="cmux-campfire-extension-") as td: + root = Path(td) + home = root / "home" + home.mkdir() + + env = os.environ.copy() + env["HOME"] = str(home) + env.pop("CAMPFIRE_CODING_AGENT_DIR", None) + + install = subprocess.run( + [cli_path, "hooks", "campfire", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if install.returncode != 0: + print("FAIL: campfire extension install failed") + print(f"exit={install.returncode}") + print(f"stdout={install.stdout.strip()}") + print(f"stderr={install.stderr.strip()}") + return 1 + + extension_path = home / ".campfire" / "agent" / "extensions" / "cmux-campfire-session.ts" + if not extension_path.exists(): + print(f"FAIL: expected extension at {extension_path}") + return 1 + extension_text = extension_path.read_text(encoding="utf-8") + if "cmux-campfire-session-extension-marker" not in extension_text: + print(f"FAIL: expected cmux marker in {extension_path}") + return 1 + + reinstall = subprocess.run( + [cli_path, "hooks", "campfire", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if reinstall.returncode != 0 or "already up to date" not in reinstall.stdout: + print("FAIL: campfire extension reinstall was not idempotent") + print(f"exit={reinstall.returncode}") + print(f"stdout={reinstall.stdout.strip()}") + print(f"stderr={reinstall.stderr.strip()}") + return 1 + + fake_cmux = root / "fake-cmux" + fake_args_log = root / "fake-cmux-args.log" + fake_stdin_log = root / "fake-cmux-stdin.log" + fake_env_log = root / "fake-cmux-env.log" + make_executable( + fake_cmux, + """#!/usr/bin/env bash +set -euo pipefail +printf '%s\n' "$*" >> "$FAKE_CMUX_ARGS_LOG" +cat >> "$FAKE_CMUX_STDIN_LOG" +printf '\n---\n' >> "$FAKE_CMUX_STDIN_LOG" +{ + printf 'kind=%s\n' "${CMUX_AGENT_LAUNCH_KIND-}" + printf 'cwd=%s\n' "${CMUX_AGENT_LAUNCH_CWD-}" + printf 'argv=%s\n' "${CMUX_AGENT_LAUNCH_ARGV_B64-}" +} >> "$FAKE_CMUX_ENV_LOG" +""", + ) + + check_env = env.copy() + check_env["CMUX_TEST_CAMPFIRE_EXTENSION_PATH"] = str(extension_path) + check_env["CMUX_SURFACE_ID"] = "surface-campfire-test" + check_env["CMUX_CAMPFIRE_CMUX_BIN"] = str(fake_cmux) + check_env["FAKE_CMUX_ARGS_LOG"] = str(fake_args_log) + check_env["FAKE_CMUX_STDIN_LOG"] = str(fake_stdin_log) + check_env["FAKE_CMUX_ENV_LOG"] = str(fake_env_log) + check_env["CAMPFIRE_SESSION_ROLE"] = "host" + check_source = """ +const extensionPath = process.env.CMUX_TEST_CAMPFIRE_EXTENSION_PATH; +const mod = await import(extensionPath); +if (typeof mod.default !== "function") throw new Error("missing default export"); +const handlers = new Map(); +mod.default({ + on(name, handler) { + handlers.set(name, handler); + } +}); +for (const name of ["session_start", "before_agent_start", "agent_end"]) { + if (typeof handlers.get(name) !== "function") throw new Error(`missing ${name}`); +} +// Simulate the bun-compiled campfire binary: real binary at argv[0], the bunfs +// virtual entry at argv[1]. The captured launch argv must drop the bunfs entry. +process.argv.splice( + 0, + process.argv.length, + "/Users/example/.local/bin/campfire", + "/$bunfs/root/campfire", + "--relay", + "wss://relay.example/ws" +); +const ctx = { + cwd: "/tmp/campfire-project", + sessionManager: { + getSessionId() { return "campfire-session-test"; } + } +}; +const start = Date.now(); +await handlers.get("session_start")({}, ctx); +await handlers.get("before_agent_start")({ prompt: "hello campfire" }, ctx); +await handlers.get("agent_end")({ + messages: [ + { role: "user", content: "hello campfire" }, + { role: "assistant", content: [{ type: "text", text: "done" }] } + ], + stopReason: "completed" +}, ctx); +// Campfire publishes observer events on a versioned global bridge; the +// extension subscribed at load. Emitting a join request must produce a +// notification hook call. +const bridge = globalThis[Symbol.for("campfire.observer.v1")]; +if (!bridge || bridge.listeners.size === 0) throw new Error("extension did not subscribe to the observer bridge"); +for (const listener of bridge.listeners) { + listener({ type: "join.requested", displayName: "alice" }); + listener({ type: "presence.changed", count: 1 }); +} +const elapsed = Date.now() - start; +if (elapsed > 2000) throw new Error(`handlers blocked for ${elapsed}ms`); +await new Promise((resolve) => setTimeout(resolve, 300)); +""" + check = subprocess.run( + [bun, "--eval", check_source], + cwd=root, + capture_output=True, + text=True, + check=False, + env=check_env, + timeout=20, + ) + if check.returncode != 0: + print("FAIL: generated Campfire extension is not importable or blocks handlers") + print(f"exit={check.returncode}") + print(f"stdout={check.stdout.strip()}") + print(f"stderr={check.stderr.strip()}") + return 1 + + expected_invocations = 4 + args_log = wait_for_text(fake_args_log, expected_invocations) + stdin_log = wait_for_text(fake_stdin_log, expected_invocations * 2) + env_log = wait_for_text(fake_env_log, expected_invocations * 3) + for expected in [ + "hooks campfire session-start", + "hooks campfire prompt-submit", + "hooks campfire stop", + "hooks campfire notification", + ]: + if expected not in args_log: + print(f"FAIL: extension did not invoke {expected}, got {args_log!r}") + return 1 + if stdin_log.count('"session_id":"campfire-session-test"') != 4: + print(f"FAIL: expected 4 hook payloads carrying the session id, got {stdin_log!r}") + return 1 + if '"prompt":"hello campfire"' not in stdin_log or '"last_assistant_message":"done"' not in stdin_log: + print(f"FAIL: extension did not pass prompt/assistant payload, got {stdin_log!r}") + return 1 + if '"message":"alice is waiting to join the campfire session"' not in stdin_log: + print(f"FAIL: extension did not bridge the join request notification, got {stdin_log!r}") + return 1 + if '"presence.changed"' in stdin_log: + print(f"FAIL: extension forwarded a non-actionable observer event, got {stdin_log!r}") + return 1 + if "kind=campfire" not in env_log or "cwd=/tmp/campfire-project" not in env_log: + print(f"FAIL: extension did not pass launch metadata environment, got {env_log!r}") + return 1 + argv_line = next((line for line in env_log.splitlines() if line.startswith("argv=")), "") + try: + decoded_argv = [ + value + for value in base64.b64decode(argv_line.removeprefix("argv=")).decode("utf-8").split("\0") + if value + ] + except Exception as exc: + print(f"FAIL: extension launch argv was not valid base64 NUL data: {exc}; env={env_log!r}") + return 1 + expected_argv = [ + "/Users/example/.local/bin/campfire", + "--relay", + "wss://relay.example/ws", + ] + if decoded_argv != expected_argv: + print(f"FAIL: extension captured wrong Campfire launch argv; expected {expected_argv!r}, got {decoded_argv!r}") + return 1 + + # The JOINER role must record nothing: its argv carries the invite URL, + # a capability token that must never be persisted or replayed. + joiner_args_log = root / "fake-cmux-joiner-args.log" + joiner_env = check_env.copy() + joiner_env["CAMPFIRE_SESSION_ROLE"] = "joiner" + joiner_env["FAKE_CMUX_ARGS_LOG"] = str(joiner_args_log) + joiner_check = subprocess.run( + [bun, "--eval", """ +const mod = await import(process.env.CMUX_TEST_CAMPFIRE_EXTENSION_PATH); +const handlers = new Map(); +mod.default({ on(name, handler) { handlers.set(name, handler); } }); +const ctx = { + cwd: "/tmp/campfire-project", + sessionManager: { getSessionId() { return "joiner-session"; } } +}; +await handlers.get("session_start")({}, ctx); +await handlers.get("agent_end")({ messages: [] }, ctx); +await new Promise((resolve) => setTimeout(resolve, 300)); +"""], + cwd=root, + capture_output=True, + text=True, + check=False, + env=joiner_env, + timeout=20, + ) + if joiner_check.returncode != 0: + print("FAIL: campfire extension errored under the joiner role") + print(f"stderr={joiner_check.stderr.strip()}") + return 1 + if joiner_args_log.exists() and joiner_args_log.read_text(encoding="utf-8").strip(): + print(f"FAIL: joiner role produced hook invocations: {joiner_args_log.read_text(encoding='utf-8')!r}") + return 1 + + # When campfire's NATIVE bridge owns the process (it publishes a flag on + # globalThis), this installed extension must go silent — otherwise every + # hook fires twice on machines that ran the installer against an older + # campfire and then upgraded. + native_args_log = root / "fake-cmux-native-args.log" + native_env = check_env.copy() + native_env["FAKE_CMUX_ARGS_LOG"] = str(native_args_log) + native_check = subprocess.run( + [bun, "--eval", """ +globalThis[Symbol.for("campfire.cmux.bridge.v1")] = true; +const mod = await import(process.env.CMUX_TEST_CAMPFIRE_EXTENSION_PATH); +const handlers = new Map(); +mod.default({ on(name, handler) { handlers.set(name, handler); } }); +const ctx = { + cwd: "/tmp/campfire-project", + sessionManager: { getSessionId() { return "native-owned-session"; } } +}; +await handlers.get("session_start")({}, ctx); +await new Promise((resolve) => setTimeout(resolve, 300)); +"""], + cwd=root, + capture_output=True, + text=True, + check=False, + env=native_env, + timeout=20, + ) + if native_check.returncode != 0: + print("FAIL: campfire extension errored under native-bridge deferral") + print(f"stderr={native_check.stderr.strip()}") + return 1 + if native_args_log.exists() and native_args_log.read_text(encoding="utf-8").strip(): + print(f"FAIL: installed extension did not defer to the native bridge: {native_args_log.read_text(encoding='utf-8')!r}") + return 1 + + if not verify_hook_persistence(cli_path, root, env): + return 1 + + uninstall = subprocess.run( + [cli_path, "hooks", "campfire", "uninstall", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if uninstall.returncode != 0 or extension_path.exists(): + print("FAIL: campfire extension uninstall failed") + print(f"exit={uninstall.returncode}") + print(f"stdout={uninstall.stdout.strip()}") + print(f"stderr={uninstall.stderr.strip()}") + return 1 + foreign_path = extension_path + foreign_path.parent.mkdir(parents=True, exist_ok=True) + foreign_path.write_text("// user extension\n", encoding="utf-8") + uninstall_foreign = subprocess.run( + [cli_path, "hooks", "campfire", "uninstall", "--yes"], + capture_output=True, + text=True, + check=False, + env=env, + timeout=20, + ) + if uninstall_foreign.returncode != 0 or not foreign_path.exists() or "Refusing to remove" not in uninstall_foreign.stdout: + print("FAIL: campfire extension uninstall did not preserve non-cmux file") + print(f"exit={uninstall_foreign.returncode}") + print(f"stdout={uninstall_foreign.stdout.strip()}") + print(f"stderr={uninstall_foreign.stderr.strip()}") + return 1 + foreign_path.unlink() + + # CAMPFIRE_CODING_AGENT_DIR overrides the agent dir for install/uninstall. + agent_override = root / "campfire-agent-override" + override_env = env.copy() + override_env["CAMPFIRE_CODING_AGENT_DIR"] = str(agent_override) + override_install = subprocess.run( + [cli_path, "hooks", "campfire", "install", "--yes"], + capture_output=True, + text=True, + check=False, + env=override_env, + timeout=20, + ) + override_extension_path = agent_override / "extensions" / "cmux-campfire-session.ts" + if override_install.returncode != 0 or not override_extension_path.exists(): + print("FAIL: campfire extension install did not respect CAMPFIRE_CODING_AGENT_DIR") + print(f"exit={override_install.returncode}") + print(f"stdout={override_install.stdout.strip()}") + print(f"stderr={override_install.stderr.strip()}") + return 1 + override_uninstall = subprocess.run( + [cli_path, "hooks", "campfire", "uninstall", "--yes"], + capture_output=True, + text=True, + check=False, + env=override_env, + timeout=20, + ) + if override_uninstall.returncode != 0 or override_extension_path.exists(): + print("FAIL: campfire extension uninstall did not respect CAMPFIRE_CODING_AGENT_DIR") + print(f"exit={override_uninstall.returncode}") + print(f"stdout={override_uninstall.stdout.strip()}") + print(f"stderr={override_uninstall.stderr.strip()}") + return 1 + print("PASS: generated Campfire extension installs, gates on the host role, emits complete cmux hook payloads, and persists hook sessions") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/web/app/[locale]/docs/session-restore/page.tsx b/web/app/[locale]/docs/session-restore/page.tsx index 00d5fd61a935..3bc53d53f912 100644 --- a/web/app/[locale]/docs/session-restore/page.tsx +++ b/web/app/[locale]/docs/session-restore/page.tsx @@ -102,6 +102,12 @@ cmux surface resume clear --checkpoint work`} omp --session <id> {t("none")} + + Campfire + campfire + campfire --session <id> + {t("none")} + Amp amp From 41cee0975e2ad2f97960f2cbf2245f9407ba181f Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Sun, 14 Jun 2026 21:07:37 +0530 Subject: [PATCH 02/24] Address PR review: Campfire session-dir precedence and relay-error privacy - VaultAgentProcessScanner: gate PI_CODING_AGENT_SESSION_DIR out of the campfire registration so Campfire (which embeds Pi) resolves sessions against CAMPFIRE_CODING_AGENT_SESSION_DIR / CAMPFIRE_CODING_AGENT_DIR instead of being silently pre-empted by a user's Pi session dir. pi/omp behavior unchanged. Adds a regression test. - CMUXCLI+CampfireExtension: drop the raw relay reason from the user-facing notification; emit a generic message per the error-privacy policy. - AgentLaunchEnvironmentPolicyTests: assert both pi and omp keep PI_PACKAGE_DIR (test previously only exercised pi). - test_campfire_extension_install: preserve falsey JSON-RPC ids (0) when echoing responses instead of rewriting them to "unknown". Co-Authored-By: Claude Opus 4.8 (1M context) --- CLI/CMUXCLI+CampfireExtension.swift | 4 +- .../AgentLaunchEnvironmentPolicyTests.swift | 10 ++++- Sources/VaultAgentProcessScanner.swift | 18 +++++++- cmuxTests/CampfireSupportTests.swift | 44 +++++++++++++++++++ tests/test_campfire_extension_install.py | 2 +- 5 files changed, 73 insertions(+), 5 deletions(-) diff --git a/CLI/CMUXCLI+CampfireExtension.swift b/CLI/CMUXCLI+CampfireExtension.swift index 73d25beba68e..6dbb2d9b78e7 100644 --- a/CLI/CMUXCLI+CampfireExtension.swift +++ b/CLI/CMUXCLI+CampfireExtension.swift @@ -252,7 +252,9 @@ function observerNotification(event: CampfireObserverEvent): string | null { case "permission.asked": return `${name} asked to ${capabilityLabel(event.capability)}`; case "relay.error": - return `Campfire relay error: ${firstString(event.reason) || "connection failed"}`; + // Do not surface the raw upstream reason in a user-facing notification; + // keep it generic per cmux's user-facing error privacy policy. + return "Campfire relay error: connection failed"; default: return null; } diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift index 00a78afe0b23..c89e6e52cba7 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift @@ -45,10 +45,16 @@ struct AgentLaunchEnvironmentPolicyTests { @Test("Keeps PI_PACKAGE_DIR for pi and omp resumes") func keepsPiPackageDirForPiKinds() { - let selected = AgentLaunchEnvironmentPolicy.selectedEnvironment( + let selectedPi = AgentLaunchEnvironmentPolicy.selectedEnvironment( from: ["PI_PACKAGE_DIR": "/nix/store/pi-package"], kind: "pi" ) - #expect(selected["PI_PACKAGE_DIR"] == "/nix/store/pi-package") + #expect(selectedPi["PI_PACKAGE_DIR"] == "/nix/store/pi-package") + + let selectedOmp = AgentLaunchEnvironmentPolicy.selectedEnvironment( + from: ["PI_PACKAGE_DIR": "/nix/store/pi-package"], + kind: "omp" + ) + #expect(selectedOmp["PI_PACKAGE_DIR"] == "/nix/store/pi-package") } } diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index e2f8fc825e01..c57dc62597df 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -1067,7 +1067,7 @@ enum PiSessionLocator { registration: CmuxVaultAgentRegistration ) -> String { let sessionRoot = process.arguments.value(afterOption: "--session-dir") - ?? process.environment["PI_CODING_AGENT_SESSION_DIR"] + ?? piConfiguredSessionDirectory(for: process, registration: registration) ?? configuredSessionDirectory(for: registration) ?? ompAgentSessionsRoot(for: process, registration: registration) ?? campfireAgentSessionsRoot(for: process, registration: registration) @@ -1081,6 +1081,22 @@ enum PiSessionLocator { return expandedRoot } + /// Reads `PI_CODING_AGENT_SESSION_DIR` for Pi-based agents only. + /// + /// Campfire embeds Pi, so a Campfire process can inherit + /// `PI_CODING_AGENT_SESSION_DIR` from a user's Pi configuration. Consuming it + /// here would resolve Campfire sessions against the Pi session directory and + /// pre-empt Campfire's own `CAMPFIRE_CODING_AGENT_SESSION_DIR` / + /// `CAMPFIRE_CODING_AGENT_DIR` lookup, so it is gated out for the `campfire` + /// registration. Behavior for `pi` and `omp` is unchanged. + private static func piConfiguredSessionDirectory( + for process: VaultObservedAgentProcess, + registration: CmuxVaultAgentRegistration + ) -> String? { + guard registration.id != "campfire" else { return nil } + return process.environment["PI_CODING_AGENT_SESSION_DIR"] + } + private static func ompAgentSessionsRoot( for process: VaultObservedAgentProcess, registration: CmuxVaultAgentRegistration diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index 72cc541913b9..fe6beb4a967f 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -88,6 +88,50 @@ struct CampfireSupportTests { #expect(detected.workingDirectory == workspace.path) } + @Test func directProcessDetectionIgnoresPiSessionDirForCampfire() throws { + // Campfire embeds Pi, so a Campfire process can inherit + // PI_CODING_AGENT_SESSION_DIR from the user's Pi configuration. Session + // detection must still resolve Campfire sessions against the + // Campfire-specific directory, not the Pi one. + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-pi-precedence-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + let piSessionsRoot = root.appendingPathComponent("pi-sessions", isDirectory: true) + let piProjectSessions = piSessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: piProjectSessions, withIntermediateDirectories: true) + let piSession = try Self.writeSessionFile( + id: "pi-session", + in: piProjectSessions, + modifiedAt: Date(timeIntervalSince1970: 5_000) + ) + + let campfireSessionsRoot = root.appendingPathComponent("campfire-sessions", isDirectory: true) + let campfireProjectSessions = campfireSessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: campfireProjectSessions, withIntermediateDirectories: true) + let campfireSession = try Self.writeSessionFile( + id: "campfire-session", + in: campfireProjectSessions, + modifiedAt: Date(timeIntervalSince1970: 1_000) + ) + + let detected = try #require(Self.detectedCampfireSnapshot( + arguments: ["/Users/example/.local/bin/campfire"], + environment: [ + "PWD": workspace.path, + "PI_CODING_AGENT_SESSION_DIR": piSessionsRoot.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": campfireSessionsRoot.path, + ] + )) + + #expect(detected.kind == RestorableAgentKind.custom("campfire")) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(campfireSession.path)) + #expect(Self.normalizedPath(detected.sessionId) != Self.normalizedPath(piSession.path)) + #expect(detected.workingDirectory == workspace.path) + } + @Test func taskManagerClassifiesCampfireCompiledBinaryAndDevInvocation() throws { let compiled = try #require(CmuxTaskManagerCodingAgentDefinition.matchingDefinition( processName: "campfire", diff --git a/tests/test_campfire_extension_install.py b/tests/test_campfire_extension_install.py index 7412ae4934ac..7db4e6d89919 100644 --- a/tests/test_campfire_extension_install.py +++ b/tests/test_campfire_extension_install.py @@ -116,7 +116,7 @@ def _response(self, line: str) -> str: payload = json.loads(line) except json.JSONDecodeError: return "OK" - request_id = payload.get("id") or "unknown" + request_id = payload["id"] if "id" in payload else "unknown" method = payload.get("method") if method == "surface.list": result = { From e1b29c987655074e0090d1df868c8602f3234db0 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:16:39 -0700 Subject: [PATCH 03/24] Add Campfire Vault detection regression tests --- cmuxTests/CampfireSupportTests.swift | 62 ++++++++++++++++++++++++++++ 1 file changed, 62 insertions(+) diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index fe6beb4a967f..399ea7d99382 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -132,6 +132,68 @@ struct CampfireSupportTests { #expect(detected.workingDirectory == workspace.path) } + @Test func directProcessDetectionClassifiesCampfireDevInvocation() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-dev-invocation-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + let latest = try Self.writeSessionFile( + id: "campfire-dev-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = try #require(Self.detectedCampfireSnapshot( + processName: "bun", + processPath: "/opt/homebrew/bin/bun", + arguments: [ + "/opt/homebrew/bin/bun", + "/Users/example/campfire/packages/session/bin/campfire.ts", + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + )) + + #expect(detected.kind == RestorableAgentKind.custom("campfire")) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.workingDirectory == workspace.path) + } + + @Test func directProcessDetectionDoesNotTreatPlainCampfireArgumentAsAgent() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-plain-argument-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + _ = try Self.writeSessionFile( + id: "campfire-should-not-bind", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = Self.detectedCampfireSnapshot( + processName: "rg", + processPath: "/usr/bin/rg", + arguments: ["/usr/bin/rg", "campfire"], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + ) + + #expect(detected == nil) + } + @Test func taskManagerClassifiesCampfireCompiledBinaryAndDevInvocation() throws { let compiled = try #require(CmuxTaskManagerCodingAgentDefinition.matchingDefinition( processName: "campfire", From 157113b8ff89f369b42d359d0019df2ef0cb44a7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:16:41 -0700 Subject: [PATCH 04/24] Tighten Campfire Vault process detection --- Sources/VaultAgentRegistry.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index 4918cfcc4b25..09000c59e1fe 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -147,7 +147,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { name: "Campfire", detect: CmuxVaultAgentDetectRule( processName: "campfire", - alternateArgvContains: ["campfire"] + alternateArgvContains: ["packages/session/bin/campfire.ts"] ), sessionIdSource: .piSessionFile, resumeCommand: "{{executable}} --session {{sessionId}}", From 560b157debc7ad76afe1e7923ed2f4b576f7ad9e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:25:15 -0700 Subject: [PATCH 05/24] Add Campfire notification and Vault regressions --- ...ifyProcessIntegrationRegressionTests.swift | 55 +++++++++++++++++++ cmuxTests/CampfireSupportTests.swift | 34 ++++++++++++ 2 files changed, 89 insertions(+) diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index aadd04e5a3fe..50f945180c6a 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -830,6 +830,61 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") } + func testCampfirePermissionNotificationLocalizesAndMarksNeedsInput() throws { + let context = try makeClaudeHookContext(name: "campfire-permission-lifecycle") + defer { context.cleanup() } + + let sessionId = "campfire-permission-session" + let launchEnvironment = agentLaunchEnvironment( + context: context, + kind: "campfire", + executable: "/usr/local/bin/campfire" + ) + startAgentHookMockServerAccepting(context: context, connectionLimit: 64) + + let prompt = runAgentHook( + context: context, + agent: "campfire", + subcommand: "prompt-submit", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"continue"}"#, + extraEnvironment: launchEnvironment + ) + XCTAssertFalse(prompt.timedOut, prompt.stderr) + XCTAssertEqual(prompt.status, 0, prompt.stderr) + + let notificationStart = context.state.commands.count + let notification = runAgentHook( + context: context, + agent: "campfire", + subcommand: "notification", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"Notification","campfire_event_type":"permission.asked","display_name":"Alice","capability":"shell:exec"}"#, + extraEnvironment: launchEnvironment + ) + XCTAssertFalse(notification.timedOut, notification.stderr) + XCTAssertEqual(notification.status, 0, notification.stderr) + + let notificationCommands = Array(context.state.commands.dropFirst(notificationStart)) + XCTAssertTrue( + notificationCommands.contains { + $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Campfire|Permission|Alice asked for permission to run a shell command") + }, + "Campfire permission notification should be localized in Swift, saw \(notificationCommands)" + ) + XCTAssertTrue( + notificationCommands.contains { + $0.hasPrefix("set_agent_lifecycle campfire needsInput --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Campfire permission notification must mark the surface as needing input, saw \(notificationCommands)" + ) + + let stateURL = context.root.appendingPathComponent("campfire-hook-sessions.json") + let state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) + let record = try XCTUnwrap(sessions[sessionId] as? [String: Any]) + XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") + } + func testGenericAgentStaleIdleStopDoesNotOverwriteNewerRunningLifecycle() throws { let context = try makeClaudeHookContext(name: "codex-stale-idle-stop-lifecycle") defer { context.cleanup() } diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index 399ea7d99382..a77662837f75 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -166,6 +166,40 @@ struct CampfireSupportTests { #expect(detected.workingDirectory == workspace.path) } + @Test func directProcessDetectionClassifiesCampfireDistInvocation() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-dist-invocation-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + let latest = try Self.writeSessionFile( + id: "campfire-dist-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = try #require(Self.detectedCampfireSnapshot( + processName: "bun", + processPath: "/opt/homebrew/bin/bun", + arguments: [ + "/opt/homebrew/bin/bun", + "/Users/example/campfire/packages/session/dist/campfire", + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + )) + + #expect(detected.kind == RestorableAgentKind.custom("campfire")) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.workingDirectory == workspace.path) + } + @Test func directProcessDetectionDoesNotTreatPlainCampfireArgumentAsAgent() throws { let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-plain-argument-") defer { try? FileManager.default.removeItem(at: root) } From 89217d7a3502cbc293b094181e6c51d2944192ac Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:25:17 -0700 Subject: [PATCH 06/24] Fix Campfire notification and Vault matching --- CLI/CMUXCLI+CampfireExtension.swift | 38 ++---- CLI/cmux.swift | 84 ++++++++++++- Resources/Localizable.xcstrings | 187 ++++++++++++++++++++++++++++ Sources/VaultAgentRegistry.swift | 2 +- 4 files changed, 280 insertions(+), 31 deletions(-) diff --git a/CLI/CMUXCLI+CampfireExtension.swift b/CLI/CMUXCLI+CampfireExtension.swift index 6dbb2d9b78e7..329d483799c0 100644 --- a/CLI/CMUXCLI+CampfireExtension.swift +++ b/CLI/CMUXCLI+CampfireExtension.swift @@ -225,36 +225,16 @@ function observerBridge(): { listeners: Set<(event: CampfireObserverEvent) => vo return created; } -function capabilityLabel(capability: string | undefined): string { - switch (capability) { - case "queue:add": - return "queue a prompt"; - case "queue:run-now": - return "run a prompt now"; - case "session:interrupt": - return "interrupt the agent"; - case "shell:exec": - return "run a shell command"; - case "tools:contribute": - return "add tools or skills"; - case "files:list": - return "browse files"; - default: - return capability || "do something"; - } -} - -function observerNotification(event: CampfireObserverEvent): string | null { - const name = firstString(event.displayName) || "Someone"; +function observerPayload(event: CampfireObserverEvent): Record | null { switch (event.type) { case "join.requested": - return `${name} is waiting to join the campfire session`; case "permission.asked": - return `${name} asked to ${capabilityLabel(event.capability)}`; case "relay.error": - // Do not surface the raw upstream reason in a user-facing notification; - // keep it generic per cmux's user-facing error privacy policy. - return "Campfire relay error: connection failed"; + return { + campfire_event_type: event.type, + display_name: firstString(event.displayName), + capability: firstString(event.capability), + }; default: return null; } @@ -281,9 +261,9 @@ export default function cmuxCampfireSessionExtension(api: ExtensionAPI) { observerBridge().listeners.add((event) => { const ctx = activeContext; if (!ctx) return; - const message = observerNotification(event); - if (!message) return; - void sendHook("notification", ctx, { message, title: "Campfire" }); + const payload = observerPayload(event); + if (!payload) return; + void sendHook("notification", ctx, payload); }); } """# diff --git a/CLI/cmux.swift b/CLI/cmux.swift index a304a8731b5a..c66773e0d7c0 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -22739,6 +22739,7 @@ struct CMUXCLI { "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source", "terminationReason", "title", "summary", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description", + "campfire_event_type", "campfireEventType", "display_name", "displayName", "capability", ] { if let value = compactClaudeHookValue(object[key], key: key) { compact[key] = value @@ -22816,6 +22817,7 @@ struct CMUXCLI { "assistantPreamble", "assistant_preamble", "user_message", "userMessage", "title", "command", "description", "pattern_key", "patternKey", "surface", "choice", "message", "body", "text", "prompt", "summary", "error", + "campfire_event_type", "campfireEventType", "display_name", "displayName", "capability", ] { if let value = compactClaudeHookValue(extra[extraKey], key: extraKey) { compactExtra[extraKey] = value @@ -22831,12 +22833,14 @@ struct CMUXCLI { private func claudeHookCompactFieldLimit(for key: String) -> Int { switch key { - case "tool_name", "toolName", "turn_id", "turnId", "conversation_id", "conversationId", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source": + case "tool_name", "toolName", "turn_id", "turnId", "conversation_id", "conversationId", "event", "event_name", "hook_event_name", "hookEventName", "type", "kind", "notification_type", "matcher", "reason", "source", "campfire_event_type", "campfireEventType", "capability": return 80 case "transcript_path", "transcriptPath": return 240 case "last_assistant_message", "lastAssistantMessage", "assistantPreamble", "assistant_preamble", "assistant_response", "assistantResponse", "title", "summary", "message", "body", "text", "prompt", "error", "codex_error_info", "codexErrorInfo", "additional_details", "additionalDetails", "description", "terminationReason", "user_message", "userMessage", "command": return 240 + case "display_name", "displayName": + return 120 default: return 160 } @@ -24401,6 +24405,9 @@ struct CMUXCLI { isFallback: false ) } + if let campfireSummary = summarizeCampfireObserverNotification(def: def, object: object) { + return campfireSummary + } if let grokSummary = summarizeGrokAssistantCompletionNotification( def: def, message: normalizedMessage, @@ -24427,6 +24434,81 @@ struct CMUXCLI { ) } + private func summarizeCampfireObserverNotification( + def: AgentHookDef, + object: [String: Any] + ) -> AgentHookNotificationSummary? { + guard def.name == "campfire" else { return nil } + let extra = (object["extra"] as? [String: Any]) ?? [:] + let eventType = firstString(in: object, keys: ["campfire_event_type", "campfireEventType"]) + ?? firstString(in: extra, keys: ["campfire_event_type", "campfireEventType"]) + guard let eventType else { + return nil + } + let displayName = firstString(in: object, keys: ["display_name", "displayName"]) + ?? firstString(in: extra, keys: ["display_name", "displayName"]) + switch eventType { + case "join.requested": + let name = displayName ?? String(localized: "agent.campfire.notification.participantFallback", defaultValue: "Someone") + let body = String.localizedStringWithFormat( + String(localized: "agent.campfire.notification.body.joinRequested", defaultValue: "%@ is waiting to join the Campfire session"), + name + ) + return AgentHookNotificationSummary( + subtitle: String(localized: "agent.generic.notification.subtitle.waiting", defaultValue: "Waiting"), + body: truncate(body, maxLength: 180), + status: .needsInput, + isFallback: false + ) + case "permission.asked": + let name = displayName ?? String(localized: "agent.campfire.notification.participantFallback", defaultValue: "Someone") + let capability = firstString(in: object, keys: ["capability"]) + ?? firstString(in: extra, keys: ["capability"]) + let capabilityLabel = campfireCapabilityLabel(capability) + let body = String.localizedStringWithFormat( + String(localized: "agent.campfire.notification.body.permissionAsked", defaultValue: "%1$@ asked for permission to %2$@"), + name, + capabilityLabel + ) + return AgentHookNotificationSummary( + subtitle: String(localized: "agent.generic.notification.subtitle.permission", defaultValue: "Permission"), + body: truncate(body, maxLength: 180), + status: .needsInput, + isFallback: false + ) + case "relay.error": + return AgentHookNotificationSummary( + subtitle: String(localized: "agent.generic.notification.subtitle.error", defaultValue: "Error"), + body: String(localized: "agent.campfire.notification.body.relayError", defaultValue: "Campfire relay connection failed"), + status: .error, + isFallback: false + ) + default: + return nil + } + } + + private func campfireCapabilityLabel(_ capability: String?) -> String { + switch capability { + case "queue:add": + return String(localized: "agent.campfire.capability.queueAdd", defaultValue: "queue a prompt") + case "queue:run-now": + return String(localized: "agent.campfire.capability.queueRunNow", defaultValue: "run a prompt now") + case "session:interrupt": + return String(localized: "agent.campfire.capability.sessionInterrupt", defaultValue: "interrupt the agent") + case "shell:exec": + return String(localized: "agent.campfire.capability.shellExec", defaultValue: "run a shell command") + case "tools:contribute": + return String(localized: "agent.campfire.capability.toolsContribute", defaultValue: "add tools or skills") + case "files:list": + return String(localized: "agent.campfire.capability.filesList", defaultValue: "browse files") + case let value?: + return value + case nil: + return String(localized: "agent.campfire.capability.fallback", defaultValue: "do something") + } + } + private func summarizeGrokAssistantCompletionNotification( def: AgentHookDef, message: String, diff --git a/Resources/Localizable.xcstrings b/Resources/Localizable.xcstrings index 38e06d880fe1..aa4deff4a490 100644 --- a/Resources/Localizable.xcstrings +++ b/Resources/Localizable.xcstrings @@ -190862,6 +190862,193 @@ } } }, + "agent.campfire.capability.fallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "do something" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "操作を実行する" + } + } + } + }, + "agent.campfire.capability.filesList": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "browse files" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ファイルを参照する" + } + } + } + }, + "agent.campfire.capability.queueAdd": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "queue a prompt" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロンプトをキューに追加する" + } + } + } + }, + "agent.campfire.capability.queueRunNow": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "run a prompt now" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "プロンプトを今すぐ実行する" + } + } + } + }, + "agent.campfire.capability.sessionInterrupt": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "interrupt the agent" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "エージェントを中断する" + } + } + } + }, + "agent.campfire.capability.shellExec": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "run a shell command" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "シェルコマンドを実行する" + } + } + } + }, + "agent.campfire.capability.toolsContribute": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "add tools or skills" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "ツールまたはスキルを追加する" + } + } + } + }, + "agent.campfire.notification.body.joinRequested": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%@ is waiting to join the Campfire session" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%@ が Campfire セッションへの参加を待っています" + } + } + } + }, + "agent.campfire.notification.body.permissionAsked": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "%1$@ asked for permission to %2$@" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "%1$@ が %2$@ 権限を求めています" + } + } + } + }, + "agent.campfire.notification.body.relayError": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Campfire relay connection failed" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "Campfire リレー接続に失敗しました" + } + } + } + }, + "agent.campfire.notification.participantFallback": { + "extractionState": "manual", + "localizations": { + "en": { + "stringUnit": { + "state": "translated", + "value": "Someone" + } + }, + "ja": { + "stringUnit": { + "state": "translated", + "value": "参加者" + } + } + } + }, "cli.hooks.campfire.aborted": { "extractionState": "manual", "localizations": { diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index 09000c59e1fe..a3b10f620ea2 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -147,7 +147,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { name: "Campfire", detect: CmuxVaultAgentDetectRule( processName: "campfire", - alternateArgvContains: ["packages/session/bin/campfire.ts"] + alternateArgvContains: ["packages/session"] ), sessionIdSource: .piSessionFile, resumeCommand: "{{executable}} --session {{sessionId}}", From 7dec4b23f6b3a36b1be2a7242f7b80af50fbb22e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:29:14 -0700 Subject: [PATCH 07/24] Cover structured Campfire observer payloads --- cmuxTests/CampfireSupportTests.swift | 31 ++++++++++++++++++++++++ tests/test_campfire_extension_install.py | 7 ++++-- 2 files changed, 36 insertions(+), 2 deletions(-) diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index a77662837f75..8b830e016ad3 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -228,6 +228,37 @@ struct CampfireSupportTests { #expect(detected == nil) } + @Test func directProcessDetectionDoesNotTreatUnrelatedPackagesSessionArgumentAsAgent() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-packages-session-argument-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + _ = try Self.writeSessionFile( + id: "campfire-should-not-bind-packages-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = Self.detectedCampfireSnapshot( + processName: "bun", + processPath: "/opt/homebrew/bin/bun", + arguments: [ + "/opt/homebrew/bin/bun", + "/Users/example/monorepo/packages/session/scripts/seed.ts", + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + ) + + #expect(detected == nil) + } + @Test func taskManagerClassifiesCampfireCompiledBinaryAndDevInvocation() throws { let compiled = try #require(CmuxTaskManagerCodingAgentDefinition.matchingDefinition( processName: "campfire", diff --git a/tests/test_campfire_extension_install.py b/tests/test_campfire_extension_install.py index 7db4e6d89919..551e66f35801 100644 --- a/tests/test_campfire_extension_install.py +++ b/tests/test_campfire_extension_install.py @@ -467,8 +467,11 @@ def main() -> int: if '"prompt":"hello campfire"' not in stdin_log or '"last_assistant_message":"done"' not in stdin_log: print(f"FAIL: extension did not pass prompt/assistant payload, got {stdin_log!r}") return 1 - if '"message":"alice is waiting to join the campfire session"' not in stdin_log: - print(f"FAIL: extension did not bridge the join request notification, got {stdin_log!r}") + if ( + '"campfire_event_type":"join.requested"' not in stdin_log + or '"display_name":"alice"' not in stdin_log + ): + print(f"FAIL: extension did not bridge the structured join request notification, got {stdin_log!r}") return 1 if '"presence.changed"' in stdin_log: print(f"FAIL: extension forwarded a non-actionable observer event, got {stdin_log!r}") From b91caf0cf92a5dccbc019f32cb819720c0a815b2 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:29:16 -0700 Subject: [PATCH 08/24] Require Campfire argv cue for Vault fallback --- Sources/VaultAgentRegistry.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index a3b10f620ea2..c3ebf1206928 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -147,7 +147,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { name: "Campfire", detect: CmuxVaultAgentDetectRule( processName: "campfire", - alternateArgvContains: ["packages/session"] + alternateArgvContains: ["packages/session", "campfire"] ), sessionIdSource: .piSessionFile, resumeCommand: "{{executable}} --session {{sessionId}}", From 32520262f6582db256c58c16769e9aca44e59be7 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:34:11 -0700 Subject: [PATCH 09/24] Cover non-Campfire packages session argv --- cmuxTests/CampfireSupportTests.swift | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index 8b830e016ad3..fdda9663a0f3 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -248,7 +248,7 @@ struct CampfireSupportTests { processPath: "/opt/homebrew/bin/bun", arguments: [ "/opt/homebrew/bin/bun", - "/Users/example/monorepo/packages/session/scripts/seed.ts", + "/Users/example/campfire/packages/session/scripts/seed.ts", ], environment: [ "PWD": workspace.path, From 588ddb01a9697411bb5efb32c8b7ccb4a7342dbb Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:34:15 -0700 Subject: [PATCH 10/24] Use precise Campfire Vault entrypoint alternatives --- Sources/VaultAgentProcessScanner.swift | 12 ++++++++++-- Sources/VaultAgentRegistry.swift | 15 ++++++++++++--- 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index c57dc62597df..41b4577b5eaf 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -817,7 +817,7 @@ private extension CmuxVaultAgentDetectRule { if let processName { expectedNames.append(processName) } - guard !expectedNames.isEmpty || !argvContains.isEmpty || !alternateArgvContains.isEmpty else { + guard !expectedNames.isEmpty || !argvContains.isEmpty || !alternateArgvContains.isEmpty || !alternateArgvContainsAny.isEmpty else { return false } let processNameMatch = expectedNames.isEmpty || expectedNames.contains { expected in @@ -828,11 +828,19 @@ private extension CmuxVaultAgentDetectRule { let argvContainsMatch = argvContains.isEmpty || process.argumentsContainAll(argvContains) let alternateArgvContainsMatch = !alternateArgvContains.isEmpty && process.argumentsContainAll(alternateArgvContains) - return (processNameMatch && argvContainsMatch) || alternateArgvContainsMatch + let alternateArgvContainsAnyMatch = !alternateArgvContainsAny.isEmpty + && process.argumentsContainAny(alternateArgvContainsAny) + return (processNameMatch && argvContainsMatch) || alternateArgvContainsMatch || alternateArgvContainsAnyMatch } } private extension VaultObservedAgentProcess { + func argumentsContainAny(_ needles: [String]) -> Bool { + needles.contains { needle in + argumentsContainAll([needle]) + } + } + func argumentsContainAll(_ needles: [String]) -> Bool { needles.allSatisfy { needle in if needle.contains(" ") { diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index c3ebf1206928..099012077a9c 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -147,7 +147,10 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { name: "Campfire", detect: CmuxVaultAgentDetectRule( processName: "campfire", - alternateArgvContains: ["packages/session", "campfire"] + alternateArgvContainsAny: [ + "packages/session/bin/campfire.ts", + "packages/session/dist/campfire", + ] ), sessionIdSource: .piSessionFile, resumeCommand: "{{executable}} --session {{sessionId}}", @@ -187,16 +190,18 @@ struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { var processNames: [String] var argvContains: [String] var alternateArgvContains: [String] + var alternateArgvContainsAny: [String] private enum CodingKeys: String, CodingKey { - case processName, processNames, argvContains, alternateArgvContains + case processName, processNames, argvContains, alternateArgvContains, alternateArgvContainsAny } init( processName: String? = nil, processNames: [String] = [], argvContains: [String] = [], - alternateArgvContains: [String] = [] + alternateArgvContains: [String] = [], + alternateArgvContainsAny: [String] = [] ) { let name = processName?.trimmingCharacters(in: .whitespacesAndNewlines) self.processName = name?.isEmpty == true ? nil : name @@ -209,6 +214,9 @@ struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { self.alternateArgvContains = alternateArgvContains .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + self.alternateArgvContainsAny = alternateArgvContainsAny + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } } init(from decoder: Decoder) throws { @@ -219,6 +227,7 @@ struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { processNames = try Self.decodeOneOrManyStrings(forKey: .processNames, in: container) argvContains = try Self.decodeOneOrManyStrings(forKey: .argvContains, in: container) alternateArgvContains = try Self.decodeOneOrManyStrings(forKey: .alternateArgvContains, in: container) + alternateArgvContainsAny = try Self.decodeOneOrManyStrings(forKey: .alternateArgvContainsAny, in: container) } private static func decodeOneOrManyStrings( From 80ea88fd8e4588458967b5d8d236b2bd40ea0f00 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:40:42 -0700 Subject: [PATCH 11/24] Cover mentioned Campfire entrypoint argv --- cmuxTests/CampfireSupportTests.swift | 31 ++++++++++++++++++++++++++++ 1 file changed, 31 insertions(+) diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index fdda9663a0f3..ce0cca811afc 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -259,6 +259,37 @@ struct CampfireSupportTests { #expect(detected == nil) } + @Test func directProcessDetectionDoesNotTreatMentionedCampfireEntrypointAsAgent() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-mentioned-entrypoint-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + _ = try Self.writeSessionFile( + id: "campfire-should-not-bind-mentioned-entrypoint", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = Self.detectedCampfireSnapshot( + processName: "rg", + processPath: "/usr/bin/rg", + arguments: [ + "/usr/bin/rg", + "packages/session/bin/campfire.ts", + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + ) + + #expect(detected == nil) + } + @Test func taskManagerClassifiesCampfireCompiledBinaryAndDevInvocation() throws { let compiled = try #require(CmuxTaskManagerCodingAgentDefinition.matchingDefinition( processName: "campfire", From df1aa89a0d13a3a81b359c6b031ef030e66f5037 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:40:45 -0700 Subject: [PATCH 12/24] Constrain Campfire Vault alternates to runtimes --- Sources/VaultAgentProcessScanner.swift | 7 +++++++ Sources/VaultAgentRegistry.swift | 9 ++++++++- 2 files changed, 15 insertions(+), 1 deletion(-) diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 41b4577b5eaf..92308a5af4ff 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -826,9 +826,16 @@ private extension CmuxVaultAgentDetectRule { } } let argvContainsMatch = argvContains.isEmpty || process.argumentsContainAll(argvContains) + let alternateProcessNameMatch = alternateProcessNames.isEmpty || alternateProcessNames.contains { expected in + process.executableBasenames.contains { candidate in + candidate.compare(expected, options: [.caseInsensitive, .literal]) == .orderedSame + } + } let alternateArgvContainsMatch = !alternateArgvContains.isEmpty + && alternateProcessNameMatch && process.argumentsContainAll(alternateArgvContains) let alternateArgvContainsAnyMatch = !alternateArgvContainsAny.isEmpty + && alternateProcessNameMatch && process.argumentsContainAny(alternateArgvContainsAny) return (processNameMatch && argvContainsMatch) || alternateArgvContainsMatch || alternateArgvContainsAnyMatch } diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index 099012077a9c..85d7a3dda543 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -147,6 +147,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { name: "Campfire", detect: CmuxVaultAgentDetectRule( processName: "campfire", + alternateProcessNames: ["bun", "node", "deno", "tsx", "ts-node"], alternateArgvContainsAny: [ "packages/session/bin/campfire.ts", "packages/session/dist/campfire", @@ -189,17 +190,19 @@ struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { var processName: String? var processNames: [String] var argvContains: [String] + var alternateProcessNames: [String] var alternateArgvContains: [String] var alternateArgvContainsAny: [String] private enum CodingKeys: String, CodingKey { - case processName, processNames, argvContains, alternateArgvContains, alternateArgvContainsAny + case processName, processNames, argvContains, alternateProcessNames, alternateArgvContains, alternateArgvContainsAny } init( processName: String? = nil, processNames: [String] = [], argvContains: [String] = [], + alternateProcessNames: [String] = [], alternateArgvContains: [String] = [], alternateArgvContainsAny: [String] = [] ) { @@ -211,6 +214,9 @@ struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { self.argvContains = argvContains .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } + self.alternateProcessNames = alternateProcessNames + .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } + .filter { !$0.isEmpty } self.alternateArgvContains = alternateArgvContains .map { $0.trimmingCharacters(in: .whitespacesAndNewlines) } .filter { !$0.isEmpty } @@ -226,6 +232,7 @@ struct CmuxVaultAgentDetectRule: Codable, Hashable, Sendable { processName = name?.isEmpty == true ? nil : name processNames = try Self.decodeOneOrManyStrings(forKey: .processNames, in: container) argvContains = try Self.decodeOneOrManyStrings(forKey: .argvContains, in: container) + alternateProcessNames = try Self.decodeOneOrManyStrings(forKey: .alternateProcessNames, in: container) alternateArgvContains = try Self.decodeOneOrManyStrings(forKey: .alternateArgvContains, in: container) alternateArgvContainsAny = try Self.decodeOneOrManyStrings(forKey: .alternateArgvContainsAny, in: container) } From 277e91590562d200f0d7f0a22a335d6c67718086 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:48:51 -0700 Subject: [PATCH 13/24] Address Campfire autoreview policy findings --- CLI/cmux.swift | 2 +- .../AgentLaunchEnvironmentPolicy.swift | 34 +- .../AgentLaunchEnvironmentPolicyTests.swift | 8 +- .../HermesAgentCodexEnvironmentTests.swift | 4 +- Sources/AgentForkSupport.swift | 4 +- Sources/RestorableAgentSession.swift | 2 +- Sources/VaultAgentProcessScanner.swift | 2 +- cmux.xcodeproj/project.pbxproj | 4 + ...ifyProcessIntegrationRegressionTests.swift | 55 --- cmuxTests/CampfireHookNotificationTests.swift | 371 ++++++++++++++++++ 10 files changed, 405 insertions(+), 81 deletions(-) create mode 100644 cmuxTests/CampfireHookNotificationTests.swift diff --git a/CLI/cmux.swift b/CLI/cmux.swift index c66773e0d7c0..6f6cbc31c388 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25606,7 +25606,7 @@ struct CMUXCLI { } private func selectedAgentLaunchEnvironment(from env: [String: String], kind: String? = nil) -> [String: String] { - var selected = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: env, kind: kind) + var selected = AgentLaunchEnvironmentPolicy().selectedEnvironment(from: env, kind: kind) if kind == "hermes-agent" { selected = HermesAgentCodexEnvironment.applyingDefaultCodexBaseURL( to: selected, diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index 7346c54e0783..12d46ec749e9 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -1,6 +1,8 @@ import Foundation -public enum ClaudeConfigDirectoryPath { +public struct ClaudeConfigDirectoryPath: Sendable { + private init() {} + public static func preferredPath( _ rawPath: String, fileManager: FileManager = .default, @@ -23,8 +25,10 @@ public enum ClaudeConfigDirectoryPath { } } -public enum AgentLaunchEnvironmentPolicy { - private static let hermesAgentEnvironmentKeys: Set = [ +public struct AgentLaunchEnvironmentPolicy: Sendable { + public init() {} + + private let hermesAgentEnvironmentKeys: Set = [ "CUSTOM_BASE_URL", "HERMES_CODEX_BASE_URL", ] @@ -34,11 +38,11 @@ public enum AgentLaunchEnvironmentPolicy { /// extracted asset cache (version+fingerprint keyed) after an upgrade, so /// it is dropped for campfire resumes specifically; pi/omp keep it (Nix /// installs rely on it). - private static let campfireManagedEnvironmentKeys: Set = [ + private let campfireManagedEnvironmentKeys: Set = [ "PI_PACKAGE_DIR", ] - private static let safeEnvironmentKeys: Set = [ + private let safeEnvironmentKeys: Set = [ // AMP_API_KEY is intentionally NOT allowlisted: it's a secret. // Amp resolves auth from ~/.config/amp/settings.json on resume. "AMP_LOG_FILE", @@ -94,7 +98,7 @@ public enum AgentLaunchEnvironmentPolicy { "USE_BUILTIN_RIPGREP" ] - public static func selectedEnvironment(from env: [String: String], kind: String? = nil) -> [String: String] { + public func selectedEnvironment(from env: [String: String], kind: String? = nil) -> [String: String] { var result: [String: String] = [:] for key in safeEnvironmentKeys.sorted() where key != "NODE_OPTIONS" { guard let value = sanitizedValue(key: key, value: env[key]) else { continue } @@ -116,7 +120,7 @@ public enum AgentLaunchEnvironmentPolicy { return result } - public static func sanitizedValue(key: String, value: String?) -> String? { + public func sanitizedValue(key: String, value: String?) -> String? { guard safeEnvironmentKeys.contains(key) else { return nil } switch key { case "CLAUDE_CONFIG_DIR": @@ -128,7 +132,7 @@ public enum AgentLaunchEnvironmentPolicy { } } - private static func selectedNodeOptions(from env: [String: String]) -> String? { + private func selectedNodeOptions(from env: [String: String]) -> String? { switch normalizedValue(env["CMUX_ORIGINAL_NODE_OPTIONS_PRESENT"]) { case "1": return sanitizedNodeOptions(env["CMUX_ORIGINAL_NODE_OPTIONS"]) @@ -139,7 +143,7 @@ public enum AgentLaunchEnvironmentPolicy { } } - private static func sanitizedNodeOptions(_ rawValue: String?) -> String? { + private func sanitizedNodeOptions(_ rawValue: String?) -> String? { let tokens = rawValue? .split(whereSeparator: \.isWhitespace) .map(String.init) ?? [] @@ -180,7 +184,7 @@ public enum AgentLaunchEnvironmentPolicy { return joined.isEmpty ? nil : joined } - private static func normalizedValue(_ value: String?) -> String? { + private func normalizedValue(_ value: String?) -> String? { guard let trimmed = value?.trimmingCharacters(in: .whitespacesAndNewlines), !trimmed.isEmpty else { return nil @@ -188,18 +192,18 @@ public enum AgentLaunchEnvironmentPolicy { return trimmed } - private static func isRequireOption(_ token: String) -> Bool { + private func isRequireOption(_ token: String) -> Bool { token == "--require" || token == "-r" } - private static func inlineRequireOptionPath(_ token: String) -> String? { + private func inlineRequireOptionPath(_ token: String) -> String? { for prefix in ["--require=", "-r="] where token.hasPrefix(prefix) { return String(token.dropFirst(prefix.count)) } return nil } - private static func isCmuxNodeOptionsRestoreModulePath(_ value: String) -> Bool { + private func isCmuxNodeOptionsRestoreModulePath(_ value: String) -> Bool { let trimmed = value.trimmingCharacters(in: CharacterSet(charactersIn: "'\"")) guard URL(fileURLWithPath: trimmed).lastPathComponent == "restore-node-options.cjs" else { return false @@ -207,7 +211,7 @@ public enum AgentLaunchEnvironmentPolicy { return trimmed.contains("/cmux-") } - private static func isInjectedNodeHeapCap(_ tokens: [String], index: Int) -> Bool { + private func isInjectedNodeHeapCap(_ tokens: [String], index: Int) -> Bool { guard index < tokens.count else { return false } let token = tokens[index] if token == "--max-old-space-size" { @@ -216,7 +220,7 @@ public enum AgentLaunchEnvironmentPolicy { return token == "--max-old-space-size=4096" } - private static func nodeHeapCapWidth(_ tokens: [String], index: Int) -> Int { + private func nodeHeapCapWidth(_ tokens: [String], index: Int) -> Int { guard index < tokens.count else { return 1 } return tokens[index] == "--max-old-space-size" ? min(2, tokens.count - index) : 1 } diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift index c89e6e52cba7..b521d71a28f5 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift @@ -5,7 +5,7 @@ import Testing struct AgentLaunchEnvironmentPolicyTests { @Test("Preserves OMP config roots without persisting secrets") func preservesOmpConfigRootsWithoutPersistingSecrets() { - let selected = AgentLaunchEnvironmentPolicy.selectedEnvironment( + let selected = AgentLaunchEnvironmentPolicy().selectedEnvironment( from: [ "OPENAI_API_KEY": "secret-should-not-persist", "PI_CODING_AGENT_DIR": "/tmp/omp-agent", @@ -22,7 +22,7 @@ struct AgentLaunchEnvironmentPolicyTests { @Test("Preserves Campfire config roots and drops its self-managed package dir") func preservesCampfireConfigRootsAndDropsManagedPackageDir() { - let selected = AgentLaunchEnvironmentPolicy.selectedEnvironment( + let selected = AgentLaunchEnvironmentPolicy().selectedEnvironment( from: [ "OPENAI_API_KEY": "secret-should-not-persist", "CAMPFIRE_CODING_AGENT_DIR": "/tmp/campfire-agent", @@ -45,13 +45,13 @@ struct AgentLaunchEnvironmentPolicyTests { @Test("Keeps PI_PACKAGE_DIR for pi and omp resumes") func keepsPiPackageDirForPiKinds() { - let selectedPi = AgentLaunchEnvironmentPolicy.selectedEnvironment( + let selectedPi = AgentLaunchEnvironmentPolicy().selectedEnvironment( from: ["PI_PACKAGE_DIR": "/nix/store/pi-package"], kind: "pi" ) #expect(selectedPi["PI_PACKAGE_DIR"] == "/nix/store/pi-package") - let selectedOmp = AgentLaunchEnvironmentPolicy.selectedEnvironment( + let selectedOmp = AgentLaunchEnvironmentPolicy().selectedEnvironment( from: ["PI_PACKAGE_DIR": "/nix/store/pi-package"], kind: "omp" ) diff --git a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/HermesAgentCodexEnvironmentTests.swift b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/HermesAgentCodexEnvironmentTests.swift index a4829992901f..b2d6e6c1bb87 100644 --- a/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/HermesAgentCodexEnvironmentTests.swift +++ b/Packages/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/HermesAgentCodexEnvironmentTests.swift @@ -104,7 +104,7 @@ struct HermesAgentCodexEnvironmentTests { @Test("Allows Hermes Codex subrouter URLs in captured launch environment") func allowsHermesCodexSubrouterURLsInCapturedLaunchEnvironment() { #expect( - AgentLaunchEnvironmentPolicy.selectedEnvironment( + AgentLaunchEnvironmentPolicy().selectedEnvironment( from: [ "CUSTOM_BASE_URL": "http://subrouter-team:31415/v1", "HERMES_CODEX_BASE_URL": "http://subrouter-team:31415/backend-api/codex", @@ -116,7 +116,7 @@ struct HermesAgentCodexEnvironmentTests { ] ) #expect( - AgentLaunchEnvironmentPolicy.selectedEnvironment( + AgentLaunchEnvironmentPolicy().selectedEnvironment( from: [ "CUSTOM_BASE_URL": "http://subrouter-team:31415/v1", "HERMES_CODEX_BASE_URL": "http://subrouter-team:31415/backend-api/codex", diff --git a/Sources/AgentForkSupport.swift b/Sources/AgentForkSupport.swift index fe78ea9bf4d5..618beb7fd56b 100644 --- a/Sources/AgentForkSupport.swift +++ b/Sources/AgentForkSupport.swift @@ -395,7 +395,7 @@ enum AgentForkSupport { ) -> [String: String] { var processEnvironment = sanitizedBaseEnvironmentForOpenCodeProbe(baseEnvironment) if let environment { - let selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment) + let selectedEnvironment = AgentLaunchEnvironmentPolicy().selectedEnvironment(from: environment) for (key, value) in selectedEnvironment { processEnvironment[key] = value } @@ -428,7 +428,7 @@ enum AgentForkSupport { } processEnvironment[key] = value } - let selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment) + let selectedEnvironment = AgentLaunchEnvironmentPolicy().selectedEnvironment(from: environment) for (key, value) in selectedEnvironment { processEnvironment[key] = value } diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index b902cb235e87..55ffd41dfecb 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -409,7 +409,7 @@ enum AgentResumeCommandBuilder { var environmentParts: [String] = [] var preservedClaudeAuthSelectionEnvironmentKeys: [String] = [] - let selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment, kind: kind.rawValue) + let selectedEnvironment = AgentLaunchEnvironmentPolicy().selectedEnvironment(from: environment, kind: kind.rawValue) for key in selectedEnvironment.keys.sorted() { guard let value = selectedEnvironment[key] else { continue } environmentParts.append("\(key)=\(value)") diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 92308a5af4ff..8d8aca149781 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -10,7 +10,7 @@ extension AgentLaunchCommandSnapshot { workingDirectory: String?, environment: [String: String] ) { - var selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment, kind: launcher) + var selectedEnvironment = AgentLaunchEnvironmentPolicy().selectedEnvironment(from: environment, kind: launcher) if launcher == "opencode", let path = environment["PATH"]?.trimmingCharacters(in: .whitespacesAndNewlines), !path.isEmpty { diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index cc3dff77872c..cce47213eb46 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -107,6 +107,7 @@ C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */; }; A5001534 /* BrowserWindowPortal.swift in Sources */ = {isa = PBXBuildFile; fileRef = A5001533 /* BrowserWindowPortal.swift */; }; C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35530000000000000102 /* BundledCLILinkageTests.swift */; }; + 0A0F00560000000000000003 /* CampfireHookNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0F00560000000000000004 /* CampfireHookNotificationTests.swift */; }; 0A0F00550000000000000003 /* CampfireSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0F00550000000000000004 /* CampfireSupportTests.swift */; }; F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */; }; D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */; }; @@ -893,6 +894,7 @@ C0DE49870000000000000002 /* BrowserWebContentProcessTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWebContentProcessTests.swift; sourceTree = ""; }; A5001533 /* BrowserWindowPortal.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortal.swift; sourceTree = ""; }; C0DE35530000000000000102 /* BundledCLILinkageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledCLILinkageTests.swift; sourceTree = ""; }; + 0A0F00560000000000000004 /* CampfireHookNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CampfireHookNotificationTests.swift; sourceTree = ""; }; 0A0F00550000000000000004 /* CampfireSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CampfireSupportTests.swift; sourceTree = ""; }; F3000001A1B2C3D4E5F60718 /* CJKIMEInputTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEInputTests.swift; sourceTree = ""; }; D3571003A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CJKIMEMarkedSelectionTests.swift; sourceTree = ""; }; @@ -2273,6 +2275,7 @@ C0DE35530000000000000102 /* BundledCLILinkageTests.swift */, C37800000000000000000002 /* VMSSHCommandTests.swift */, 0A0F00550000000000000002 /* OmpSupportTests.swift */, + 0A0F00560000000000000004 /* CampfireHookNotificationTests.swift */, 0A0F00550000000000000004 /* CampfireSupportTests.swift */, A5E01204A1B2C3D4E5F60718 /* OpenCodeHookRegressionTests.swift */, 10D684CFFB8CDEF89CE2D9E1 /* TabManagerSessionSnapshotTests.swift */, @@ -3218,6 +3221,7 @@ 1F14445B9627DE9D3AF4FD2E /* BrowserPanelTests.swift in Sources */, C0DE49870000000000000001 /* BrowserWebContentProcessTests.swift in Sources */, C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */, + 0A0F00560000000000000003 /* CampfireHookNotificationTests.swift in Sources */, 0A0F00550000000000000003 /* CampfireSupportTests.swift in Sources */, F3000000A1B2C3D4E5F60718 /* CJKIMEInputTests.swift in Sources */, D3571002A1B2C3D4E5F60718 /* CJKIMEMarkedSelectionTests.swift in Sources */, diff --git a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift index 50f945180c6a..aadd04e5a3fe 100644 --- a/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift +++ b/cmuxTests/CLINotifyProcessIntegrationRegressionTests.swift @@ -830,61 +830,6 @@ final class CLINotifyProcessIntegrationRegressionTests: XCTestCase { XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") } - func testCampfirePermissionNotificationLocalizesAndMarksNeedsInput() throws { - let context = try makeClaudeHookContext(name: "campfire-permission-lifecycle") - defer { context.cleanup() } - - let sessionId = "campfire-permission-session" - let launchEnvironment = agentLaunchEnvironment( - context: context, - kind: "campfire", - executable: "/usr/local/bin/campfire" - ) - startAgentHookMockServerAccepting(context: context, connectionLimit: 64) - - let prompt = runAgentHook( - context: context, - agent: "campfire", - subcommand: "prompt-submit", - standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"continue"}"#, - extraEnvironment: launchEnvironment - ) - XCTAssertFalse(prompt.timedOut, prompt.stderr) - XCTAssertEqual(prompt.status, 0, prompt.stderr) - - let notificationStart = context.state.commands.count - let notification = runAgentHook( - context: context, - agent: "campfire", - subcommand: "notification", - standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"Notification","campfire_event_type":"permission.asked","display_name":"Alice","capability":"shell:exec"}"#, - extraEnvironment: launchEnvironment - ) - XCTAssertFalse(notification.timedOut, notification.stderr) - XCTAssertEqual(notification.status, 0, notification.stderr) - - let notificationCommands = Array(context.state.commands.dropFirst(notificationStart)) - XCTAssertTrue( - notificationCommands.contains { - $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Campfire|Permission|Alice asked for permission to run a shell command") - }, - "Campfire permission notification should be localized in Swift, saw \(notificationCommands)" - ) - XCTAssertTrue( - notificationCommands.contains { - $0.hasPrefix("set_agent_lifecycle campfire needsInput --tab=\(context.workspaceId)") - && $0.contains("--panel=\(context.surfaceId)") - }, - "Campfire permission notification must mark the surface as needing input, saw \(notificationCommands)" - ) - - let stateURL = context.root.appendingPathComponent("campfire-hook-sessions.json") - let state = try XCTUnwrap(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) - let sessions = try XCTUnwrap(state["sessions"] as? [String: Any]) - let record = try XCTUnwrap(sessions[sessionId] as? [String: Any]) - XCTAssertEqual(record["agentLifecycle"] as? String, "needsInput") - } - func testGenericAgentStaleIdleStopDoesNotOverwriteNewerRunningLifecycle() throws { let context = try makeClaudeHookContext(name: "codex-stale-idle-stop-lifecycle") defer { context.cleanup() } diff --git a/cmuxTests/CampfireHookNotificationTests.swift b/cmuxTests/CampfireHookNotificationTests.swift new file mode 100644 index 000000000000..14c9b7a051f6 --- /dev/null +++ b/cmuxTests/CampfireHookNotificationTests.swift @@ -0,0 +1,371 @@ +import Darwin +import Foundation +import Testing + +@Suite("Campfire hook notifications") +struct CampfireHookNotificationTests { + @Test func permissionNotificationLocalizesAndMarksNeedsInput() throws { + let context = try makeHookContext(name: "campfire-permission-lifecycle") + defer { context.cleanup() } + + let sessionId = "campfire-permission-session" + let launchEnvironment = agentLaunchEnvironment( + context: context, + kind: "campfire", + executable: "/usr/local/bin/campfire" + ) + startAgentHookMockServerAccepting(context: context, connectionLimit: 64) + + let prompt = runAgentHook( + context: context, + agent: "campfire", + subcommand: "prompt-submit", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"UserPromptSubmit","prompt":"continue"}"#, + extraEnvironment: launchEnvironment + ) + #expect(prompt.timedOut == false, Comment(rawValue: prompt.stderr)) + #expect(prompt.status == 0, Comment(rawValue: prompt.stderr)) + + let notificationStart = context.state.snapshot().count + let notification = runAgentHook( + context: context, + agent: "campfire", + subcommand: "notification", + standardInput: #"{"session_id":"\#(sessionId)","cwd":"\#(context.root.path)","hook_event_name":"Notification","campfire_event_type":"permission.asked","display_name":"Alice","capability":"shell:exec"}"#, + extraEnvironment: launchEnvironment + ) + #expect(notification.timedOut == false, Comment(rawValue: notification.stderr)) + #expect(notification.status == 0, Comment(rawValue: notification.stderr)) + + let notificationCommands = Array(context.state.snapshot().dropFirst(notificationStart)) + #expect( + notificationCommands.contains { + $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Campfire|Permission|Alice asked for permission to run a shell command") + }, + "Campfire permission notification should be localized in Swift, saw \(notificationCommands)" + ) + #expect( + notificationCommands.contains { + $0.hasPrefix("set_agent_lifecycle campfire needsInput --tab=\(context.workspaceId)") + && $0.contains("--panel=\(context.surfaceId)") + }, + "Campfire permission notification must mark the surface as needing input, saw \(notificationCommands)" + ) + + let stateURL = context.root.appendingPathComponent("campfire-hook-sessions.json") + let state = try #require(JSONSerialization.jsonObject(with: Data(contentsOf: stateURL)) as? [String: Any]) + let sessions = try #require(state["sessions"] as? [String: Any]) + let record = try #require(sessions[sessionId] as? [String: Any]) + #expect(record["agentLifecycle"] as? String == "needsInput") + } + + private final class BundleProbe {} + + private struct ProcessRunResult { + let status: Int32 + let stdout: String + let stderr: String + let timedOut: Bool + } + + private final class MockSocketServerState: @unchecked Sendable { + private let lock = NSLock() + private var commands: [String] = [] + + func append(_ command: String) { + lock.lock() + commands.append(command) + lock.unlock() + } + + func snapshot() -> [String] { + lock.lock() + let value = commands + lock.unlock() + return value + } + } + + private struct HookContext { + let cliPath: String + let socketPath: String + let listenerFD: Int32 + let state: MockSocketServerState + let root: URL + let workspaceId: String + let surfaceId: String + + func cleanup() { + Darwin.close(listenerFD) + unlink(socketPath) + try? FileManager.default.removeItem(at: root) + } + } + + private func makeHookContext(name: String) throws -> HookContext { + let root = FileManager.default.temporaryDirectory + .appendingPathComponent("cmux-\(name)-\(UUID().uuidString)", isDirectory: true) + let socketPath = FileManager.default.temporaryDirectory + .appendingPathComponent("cli-\(name.prefix(6))-\(UUID().uuidString.replacingOccurrences(of: "-", with: "").prefix(8)).sock") + .path + try FileManager.default.createDirectory(at: root, withIntermediateDirectories: true) + return HookContext( + cliPath: try BundledCLITestSupport.bundledCLIPath(for: BundleProbe.self), + socketPath: socketPath, + listenerFD: try bindUnixSocket(at: socketPath), + state: MockSocketServerState(), + root: root, + workspaceId: "11111111-1111-1111-1111-111111111111", + surfaceId: "22222222-2222-2222-2222-222222222222" + ) + } + + private func bindUnixSocket(at path: String) throws -> Int32 { + unlink(path) + let fd = Darwin.socket(AF_UNIX, SOCK_STREAM, 0) + guard fd >= 0 else { + throw posixError("socket") + } + + var addr = sockaddr_un() + addr.sun_family = sa_family_t(AF_UNIX) + let maxPathLength = MemoryLayout.size(ofValue: addr.sun_path) + let utf8 = Array(path.utf8) + guard utf8.count < maxPathLength else { + Darwin.close(fd) + throw NSError(domain: "cmux.tests", code: 1, userInfo: [ + NSLocalizedDescriptionKey: "socket path is too long: \(path)", + ]) + } + _ = withUnsafeMutablePointer(to: &addr.sun_path) { pointer in + pointer.withMemoryRebound(to: CChar.self, capacity: maxPathLength) { buffer in + for index in 0...size)) + } + } + guard bindResult == 0 else { + Darwin.close(fd) + throw posixError("bind") + } + guard Darwin.listen(fd, 1) == 0 else { + Darwin.close(fd) + throw posixError("listen") + } + return fd + } + + private func agentLaunchEnvironment( + context: HookContext, + kind: String, + executable: String, + arguments: [String]? = nil + ) -> [String: String] { + [ + "CMUX_AGENT_LAUNCH_KIND": kind, + "CMUX_AGENT_LAUNCH_EXECUTABLE": executable, + "CMUX_AGENT_LAUNCH_CWD": context.root.path, + "CMUX_AGENT_LAUNCH_ARGV_B64": base64NULSeparated(arguments ?? [executable]), + ] + } + + private func runAgentHook( + context: HookContext, + agent: String, + subcommand: String, + standardInput: String, + extraEnvironment: [String: String] = [:] + ) -> ProcessRunResult { + var environment = [ + "HOME": context.root.path, + "PATH": "/usr/bin:/bin:/usr/sbin:/sbin", + "PWD": context.root.path, + "CMUX_SOCKET_PATH": context.socketPath, + "CMUX_WORKSPACE_ID": context.workspaceId, + "CMUX_SURFACE_ID": context.surfaceId, + "CMUX_AGENT_HOOK_STATE_DIR": context.root.path, + "CMUX_CLI_SENTRY_DISABLED": "1", + ] + environment.merge(extraEnvironment, uniquingKeysWith: { _, new in new }) + + return runProcess( + executablePath: context.cliPath, + arguments: ["hooks", agent, subcommand], + environment: environment, + standardInput: standardInput, + timeout: 5 + ) + } + + private func startAgentHookMockServerAccepting( + context: HookContext, + connectionLimit: Int + ) { + DispatchQueue.global(qos: .userInitiated).async { + var accepted = 0 + while accepted < connectionLimit { + var clientAddr = sockaddr_un() + var clientAddrLen = socklen_t(MemoryLayout.size) + let clientFD = withUnsafeMutablePointer(to: &clientAddr) { ptr in + ptr.withMemoryRebound(to: sockaddr.self, capacity: 1) { sockaddrPtr in + Darwin.accept(context.listenerFD, sockaddrPtr, &clientAddrLen) + } + } + if clientFD < 0 { + if errno == EINTR { continue } + return + } + accepted += 1 + + DispatchQueue.global(qos: .userInitiated).async { + defer { Darwin.close(clientFD) } + var pending = Data() + var buffer = [UInt8](repeating: 0, count: 4096) + while true { + let count = Darwin.read(clientFD, &buffer, buffer.count) + if count < 0 { + if errno == EINTR { continue } + return + } + if count == 0 { return } + pending.append(buffer, count: count) + while let newlineRange = pending.firstRange(of: Data([0x0A])) { + let lineData = pending.subdata(in: 0.. String { + guard let payload = jsonObject(line) else { + return "OK" + } + guard let id = payload["id"] as? String, let method = payload["method"] as? String else { + return malformedRequestResponse(id: payload["id"] as? String, raw: line) + } + switch method { + case "surface.list": + return v2Response( + id: id, + ok: true, + result: ["surfaces": [["id": context.surfaceId, "ref": "surface:1", "focused": true]]] + ) + case "feed.push": + return v2Response(id: id, ok: true, result: [:]) + case "surface.resume.set": + return v2Response(id: id, ok: true, result: ["resume_binding": [:]]) + case "surface.resume.clear": + return v2Response(id: id, ok: true, result: ["cleared": true]) + default: + return v2Response(id: id, ok: false, error: ["code": "unrecognized_method", "message": "unexpected method: \(method)"]) + } + } + + private func runProcess( + executablePath: String, + arguments: [String], + environment: [String: String], + standardInput: String, + timeout: TimeInterval + ) -> ProcessRunResult { + let process = Process() + let stdoutPipe = Pipe() + let stderrPipe = Pipe() + let stdinPipe = Pipe() + process.executableURL = URL(fileURLWithPath: executablePath) + process.arguments = arguments + process.environment = environment + process.standardInput = stdinPipe + process.standardOutput = stdoutPipe + process.standardError = stderrPipe + + do { + try process.run() + } catch { + return ProcessRunResult(status: -1, stdout: "", stderr: String(describing: error), timedOut: false) + } + stdinPipe.fileHandleForWriting.write(Data(standardInput.utf8)) + try? stdinPipe.fileHandleForWriting.close() + + let exitSignal = DispatchSemaphore(value: 0) + DispatchQueue.global(qos: .userInitiated).async { + process.waitUntilExit() + exitSignal.signal() + } + + let timedOut = exitSignal.wait(timeout: .now() + timeout) == .timedOut + if timedOut { + process.terminate() + if exitSignal.wait(timeout: .now() + 1) == .timedOut { + kill(process.processIdentifier, SIGKILL) + _ = exitSignal.wait(timeout: .now() + 1) + } + } + + let stdout = String(data: stdoutPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + let stderr = String(data: stderrPipe.fileHandleForReading.readDataToEndOfFile(), encoding: .utf8) ?? "" + return ProcessRunResult( + status: process.isRunning ? SIGKILL : process.terminationStatus, + stdout: stdout, + stderr: stderr, + timedOut: timedOut + ) + } + + private func base64NULSeparated(_ values: [String]) -> String { + var data = Data() + for value in values { + data.append(contentsOf: value.utf8) + data.append(0) + } + return data.base64EncodedString() + } + + private func v2Response( + id: String, + ok: Bool, + result: [String: Any]? = nil, + error: [String: Any]? = nil + ) -> String { + var payload: [String: Any] = ["id": id, "ok": ok] + if let result { payload["result"] = result } + if let error { payload["error"] = error } + let data = try? JSONSerialization.data(withJSONObject: payload, options: []) + return String(data: data ?? Data("{}".utf8), encoding: .utf8) ?? "{}" + } + + private func malformedRequestResponse(id: String? = nil, raw: String) -> String { + v2Response( + id: id ?? "unknown", + ok: false, + error: ["code": "malformed_request", "message": "invalid or non-JSON payload", "raw": raw] + ) + } + + private func jsonObject(_ line: String) -> [String: Any]? { + guard let data = line.data(using: .utf8) else { return nil } + return try? JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] + } + + private func posixError(_ operation: String) -> NSError { + NSError(domain: NSPOSIXErrorDomain, code: Int(errno), userInfo: [ + NSLocalizedDescriptionKey: "\(operation) failed with errno \(errno)", + ]) + } +} From 38ac2a4bf884a589589236d2cdafb1073b3e4108 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 15:56:06 -0700 Subject: [PATCH 14/24] Fix Campfire runtime Vault restore executable --- Sources/VaultAgentProcessScanner.swift | 38 ++++++++++++++++++++++---- cmuxTests/CampfireSupportTests.swift | 3 ++ 2 files changed, 35 insertions(+), 6 deletions(-) diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 8d8aca149781..d5fc4a362bb1 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -111,8 +111,13 @@ extension RestorableAgentSessionIndex { continue } - let executablePath = normalized(observed.arguments.first) ?? normalized(process.path) ?? registration.defaultExecutable - let arguments = observed.arguments.isEmpty ? [executablePath] : observed.arguments + let useDefaultExecutable = registration.detect.usesAlternateMatchWithoutPrimaryMatch(observed) + let executablePath = useDefaultExecutable + ? registration.defaultExecutable + : (normalized(observed.arguments.first) ?? normalized(process.path) ?? registration.defaultExecutable) + let arguments = useDefaultExecutable + ? [executablePath] + : (observed.arguments.isEmpty ? [executablePath] : observed.arguments) let snapshot = SessionRestorableAgentSnapshot( kind: .custom(registration.id), sessionId: sessionId, @@ -813,19 +818,40 @@ private struct VaultObservedAgentProcess: Sendable { private extension CmuxVaultAgentDetectRule { func matches(_ process: VaultObservedAgentProcess) -> Bool { + let expectedNames = primaryProcessNames + guard !expectedNames.isEmpty || !argvContains.isEmpty || !alternateArgvContains.isEmpty || !alternateArgvContainsAny.isEmpty else { + return false + } + return primaryMatches(process, expectedNames: expectedNames) || alternateMatches(process) + } + + func usesAlternateMatchWithoutPrimaryMatch(_ process: VaultObservedAgentProcess) -> Bool { + let expectedNames = primaryProcessNames + return alternateMatches(process) && !primaryMatches(process, expectedNames: expectedNames) + } + + private var primaryProcessNames: [String] { var expectedNames = processNames if let processName { expectedNames.append(processName) } - guard !expectedNames.isEmpty || !argvContains.isEmpty || !alternateArgvContains.isEmpty || !alternateArgvContainsAny.isEmpty else { - return false - } + return expectedNames + } + + private func primaryMatches( + _ process: VaultObservedAgentProcess, + expectedNames: [String] + ) -> Bool { let processNameMatch = expectedNames.isEmpty || expectedNames.contains { expected in process.executableBasenames.contains { candidate in candidate.compare(expected, options: [.caseInsensitive, .literal]) == .orderedSame } } let argvContainsMatch = argvContains.isEmpty || process.argumentsContainAll(argvContains) + return processNameMatch && argvContainsMatch + } + + private func alternateMatches(_ process: VaultObservedAgentProcess) -> Bool { let alternateProcessNameMatch = alternateProcessNames.isEmpty || alternateProcessNames.contains { expected in process.executableBasenames.contains { candidate in candidate.compare(expected, options: [.caseInsensitive, .literal]) == .orderedSame @@ -837,7 +863,7 @@ private extension CmuxVaultAgentDetectRule { let alternateArgvContainsAnyMatch = !alternateArgvContainsAny.isEmpty && alternateProcessNameMatch && process.argumentsContainAny(alternateArgvContainsAny) - return (processNameMatch && argvContainsMatch) || alternateArgvContainsMatch || alternateArgvContainsAnyMatch + return alternateArgvContainsMatch || alternateArgvContainsAnyMatch } } diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index ce0cca811afc..a3d3ec9685da 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -164,6 +164,9 @@ struct CampfireSupportTests { #expect(detected.kind == RestorableAgentKind.custom("campfire")) #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) #expect(detected.workingDirectory == workspace.path) + #expect(detected.launchCommand?.executablePath == "campfire") + #expect(detected.resumeCommand?.contains("'campfire' '--session'") == true) + #expect(detected.resumeCommand?.contains("/opt/homebrew/bin/bun") == false) } @Test func directProcessDetectionClassifiesCampfireDistInvocation() throws { From b923a36dda39cfbd43a5b2d06112e731ca1dfe2f Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:02:39 -0700 Subject: [PATCH 15/24] Gate Campfire Vault detection to hosts --- Sources/VaultAgentProcessScanner.swift | 8 +++++ cmuxTests/CampfireSupportTests.swift | 50 ++++++++++++++++++++++++-- 2 files changed, 56 insertions(+), 2 deletions(-) diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index d5fc4a362bb1..53ad0744bbee 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -103,6 +103,7 @@ extension RestorableAgentSessionIndex { let cwd = normalized(observed.environment["CMUX_AGENT_LAUNCH_CWD"] ?? observed.environment["PWD"]) let processRegistry = registryForWorkingDirectory(cwd) guard let registration = processRegistry.registrations.first(where: { $0.detect.matches(observed) }), + registration.processDetectedSnapshotIsRestorable(for: observed), let sessionId = registration.sessionIdSource.sessionId( from: observed, registration: registration, @@ -816,6 +817,13 @@ private struct VaultObservedAgentProcess: Sendable { } } +private extension CmuxVaultAgentRegistration { + func processDetectedSnapshotIsRestorable(for process: VaultObservedAgentProcess) -> Bool { + guard id == "campfire" else { return true } + return process.environment["CAMPFIRE_SESSION_ROLE"] == "host" + } +} + private extension CmuxVaultAgentDetectRule { func matches(_ process: VaultObservedAgentProcess) -> Bool { let expectedNames = primaryProcessNames diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index a3d3ec9685da..5834de73f45b 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -293,6 +293,46 @@ struct CampfireSupportTests { #expect(detected == nil) } + @Test func directProcessDetectionSkipsNonHostCampfireRoles() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-joiner-role-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + _ = try Self.writeSessionFile( + id: "campfire-should-not-bind-joiner", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + for role in [nil, "joiner"] as [String?] { + var environment = [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + if let role { + environment["CAMPFIRE_SESSION_ROLE"] = role + } + + let detected = Self.detectedCampfireSnapshot( + processName: "campfire", + processPath: "/Users/example/.local/bin/campfire", + arguments: [ + "/Users/example/.local/bin/campfire", + "--join", + "https://campfire.example/invite/token", + ], + environment: environment, + defaultCampfireSessionRole: nil + ) + + #expect(detected == nil, "role \(role ?? "") must not produce a restorable Campfire snapshot") + } + } + @Test func taskManagerClassifiesCampfireCompiledBinaryAndDevInvocation() throws { let compiled = try #require(CmuxTaskManagerCodingAgentDefinition.matchingDefinition( processName: "campfire", @@ -326,7 +366,8 @@ struct CampfireSupportTests { processPath: String? = "/Users/example/.local/bin/campfire", arguments: [String], environment: [String: String], - registration: CmuxVaultAgentRegistration = .builtInCampfire + registration: CmuxVaultAgentRegistration = .builtInCampfire, + defaultCampfireSessionRole: String? = "host" ) -> SessionRestorableAgentSnapshot? { let workspaceId = UUID(uuidString: "11111111-1111-1111-1111-111111111111")! let panelId = UUID(uuidString: "22222222-2222-2222-2222-222222222222")! @@ -354,6 +395,11 @@ struct CampfireSupportTests { sampledAt: Date(timeIntervalSince1970: 0), includesProcessDetails: true ) + var processEnvironment = environment + if let defaultCampfireSessionRole, + processEnvironment["CAMPFIRE_SESSION_ROLE"] == nil { + processEnvironment["CAMPFIRE_SESSION_ROLE"] = defaultCampfireSessionRole + } return RestorableAgentSessionIndex.processDetectedSnapshots( registry: CmuxVaultAgentRegistry(registrations: [registration]), fileManager: FileManager.default, @@ -361,7 +407,7 @@ struct CampfireSupportTests { capturedAt: 42, processArgumentsProvider: { requestedProcessId in guard requestedProcessId == processId else { return nil } - return CmuxTopProcessArguments(arguments: arguments, environment: environment) + return CmuxTopProcessArguments(arguments: arguments, environment: processEnvironment) } )[panelKey]?.snapshot } From b2dc58e49565b45d9ed1603a70833d2a442bd53d Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Wed, 17 Jun 2026 16:09:50 -0700 Subject: [PATCH 16/24] Fix Campfire Bun argv restore --- CLI/CMUXCLI+CampfireExtension.swift | 45 ++++++- .../AgentLaunchEnvironmentPolicy.swift | 12 ++ Sources/RestorableAgentSession.swift | 8 ++ Sources/VaultAgentProcessScanner.swift | 60 ++++++++- cmuxTests/CampfireSupportTests.swift | 123 ++++++++++++++++++ tests/test_campfire_extension_install.py | 21 +++ 6 files changed, 260 insertions(+), 9 deletions(-) diff --git a/CLI/CMUXCLI+CampfireExtension.swift b/CLI/CMUXCLI+CampfireExtension.swift index 329d483799c0..445780f388d2 100644 --- a/CLI/CMUXCLI+CampfireExtension.swift +++ b/CLI/CMUXCLI+CampfireExtension.swift @@ -61,6 +61,13 @@ function looksLikeJavaScriptRuntime(value: string): boolean { return base === "node" || base === "bun" || base === "deno" || base === "tsx" || base === "ts-node"; } +function campfireScriptIndex(raw: string[]): number { + for (let index = 1; index < raw.length; index += 1) { + if (looksLikeCampfireScript(raw[index] || "")) return index; + } + return -1; +} + function normalizedLaunchArgv(): string[] { const raw = Array.isArray(process.argv) ? process.argv.map((value) => String(value)) : []; if (raw.length === 0) return [resolveExecutable("campfire")]; @@ -69,8 +76,9 @@ function normalizedLaunchArgv(): string[] { if (raw.length > 1 && looksLikeBunfsEntry(raw[1])) return [raw[0], ...raw.slice(2)]; return raw; } - if (raw.length > 1 && looksLikeJavaScriptRuntime(raw[0]) && looksLikeCampfireScript(raw[1])) { - return [resolveExecutable("campfire"), ...raw.slice(2)]; + if (raw.length > 1 && looksLikeJavaScriptRuntime(raw[0])) { + const scriptIndex = campfireScriptIndex(raw); + if (scriptIndex >= 0) return [resolveExecutable("campfire"), ...raw.slice(scriptIndex + 1)]; } return [resolveExecutable("campfire"), ...raw.slice(1)]; } @@ -103,6 +111,11 @@ interface HookInvocation { env: NodeJS.ProcessEnv; } +interface SendHookOptions { + waitForExit?: boolean; + timeoutMs?: number; +} + function eventName(subcommand: string): string { switch (subcommand) { case "session-start": @@ -176,26 +189,44 @@ function hookInvocation(subcommand: string, ctx: ExtensionContext, extra: Record }; } -async function sendHook(subcommand: string, ctx: ExtensionContext, extra: Record = {}): Promise { +async function sendHook( + subcommand: string, + ctx: ExtensionContext, + extra: Record = {}, + options: SendHookOptions = {}, +): Promise { const invocation = hookInvocation(subcommand, ctx, extra); if (!invocation) return; + const waitForExit = options.waitForExit !== false; await new Promise((resolve) => { let settled = false; + let timeout: ReturnType | null = null; const settle = () => { if (settled) return; settled = true; + if (timeout) clearTimeout(timeout); resolve(); }; try { const child = spawn(invocation.cmux, ["hooks", "campfire", subcommand], { env: invocation.env, stdio: ["pipe", "ignore", "ignore"], - detached: true, + detached: !waitForExit, }); child.on("error", settle); child.stdin.on("error", settle); - child.stdin.on("finish", settle); - child.unref(); + if (waitForExit) { + child.on("close", settle); + timeout = setTimeout(() => { + try { + child.kill("SIGTERM"); + } catch (_) {} + settle(); + }, options.timeoutMs ?? 5000); + } else { + child.stdin.on("finish", settle); + child.unref(); + } child.stdin.end(invocation.payload); } catch (_) { settle(); @@ -263,7 +294,7 @@ export default function cmuxCampfireSessionExtension(api: ExtensionAPI) { if (!ctx) return; const payload = observerPayload(event); if (!payload) return; - void sendHook("notification", ctx, payload); + void sendHook("notification", ctx, payload, { waitForExit: false }); }); } """# diff --git a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index 12d46ec749e9..f524ac8fd554 100644 --- a/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -1,8 +1,13 @@ import Foundation +/// Resolves Claude configuration directories that may have moved between cmux-managed auth roots. public struct ClaudeConfigDirectoryPath: Sendable { private init() {} + /// Returns the preferred on-disk Claude config path for a captured launch environment value. + /// + /// Legacy cmux auth directories under `~/.subrouter/codex/claude` are mapped to the newer + /// `~/.codex-accounts/claude` location when the corresponding account directory exists. public static func preferredPath( _ rawPath: String, fileManager: FileManager = .default, @@ -25,7 +30,9 @@ public struct ClaudeConfigDirectoryPath: Sendable { } } +/// Selects the non-secret launch environment values that are safe to replay when restoring agents. public struct AgentLaunchEnvironmentPolicy: Sendable { + /// Creates a launch environment policy. public init() {} private let hermesAgentEnvironmentKeys: Set = [ @@ -98,6 +105,10 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { "USE_BUILTIN_RIPGREP" ] + /// Returns the subset of captured environment variables that should be replayed for an agent. + /// + /// The optional `kind` applies agent-specific exclusions for values that are safe for one + /// agent but managed or incorrect for another. public func selectedEnvironment(from env: [String: String], kind: String? = nil) -> [String: String] { var result: [String: String] = [:] for key in safeEnvironmentKeys.sorted() where key != "NODE_OPTIONS" { @@ -120,6 +131,7 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { return result } + /// Returns a replay-safe value for a single environment variable, or `nil` when it should drop. public func sanitizedValue(key: String, value: String?) -> String? { guard safeEnvironmentKeys.contains(key) else { return nil } switch key { diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index 55ffd41dfecb..cbe99a5e9059 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -448,6 +448,14 @@ enum AgentResumeCommandBuilder { if case .custom = kind { guard let customRegistration else { return nil } + if customRegistration == CmuxVaultAgentRegistration.builtInCampfire { + return AgentResumeArgv().builtInKind( + kind: "campfire", + sessionId: sessionId, + executablePath: launchCommand?.executablePath, + arguments: launchCommand?.arguments ?? [] + ) + } if customRegistration.id == CmuxVaultAgentRegistration.builtInAntigravity.id { return resumeWithOption( kind: "antigravity", diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 53ad0744bbee..c6724599de07 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -113,12 +113,19 @@ extension RestorableAgentSessionIndex { } let useDefaultExecutable = registration.detect.usesAlternateMatchWithoutPrimaryMatch(observed) - let executablePath = useDefaultExecutable + var executablePath = useDefaultExecutable ? registration.defaultExecutable : (normalized(observed.arguments.first) ?? normalized(process.path) ?? registration.defaultExecutable) - let arguments = useDefaultExecutable + var arguments = useDefaultExecutable ? [executablePath] : (observed.arguments.isEmpty ? [executablePath] : observed.arguments) + if registration == CmuxVaultAgentRegistration.builtInCampfire { + arguments = normalizedCampfireLaunchArguments( + observed.arguments, + defaultExecutable: registration.defaultExecutable + ) + executablePath = arguments.first ?? registration.defaultExecutable + } let snapshot = SessionRestorableAgentSnapshot( kind: .custom(registration.id), sessionId: sessionId, @@ -143,6 +150,55 @@ extension RestorableAgentSessionIndex { return resolved } + private static func normalizedCampfireLaunchArguments( + _ arguments: [String], + defaultExecutable: String + ) -> [String] { + guard !arguments.isEmpty else { return [defaultExecutable] } + if campfireArgumentLooksLikeExecutable(arguments[0]) { + if arguments.count > 1, campfireArgumentLooksLikeBunfsEntry(arguments[1]) { + return [arguments[0]] + Array(arguments.dropFirst(2)) + } + return arguments + } + if campfireArgumentLooksLikeJavaScriptRuntime(arguments[0]), + let scriptIndex = campfireScriptArgumentIndex(in: arguments) { + return [defaultExecutable] + Array(arguments.dropFirst(scriptIndex + 1)) + } + return [defaultExecutable] + Array(arguments.dropFirst()) + } + + private static func campfireScriptArgumentIndex(in arguments: [String]) -> Int? { + guard arguments.count > 1 else { return nil } + return arguments.indices.dropFirst().first { campfireArgumentLooksLikeScript(arguments[$0]) } + } + + private static func campfireArgumentLooksLikeBunfsEntry(_ value: String) -> Bool { + let normalized = value.replacingOccurrences(of: "\\", with: "/") + return normalized.contains("$bunfs") + || normalized.contains("~BUN") + || normalized.contains("%7EBUN") + } + + private static func campfireArgumentLooksLikeExecutable(_ value: String) -> Bool { + URL(fileURLWithPath: value).lastPathComponent.compare( + "campfire", + options: [.caseInsensitive, .literal] + ) == .orderedSame && !campfireArgumentLooksLikeBunfsEntry(value) + } + + private static func campfireArgumentLooksLikeScript(_ value: String) -> Bool { + let normalized = value.replacingOccurrences(of: "\\", with: "/").lowercased() + let base = URL(fileURLWithPath: normalized).lastPathComponent + return ["campfire.ts", "campfire.js", "campfire"].contains(base) + && (normalized.contains("/campfire") || normalized.contains("packages/session")) + } + + private static func campfireArgumentLooksLikeJavaScriptRuntime(_ value: String) -> Bool { + let base = URL(fileURLWithPath: value).lastPathComponent.lowercased() + return ["node", "bun", "deno", "tsx", "ts-node"].contains(base) + } + static func processLooksLikeOpenCode( processName: String, processPath: String?, diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index 5834de73f45b..7fbe38ce3e5e 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -169,6 +169,129 @@ struct CampfireSupportTests { #expect(detected.resumeCommand?.contains("/opt/homebrew/bin/bun") == false) } + @Test func directProcessDetectionDropsCampfireBunfsEntrypointAndPreservesFlags() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-bunfs-invocation-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + let latest = try Self.writeSessionFile( + id: "campfire-bunfs-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = try #require(Self.detectedCampfireSnapshot( + processName: "campfire", + processPath: "/Users/example/.local/bin/campfire", + arguments: [ + "/Users/example/.local/bin/campfire", + "/$bunfs/root/campfire", + "--relay", + "wss://relay.example/ws", + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + )) + + #expect(detected.kind == RestorableAgentKind.custom("campfire")) + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.launchCommand?.arguments == [ + "/Users/example/.local/bin/campfire", + "--relay", + "wss://relay.example/ws", + ]) + #expect(detected.resumeCommand?.contains("$bunfs") == false) + #expect(detected.resumeCommand?.contains("'--relay' 'wss://relay.example/ws'") == true) + } + + @Test func directProcessDetectionDropsCampfireRuntimePrefixAndPreservesFlags() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-runtime-prefix-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + let latest = try Self.writeSessionFile( + id: "campfire-runtime-prefix-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = try #require(Self.detectedCampfireSnapshot( + processName: "node", + processPath: "/opt/homebrew/bin/node", + arguments: [ + "/opt/homebrew/bin/node", + "--import", + "./loader.mjs", + "/Users/example/campfire/packages/session/bin/campfire.ts", + "--relay", + "wss://relay.example/ws", + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + )) + + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.launchCommand?.arguments == [ + "campfire", + "--relay", + "wss://relay.example/ws", + ]) + #expect(detected.resumeCommand?.contains("'--import'") == false) + #expect(detected.resumeCommand?.contains("'./loader.mjs'") == false) + #expect(detected.resumeCommand?.contains("'--relay' 'wss://relay.example/ws'") == true) + } + + @Test func campfireRegistrationOverrideKeepsConfiguredResumeCommand() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-override-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + let latest = try Self.writeSessionFile( + id: "campfire-override-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + var registration = CmuxVaultAgentRegistration.builtInCampfire + registration.resumeCommand = "custom-campfire restore {{sessionId}}" + + let detected = try #require(Self.detectedCampfireSnapshot( + arguments: [ + "/Users/example/.local/bin/campfire", + "/$bunfs/root/campfire", + "--relay", + "wss://relay.example/ws", + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ], + registration: registration + )) + + #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.resumeCommand?.contains("'custom-campfire' 'restore'") == true) + #expect(detected.resumeCommand?.contains("'campfire' '--session'") == false) + } + @Test func directProcessDetectionClassifiesCampfireDistInvocation() throws { let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-dist-invocation-") defer { try? FileManager.default.removeItem(at: root) } diff --git a/tests/test_campfire_extension_install.py b/tests/test_campfire_extension_install.py index 551e66f35801..e4384f98f643 100644 --- a/tests/test_campfire_extension_install.py +++ b/tests/test_campfire_extension_install.py @@ -357,10 +357,16 @@ def main() -> int: fake_args_log = root / "fake-cmux-args.log" fake_stdin_log = root / "fake-cmux-stdin.log" fake_env_log = root / "fake-cmux-env.log" + fake_order_log = root / "fake-cmux-order.log" make_executable( fake_cmux, """#!/usr/bin/env bash set -euo pipefail +printf 'start %s\n' "$*" >> "$FAKE_CMUX_ORDER_LOG" +case "$*" in + *notification*) sleep 1 ;; + *) sleep 0.15 ;; +esac printf '%s\n' "$*" >> "$FAKE_CMUX_ARGS_LOG" cat >> "$FAKE_CMUX_STDIN_LOG" printf '\n---\n' >> "$FAKE_CMUX_STDIN_LOG" @@ -369,6 +375,7 @@ def main() -> int: printf 'cwd=%s\n' "${CMUX_AGENT_LAUNCH_CWD-}" printf 'argv=%s\n' "${CMUX_AGENT_LAUNCH_ARGV_B64-}" } >> "$FAKE_CMUX_ENV_LOG" +printf 'end %s\n' "$*" >> "$FAKE_CMUX_ORDER_LOG" """, ) @@ -379,6 +386,7 @@ def main() -> int: check_env["FAKE_CMUX_ARGS_LOG"] = str(fake_args_log) check_env["FAKE_CMUX_STDIN_LOG"] = str(fake_stdin_log) check_env["FAKE_CMUX_ENV_LOG"] = str(fake_env_log) + check_env["FAKE_CMUX_ORDER_LOG"] = str(fake_order_log) check_env["CAMPFIRE_SESSION_ROLE"] = "host" check_source = """ const extensionPath = process.env.CMUX_TEST_CAMPFIRE_EXTENSION_PATH; @@ -452,6 +460,19 @@ def main() -> int: args_log = wait_for_text(fake_args_log, expected_invocations) stdin_log = wait_for_text(fake_stdin_log, expected_invocations * 2) env_log = wait_for_text(fake_env_log, expected_invocations * 3) + order_log = wait_for_text(fake_order_log, expected_invocations * 2) + order_lines = [line for line in order_log.splitlines() if line.strip()] + expected_lifecycle_order = [ + "start hooks campfire session-start", + "end hooks campfire session-start", + "start hooks campfire prompt-submit", + "end hooks campfire prompt-submit", + "start hooks campfire stop", + "end hooks campfire stop", + ] + if order_lines[: len(expected_lifecycle_order)] != expected_lifecycle_order: + print(f"FAIL: lifecycle hooks did not run serially, got {order_log!r}") + return 1 for expected in [ "hooks campfire session-start", "hooks campfire prompt-submit", From 6e4a1f4c8fdf8f0fd22181481ab13ac15c35c3cf Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Sun, 28 Jun 2026 12:02:46 -0700 Subject: [PATCH 17/24] Add failing test for alternate-only Vault detect rule A CmuxVaultAgentDetectRule that specifies only alternate criteria (no primary process names and no argvContains) currently matches every process: the empty primary criteria make primaryMatches return true before the alternate criteria are checked. This test asserts an unrelated `node` process is not classified, and fails without the fix. Co-Authored-By: Claude Opus 4.8 (1M context) --- cmuxTests/CampfireSupportTests.swift | 21 +++++++++++++++++++++ 1 file changed, 21 insertions(+) diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index 7fbe38ce3e5e..ad7d4041c8db 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -484,6 +484,27 @@ struct CampfireSupportTests { #expect(registration.sessionDirectory == "~/.campfire/agent/sessions") } + @Test func alternateOnlyDetectRuleDoesNotMatchUnrelatedProcess() throws { + // A detect rule that specifies only alternate criteria (no primary + // process names and no `argvContains`) must not classify an unrelated + // process. Otherwise the empty primary criteria make the primary match + // succeed for every process before the alternate criteria are checked. + var registration = CmuxVaultAgentRegistration.builtInCampfire + registration.detect = CmuxVaultAgentDetectRule( + alternateArgvContainsAny: ["packages/session/bin/campfire.ts"] + ) + + let detected = Self.detectedCampfireSnapshot( + processName: "node", + processPath: "/opt/homebrew/bin/node", + arguments: ["/opt/homebrew/bin/node", "some-other-script.js"], + environment: ["PWD": "/tmp"], + registration: registration + ) + + #expect(detected == nil) + } + private static func detectedCampfireSnapshot( processName: String = "campfire", processPath: String? = "/Users/example/.local/bin/campfire", From 953ca20a71a742c1928d80bee00380bee0b70f40 Mon Sep 17 00:00:00 2001 From: Nishant Joshi Date: Sun, 28 Jun 2026 12:03:34 -0700 Subject: [PATCH 18/24] Address PR review: detect-rule matching, ts-node host, capability fallback MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - VaultAgentProcessScanner: gate the primary match on the presence of primary criteria so an alternate-only detect rule no longer matches every process (fixes the test added in the previous commit). - TaskManagerTypes: add `ts-node` to argumentHostBasenames so Task Manager classifies `ts-node …/campfire.ts` as Campfire, matching the hosts already recognized by Vault detection. - cmux CLI: route unknown/unmapped Campfire capability values to the localized fallback label instead of surfacing the raw identifier in user-facing notification copy. Co-Authored-By: Claude Opus 4.8 (1M context) --- CLI/cmux.swift | 7 ++++--- Sources/TaskManagerTypes.swift | 2 +- Sources/VaultAgentProcessScanner.swift | 11 +++++++++-- 3 files changed, 14 insertions(+), 6 deletions(-) diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f4d218192ac1..464585c13f90 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -25997,9 +25997,10 @@ struct CMUXCLI { return String(localized: "agent.campfire.capability.toolsContribute", defaultValue: "add tools or skills") case "files:list": return String(localized: "agent.campfire.capability.filesList", defaultValue: "browse files") - case let value?: - return value - case nil: + default: + // Unknown/unmapped (or nil) capabilities fall back to the localized + // generic label rather than surfacing the raw capability identifier + // in user-facing notification copy. return String(localized: "agent.campfire.capability.fallback", defaultValue: "do something") } } diff --git a/Sources/TaskManagerTypes.swift b/Sources/TaskManagerTypes.swift index 3870359385cc..244b249d9cc3 100644 --- a/Sources/TaskManagerTypes.swift +++ b/Sources/TaskManagerTypes.swift @@ -744,7 +744,7 @@ struct CmuxTaskManagerCodingAgentDefinition: Equatable { } private static let argumentHostBasenames: Set = [ - "node", "bun", "deno", "npm", "npx", "pnpm", "yarn", "tsx" + "node", "bun", "deno", "npm", "npx", "pnpm", "yarn", "tsx", "ts-node" ] private static let ambiguousDirectBasenames: Set = [ diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index 599efbb5e7b4..c25ac99cde9e 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -889,10 +889,17 @@ private extension CmuxVaultAgentRegistration { private extension CmuxVaultAgentDetectRule { func matches(_ process: VaultObservedAgentProcess) -> Bool { let expectedNames = primaryProcessNames - guard !expectedNames.isEmpty || !argvContains.isEmpty || !alternateArgvContains.isEmpty || !alternateArgvContainsAny.isEmpty else { + let hasPrimaryCriteria = !expectedNames.isEmpty || !argvContains.isEmpty + let hasAlternateCriteria = !alternateArgvContains.isEmpty || !alternateArgvContainsAny.isEmpty + guard hasPrimaryCriteria || hasAlternateCriteria else { return false } - return primaryMatches(process, expectedNames: expectedNames) || alternateMatches(process) + // Gate the primary match on the presence of primary criteria. Without + // this, an alternate-only rule (empty process names and `argvContains`) + // makes `primaryMatches` return true for every process, so it would + // match before the alternate criteria are ever checked. + let primary = hasPrimaryCriteria && primaryMatches(process, expectedNames: expectedNames) + return primary || alternateMatches(process) } func usesAlternateMatchWithoutPrimaryMatch(_ process: VaultObservedAgentProcess) -> Bool { From 952d8ab7f52bc620b0d95629cf442bc45befdb87 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Fri, 3 Jul 2026 20:32:08 -0700 Subject: [PATCH 19/24] Fix Campfire review blockers --- CLI/CMUXCLI+CampfireExtension.swift | 10 +- ...CMUXCLI+SessionsListForkStartupInput.swift | 2 +- CLI/cmux.swift | 6 +- .../AgentLaunchCaptureTrust.swift | 8 + .../AgentLaunchCaptureTrustTests.swift | 111 ++++++----- .../Surface/TerminalSurface+Renderer.swift | 4 +- .../Interop/GhosttyRuntimeCInterop.swift | 19 -- .../GhosttySurfaceRendererRealizer.swift | 28 +++ .../CampfireLaunchArgumentNormalizer.swift | 51 +++++ Sources/RestorableAgentSession.swift | 3 +- Sources/VaultAgentProcessScanner.swift | 94 ++++------ Sources/VaultAgentRegistry.swift | 2 +- cmux.xcodeproj/project.pbxproj | 4 + cmuxTests/CampfireHookNotificationTests.swift | 4 +- cmuxTests/CampfireSupportTests.swift | 176 +++++++++++++++--- cmuxTests/OmpSupportTests.swift | 14 ++ docs/vault.md | 2 +- tests/test_campfire_extension_install.py | 6 + 18 files changed, 390 insertions(+), 154 deletions(-) create mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRendererRealizer.swift create mode 100644 Sources/CampfireLaunchArgumentNormalizer.swift diff --git a/CLI/CMUXCLI+CampfireExtension.swift b/CLI/CMUXCLI+CampfireExtension.swift index 445780f388d2..7ffeea3d5533 100644 --- a/CLI/CMUXCLI+CampfireExtension.swift +++ b/CLI/CMUXCLI+CampfireExtension.swift @@ -94,7 +94,13 @@ function base64NulSeparated(values: string[]): string { function hookEnvironment(cwd: string): NodeJS.ProcessEnv { const env: NodeJS.ProcessEnv = { ...process.env }; - if (!env.CMUX_AGENT_LAUNCH_ARGV_B64) { + const launchKind = String(env.CMUX_AGENT_LAUNCH_KIND || "").toLowerCase(); + const shouldCaptureLaunch = + launchKind !== "campfire" || + !env.CMUX_AGENT_LAUNCH_EXECUTABLE || + !env.CMUX_AGENT_LAUNCH_ARGV_B64 || + !env.CMUX_AGENT_LAUNCH_CWD; + if (shouldCaptureLaunch) { const argv = normalizedLaunchArgv(); env.CMUX_AGENT_LAUNCH_KIND = "campfire"; env.CMUX_AGENT_LAUNCH_EXECUTABLE = argv[0] || resolveExecutable("campfire"); @@ -336,7 +342,7 @@ export default function cmuxCampfireSessionExtension(api: ExtensionAPI) { ), url.path ) - throw CLIError(message: "\(message): \(String(describing: error))") + throw CLIError(message: message) } } diff --git a/CLI/CMUXCLI+SessionsListForkStartupInput.swift b/CLI/CMUXCLI+SessionsListForkStartupInput.swift index d151256f5519..662f8e18e3b6 100644 --- a/CLI/CMUXCLI+SessionsListForkStartupInput.swift +++ b/CLI/CMUXCLI+SessionsListForkStartupInput.swift @@ -83,7 +83,7 @@ extension CMUXCLI { environment: [String: String]? ) -> [String] { guard let environment, !environment.isEmpty else { return [] } - let selectedEnvironment = AgentLaunchEnvironmentPolicy.selectedEnvironment(from: environment, kind: agent) + let selectedEnvironment = AgentLaunchEnvironmentPolicy().selectedEnvironment(from: environment, kind: agent) var environmentParts: [String] = [] var preservedClaudeKeys: [String] = [] for key in selectedEnvironment.keys.sorted() { diff --git a/CLI/cmux.swift b/CLI/cmux.swift index f9ad6a998253..ed5a16eb47d2 100644 --- a/CLI/cmux.swift +++ b/CLI/cmux.swift @@ -26080,7 +26080,8 @@ struct CMUXCLI { subtitle: String(localized: "agent.generic.notification.subtitle.waiting", defaultValue: "Waiting"), body: truncate(body, maxLength: 180), status: .needsInput, - isFallback: false + isFallback: false, + notifyCategory: .needsPermission ) case "permission.asked": let name = displayName ?? String(localized: "agent.campfire.notification.participantFallback", defaultValue: "Someone") @@ -26096,7 +26097,8 @@ struct CMUXCLI { subtitle: String(localized: "agent.generic.notification.subtitle.permission", defaultValue: "Permission"), body: truncate(body, maxLength: 180), status: .needsInput, - isFallback: false + isFallback: false, + notifyCategory: .needsPermission ) case "relay.error": return AgentHookNotificationSummary( diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift index a13f16ee5ac5..5f64d7da51de 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift @@ -20,6 +20,7 @@ public enum AgentLaunchCaptureTrust { private static let nativeProcessAliasesByKind: [String: Set] = [ "antigravity": ["agy"], + "campfire": ["campfire"], "claude": ["claude"], "codex": ["codex"], "codebuddy": ["codebuddy"], @@ -127,6 +128,13 @@ public enum AgentLaunchCaptureTrust { }) { descriptors.insert("claude") } + if arguments.dropFirst().contains(where: { argument in + let lowered = argument.replacingOccurrences(of: "\\", with: "/").lowercased() + return lowered.contains("packages/session/bin/campfire.ts") + || lowered.contains("packages/session/dist/campfire") + }) { + descriptors.insert("campfire") + } return descriptors } diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift index 414eabd583e8..dd533274b464 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift @@ -1,90 +1,117 @@ -import XCTest +import Testing @testable import CMUXAgentLaunch -final class AgentLaunchCaptureTrustTests: XCTestCase { - func testExactKindMatchIsTrusted() { - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("codex", kind: "codex")) - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("Claude", kind: "claude")) - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("pi", kind: "pi")) +@Suite("Agent launch capture trust") +struct AgentLaunchCaptureTrustTests { + @Test func exactKindMatchIsTrusted() { + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("codex", kind: "codex")) + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("Claude", kind: "claude")) + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("pi", kind: "pi")) } - func testAbsentLauncherIsTrusted() { - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind(nil, kind: "codex")) - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind(" ", kind: "codex")) + @Test func absentLauncherIsTrusted() { + #expect(AgentLaunchCaptureTrust.launcherDescribesKind(nil, kind: "codex")) + #expect(AgentLaunchCaptureTrust.launcherDescribesKind(" ", kind: "codex")) } - func testWrapperLaunchersDescribeTheirKind() { - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("claudeTeams", kind: "claude")) - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("codexTeams", kind: "codex")) - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("omo", kind: "opencode")) - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("omx", kind: "opencode")) - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("omc", kind: "opencode")) - XCTAssertTrue(AgentLaunchCaptureTrust.launcherDescribesKind("omp", kind: "pi")) + @Test func wrapperLaunchersDescribeTheirKind() { + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("claudeTeams", kind: "claude")) + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("codexTeams", kind: "codex")) + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("omo", kind: "opencode")) + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("omx", kind: "opencode")) + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("omc", kind: "opencode")) + #expect(AgentLaunchCaptureTrust.launcherDescribesKind("omp", kind: "pi")) } - func testCrossAgentLauncherIsDistrusted() { - XCTAssertFalse(AgentLaunchCaptureTrust.launcherDescribesKind("claude", kind: "codex")) - XCTAssertFalse(AgentLaunchCaptureTrust.launcherDescribesKind("codex", kind: "claude")) - XCTAssertFalse(AgentLaunchCaptureTrust.launcherDescribesKind("claudeTeams", kind: "codex")) - XCTAssertFalse(AgentLaunchCaptureTrust.launcherDescribesKind("omo", kind: "codex")) + @Test func crossAgentLauncherIsDistrusted() { + #expect(!AgentLaunchCaptureTrust.launcherDescribesKind("claude", kind: "codex")) + #expect(!AgentLaunchCaptureTrust.launcherDescribesKind("codex", kind: "claude")) + #expect(!AgentLaunchCaptureTrust.launcherDescribesKind("claudeTeams", kind: "codex")) + #expect(!AgentLaunchCaptureTrust.launcherDescribesKind("omo", kind: "codex")) } - func testShellWrapperArgvDetection() { - XCTAssertTrue(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["sh", "-c", "eval x"])) - XCTAssertTrue(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["/bin/zsh", "-lc", "codex"])) - XCTAssertTrue(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["/bin/zsh", "-lic", "codex"])) - XCTAssertFalse(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["/usr/local/bin/codex", "--yolo"])) - XCTAssertFalse(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper([])) + @Test func shellWrapperArgvDetection() { + #expect(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["sh", "-c", "eval x"])) + #expect(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["/bin/zsh", "-lc", "codex"])) + #expect(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["/bin/zsh", "-lic", "codex"])) + #expect(!AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["/usr/local/bin/codex", "--yolo"])) + #expect(!AgentLaunchCaptureTrust.argvLooksLikeShellWrapper([])) // An agent that merely shares a shell's basename must stay trusted. - XCTAssertFalse(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["/Users/alice/.local/bin/fish", "--resume", "x"])) - XCTAssertFalse(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["sh"])) + #expect(!AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["/Users/alice/.local/bin/fish", "--resume", "x"])) + #expect(!AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["sh"])) // `--chrome` is a long option, not a shell command-string flag. - XCTAssertFalse(AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["zsh", "--chrome"])) + #expect(!AgentLaunchCaptureTrust.argvLooksLikeShellWrapper(["zsh", "--chrome"])) } - func testPIDProcessMetadataMustMatchHookKind() { - XCTAssertTrue( + @Test func pidProcessMetadataMustMatchHookKind() { + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "codex", arguments: ["/opt/homebrew/bin/codex", "--sandbox", "workspace-write"], kind: "codex" ) ) - XCTAssertTrue( + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( processName: "codex", arguments: ["/opt/homebrew/bin/codex", "--sandbox", "workspace-write"] ) ) - XCTAssertTrue( + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "node", arguments: ["node", "/Users/alice/.claude/local/claude.js"], kind: "claude" ) ) - XCTAssertTrue( + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "grok-macos-aarch64", arguments: ["/Users/alice/.local/bin/grok-macos-aarch64", "-r", "session"], kind: "grok" ) ) - XCTAssertTrue( + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "kiro-cli", arguments: ["/Users/alice/.cargo/bin/kiro-cli", "chat"], kind: "kiro" ) ) - XCTAssertTrue( + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "campfire", + arguments: ["/Users/alice/.local/bin/campfire", "--session", "session"], + kind: "campfire" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "campfire", + arguments: ["/Users/alice/.local/bin/campfire", "--session", "session"] + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "bun", + arguments: ["bun", "/Users/alice/campfire/packages/session/bin/campfire.ts"], + kind: "campfire" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "node", + arguments: ["node", "/Users/alice/campfire/packages/session/dist/campfire"] + ) + ) + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "acme-agent", arguments: ["/Users/alice/bin/acme-agent", "--session", "native-session"], kind: "acme-agent" ) ) - XCTAssertFalse( + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "cmux DEV", arguments: [ @@ -92,16 +119,16 @@ final class AgentLaunchCaptureTrustTests: XCTestCase { "-NSTreatUnknownArgumentsAsOpen", ], kind: "codex" - ) + ) == false ) - XCTAssertFalse( + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "codex", arguments: ["/opt/homebrew/bin/codex"], kind: "claude" - ) + ) == false ) - XCTAssertTrue( + #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "agy", arguments: ["/usr/local/bin/agy"], diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 7ddc4b3663e7..078c72a21663 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -109,7 +109,7 @@ extension TerminalSurface { // mailbox is full). If it dropped, keep `rendererRealized = true` so the // controller retries on its next pass rather than desyncing from // Ghostty's still-realized swap chain. - if GhosttyRuntimeCInterop.setRendererRealized(surface, false) { + if GhosttySurfaceRendererRealizer().setRealized(surface, false) { rendererRealized = false } #endif @@ -136,7 +136,7 @@ extension TerminalSurface { // the push drops, `rendererRealized` stays false, and the controller's // pass re-realizes any visible-but-unrealized surface as the backstop. We // never block the main actor waiting on the renderer thread. - if GhosttyRuntimeCInterop.setRendererRealized(surface, true) { + if GhosttySurfaceRendererRealizer().setRealized(surface, true) { rendererRealized = true } else { // Enqueue dropped (full mailbox, i.e. the renderer thread is not diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttyRuntimeCInterop.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttyRuntimeCInterop.swift index bea2cc6dbab1..448c4aeba50e 100644 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttyRuntimeCInterop.swift +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttyRuntimeCInterop.swift @@ -6,11 +6,6 @@ public import GhosttyKit @_silgen_name("ghostty_surface_clear_selection") private func cmux_ghostty_surface_clear_selection(_ surface: ghostty_surface_t) -> Bool -// lint:allow free-function — @_silgen_name FFI declaration: see -// `cmux_ghostty_surface_clear_selection` above. -@_silgen_name("ghostty_surface_set_renderer_realized") -private func cmux_ghostty_surface_set_renderer_realized(_ surface: ghostty_surface_t, _ realized: Bool) -> Bool - /// The one sanctioned seam for libghostty symbols that are linked by name /// rather than imported through the GhosttyKit header. /// @@ -38,18 +33,4 @@ public struct GhosttyRuntimeCInterop { cmux_ghostty_surface_clear_selection(surface) } - /// Sets whether a runtime surface owns realized GPU renderer resources. - /// - /// Mirrors `ghostty_surface_set_renderer_realized` from the cmux libghostty - /// fork. The call asks Ghostty to enqueue a renderer realize/release - /// transition without blocking the caller. - /// - /// - Parameters: - /// - surface: The live runtime surface to update. - /// - realized: Whether the surface should hold renderer resources. - /// - Returns: Whether Ghostty accepted the renderer transition message. - @discardableResult - public static func setRendererRealized(_ surface: ghostty_surface_t, _ realized: Bool) -> Bool { - cmux_ghostty_surface_set_renderer_realized(surface, realized) - } } diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRendererRealizer.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRendererRealizer.swift new file mode 100644 index 000000000000..6609f77c0ff6 --- /dev/null +++ b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRendererRealizer.swift @@ -0,0 +1,28 @@ +public import GhosttyKit + +// lint:allow free-function — @_silgen_name FFI declaration: the symbol is +// exported by libghostty without a public header entry, so it must be declared +// as a bare function signature for the linker to bind. +@_silgen_name("ghostty_surface_set_renderer_realized") +private func cmux_ghostty_surface_set_renderer_realized(_ surface: ghostty_surface_t, _ realized: Bool) -> Bool + +/// Sets renderer-realization state for a Ghostty runtime surface. +public struct GhosttySurfaceRendererRealizer: Sendable { + /// Creates a renderer-realization service. + public init() {} + + /// Sets whether a runtime surface owns realized GPU renderer resources. + /// + /// Mirrors `ghostty_surface_set_renderer_realized` from the cmux libghostty + /// fork. The call asks Ghostty to enqueue a renderer realize/release + /// transition without blocking the caller. + /// + /// - Parameters: + /// - surface: The live runtime surface to update. + /// - realized: Whether the surface should hold renderer resources. + /// - Returns: Whether Ghostty accepted the renderer transition message. + @discardableResult + public func setRealized(_ surface: ghostty_surface_t, _ realized: Bool) -> Bool { + cmux_ghostty_surface_set_renderer_realized(surface, realized) + } +} diff --git a/Sources/CampfireLaunchArgumentNormalizer.swift b/Sources/CampfireLaunchArgumentNormalizer.swift new file mode 100644 index 000000000000..a8fe51b0abd1 --- /dev/null +++ b/Sources/CampfireLaunchArgumentNormalizer.swift @@ -0,0 +1,51 @@ +import Foundation + +struct CampfireLaunchArgumentNormalizer: Sendable { + var defaultExecutable: String + + func normalized(arguments: [String]) -> [String] { + guard !arguments.isEmpty else { return [defaultExecutable] } + if argumentLooksLikeExecutable(arguments[0]) { + if arguments.count > 1, argumentLooksLikeBunfsEntry(arguments[1]) { + return [arguments[0]] + Array(arguments.dropFirst(2)) + } + return arguments + } + if argumentLooksLikeJavaScriptRuntime(arguments[0]), + let scriptIndex = scriptArgumentIndex(in: arguments) { + return [defaultExecutable] + Array(arguments.dropFirst(scriptIndex + 1)) + } + return [defaultExecutable] + Array(arguments.dropFirst()) + } + + private func scriptArgumentIndex(in arguments: [String]) -> Int? { + guard arguments.count > 1 else { return nil } + return arguments.indices.dropFirst().first { argumentLooksLikeScript(arguments[$0]) } + } + + private func argumentLooksLikeBunfsEntry(_ value: String) -> Bool { + let normalized = value.replacingOccurrences(of: "\\", with: "/") + return normalized.contains("$bunfs") + || normalized.contains("~BUN") + || normalized.contains("%7EBUN") + } + + private func argumentLooksLikeExecutable(_ value: String) -> Bool { + URL(fileURLWithPath: value).lastPathComponent.compare( + "campfire", + options: [.caseInsensitive, .literal] + ) == .orderedSame && !argumentLooksLikeBunfsEntry(value) + } + + private func argumentLooksLikeScript(_ value: String) -> Bool { + let normalized = value.replacingOccurrences(of: "\\", with: "/").lowercased() + let base = URL(fileURLWithPath: normalized).lastPathComponent + return ["campfire.ts", "campfire.js", "campfire"].contains(base) + && (normalized.contains("/campfire") || normalized.contains("packages/session")) + } + + private func argumentLooksLikeJavaScriptRuntime(_ value: String) -> Bool { + let base = URL(fileURLWithPath: value).lastPathComponent.lowercased() + return ["node", "bun", "deno", "tsx", "ts-node"].contains(base) + } +} diff --git a/Sources/RestorableAgentSession.swift b/Sources/RestorableAgentSession.swift index fe3599ba2619..6832be81fa10 100644 --- a/Sources/RestorableAgentSession.swift +++ b/Sources/RestorableAgentSession.swift @@ -489,7 +489,8 @@ enum AgentResumeCommandBuilder { if case .custom = kind { guard let customRegistration else { return nil } - if customRegistration == CmuxVaultAgentRegistration.builtInCampfire { + if customRegistration.id == CmuxVaultAgentRegistration.builtInCampfire.id, + customRegistration.resumeCommand == CmuxVaultAgentRegistration.builtInCampfire.resumeCommand { return AgentResumeArgv().builtInKind( kind: "campfire", sessionId: sessionId, diff --git a/Sources/VaultAgentProcessScanner.swift b/Sources/VaultAgentProcessScanner.swift index c25ac99cde9e..8790c9386a81 100644 --- a/Sources/VaultAgentProcessScanner.swift +++ b/Sources/VaultAgentProcessScanner.swift @@ -120,13 +120,12 @@ extension RestorableAgentSessionIndex { ? registration.defaultExecutable : (normalized(observed.arguments.first) ?? normalized(process.path) ?? registration.defaultExecutable) var arguments = useDefaultExecutable - ? [executablePath] + ? registration.detect.alternateLaunchArguments(for: observed, defaultExecutable: executablePath) : (observed.arguments.isEmpty ? [executablePath] : observed.arguments) - if registration == CmuxVaultAgentRegistration.builtInCampfire { - arguments = normalizedCampfireLaunchArguments( - observed.arguments, + if registration.id == CmuxVaultAgentRegistration.builtInCampfire.id { + arguments = CampfireLaunchArgumentNormalizer( defaultExecutable: registration.defaultExecutable - ) + ).normalized(arguments: observed.arguments) executablePath = arguments.first ?? registration.defaultExecutable } let snapshot = SessionRestorableAgentSnapshot( @@ -154,55 +153,6 @@ extension RestorableAgentSessionIndex { return resolved } - private static func normalizedCampfireLaunchArguments( - _ arguments: [String], - defaultExecutable: String - ) -> [String] { - guard !arguments.isEmpty else { return [defaultExecutable] } - if campfireArgumentLooksLikeExecutable(arguments[0]) { - if arguments.count > 1, campfireArgumentLooksLikeBunfsEntry(arguments[1]) { - return [arguments[0]] + Array(arguments.dropFirst(2)) - } - return arguments - } - if campfireArgumentLooksLikeJavaScriptRuntime(arguments[0]), - let scriptIndex = campfireScriptArgumentIndex(in: arguments) { - return [defaultExecutable] + Array(arguments.dropFirst(scriptIndex + 1)) - } - return [defaultExecutable] + Array(arguments.dropFirst()) - } - - private static func campfireScriptArgumentIndex(in arguments: [String]) -> Int? { - guard arguments.count > 1 else { return nil } - return arguments.indices.dropFirst().first { campfireArgumentLooksLikeScript(arguments[$0]) } - } - - private static func campfireArgumentLooksLikeBunfsEntry(_ value: String) -> Bool { - let normalized = value.replacingOccurrences(of: "\\", with: "/") - return normalized.contains("$bunfs") - || normalized.contains("~BUN") - || normalized.contains("%7EBUN") - } - - private static func campfireArgumentLooksLikeExecutable(_ value: String) -> Bool { - URL(fileURLWithPath: value).lastPathComponent.compare( - "campfire", - options: [.caseInsensitive, .literal] - ) == .orderedSame && !campfireArgumentLooksLikeBunfsEntry(value) - } - - private static func campfireArgumentLooksLikeScript(_ value: String) -> Bool { - let normalized = value.replacingOccurrences(of: "\\", with: "/").lowercased() - let base = URL(fileURLWithPath: normalized).lastPathComponent - return ["campfire.ts", "campfire.js", "campfire"].contains(base) - && (normalized.contains("/campfire") || normalized.contains("packages/session")) - } - - private static func campfireArgumentLooksLikeJavaScriptRuntime(_ value: String) -> Bool { - let base = URL(fileURLWithPath: value).lastPathComponent.lowercased() - return ["node", "bun", "deno", "tsx", "ts-node"].contains(base) - } - static func processLooksLikeOpenCode( processName: String, processPath: String?, @@ -904,7 +854,17 @@ private extension CmuxVaultAgentDetectRule { func usesAlternateMatchWithoutPrimaryMatch(_ process: VaultObservedAgentProcess) -> Bool { let expectedNames = primaryProcessNames - return alternateMatches(process) && !primaryMatches(process, expectedNames: expectedNames) + let hasPrimaryCriteria = !expectedNames.isEmpty || !argvContains.isEmpty + return alternateMatches(process) + && !(hasPrimaryCriteria && primaryMatches(process, expectedNames: expectedNames)) + } + + func alternateLaunchArguments(for process: VaultObservedAgentProcess, defaultExecutable: String) -> [String] { + guard !process.arguments.isEmpty else { return [defaultExecutable] } + if let entrypointIndex = alternateEntrypointIndex(in: process.arguments) { + return [defaultExecutable] + Array(process.arguments.dropFirst(entrypointIndex + 1)) + } + return [defaultExecutable] + Array(process.arguments.dropFirst()) } private var primaryProcessNames: [String] { @@ -942,6 +902,30 @@ private extension CmuxVaultAgentDetectRule { && process.argumentsContainAny(alternateArgvContainsAny) return alternateArgvContainsMatch || alternateArgvContainsAnyMatch } + + private func alternateEntrypointIndex(in arguments: [String]) -> Int? { + let needles = alternateArgvContains + alternateArgvContainsAny + return arguments.indices.first { index in + needles.contains { argument(arguments[index], containsNeedle: $0) } + } + } + + private func argument(_ argument: String, containsNeedle needle: String) -> Bool { + guard !needle.isEmpty else { return false } + if needle.contains("/") { + let normalizedArgument = argument.replacingOccurrences(of: "\\", with: "/") + let normalizedNeedle = needle.replacingOccurrences(of: "\\", with: "/") + return normalizedArgument.range( + of: normalizedNeedle, + options: [.caseInsensitive, .literal] + ) != nil + } + return argument.range(of: needle, options: [.caseInsensitive, .literal]) != nil + || (argument as NSString).lastPathComponent.range( + of: needle, + options: [.caseInsensitive, .literal] + ) != nil + } } private extension VaultObservedAgentProcess { diff --git a/Sources/VaultAgentRegistry.swift b/Sources/VaultAgentRegistry.swift index 3128ff0df098..00f7dfa7d52c 100644 --- a/Sources/VaultAgentRegistry.swift +++ b/Sources/VaultAgentRegistry.swift @@ -173,7 +173,7 @@ struct CmuxVaultAgentRegistration: Codable, Hashable, Sendable { "packages/session/dist/campfire", ] ), - sessionIdSource: .piSessionFile, + sessionIdSource: .argvOption("--session"), resumeCommand: "{{executable}} --session {{sessionId}}", cwd: .preserve, sessionDirectory: "~/.campfire/agent/sessions" diff --git a/cmux.xcodeproj/project.pbxproj b/cmux.xcodeproj/project.pbxproj index 3e00514cda81..b83cba11dcfe 100644 --- a/cmux.xcodeproj/project.pbxproj +++ b/cmux.xcodeproj/project.pbxproj @@ -230,6 +230,7 @@ C0DE62600000000000000001 /* BrowserWindowPortalRegistryNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE62600000000000000002 /* BrowserWindowPortalRegistryNotificationTests.swift */; }; C0DE35530000000000000101 /* BundledCLILinkageTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = C0DE35530000000000000102 /* BundledCLILinkageTests.swift */; }; 0A0F00560000000000000003 /* CampfireHookNotificationTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0F00560000000000000004 /* CampfireHookNotificationTests.swift */; }; + CAFE58130000000000000001 /* CampfireLaunchArgumentNormalizer.swift in Sources */ = {isa = PBXBuildFile; fileRef = CAFE58130000000000000002 /* CampfireLaunchArgumentNormalizer.swift */; }; 0A0F00550000000000000003 /* CampfireSupportTests.swift in Sources */ = {isa = PBXBuildFile; fileRef = 0A0F00550000000000000004 /* CampfireSupportTests.swift */; }; CA52B0010000000000000000 /* CanvasAction.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA52C0010000000000000000 /* CanvasAction.swift */; }; CA5CADF00000000000000002 /* CanvasDebugMenuButtons.swift in Sources */ = {isa = PBXBuildFile; fileRef = CA5CADF00000000000000001 /* CanvasDebugMenuButtons.swift */; }; @@ -1597,6 +1598,7 @@ C0DE62600000000000000002 /* BrowserWindowPortalRegistryNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BrowserWindowPortalRegistryNotificationTests.swift; sourceTree = ""; }; C0DE35530000000000000102 /* BundledCLILinkageTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = BundledCLILinkageTests.swift; sourceTree = ""; }; 0A0F00560000000000000004 /* CampfireHookNotificationTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CampfireHookNotificationTests.swift; sourceTree = ""; }; + CAFE58130000000000000002 /* CampfireLaunchArgumentNormalizer.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CampfireLaunchArgumentNormalizer.swift; sourceTree = ""; }; 0A0F00550000000000000004 /* CampfireSupportTests.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CampfireSupportTests.swift; sourceTree = ""; }; CA52C0010000000000000000 /* CanvasAction.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = "CanvasAction.swift"; sourceTree = ""; }; CA5CADF00000000000000001 /* CanvasDebugMenuButtons.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = CanvasDebugMenuButtons.swift; sourceTree = ""; }; @@ -3550,6 +3552,7 @@ FE001006 /* GitStatusProvider.swift */, FE003001 /* SessionIndexStore.swift */, B35750000000000000000007 /* SessionIndexRegisteredAgents.swift */, + CAFE58130000000000000002 /* CampfireLaunchArgumentNormalizer.swift */, B35750000000000000000005 /* VaultAgentProcessScanner.swift */, FE003004 /* SessionIndexStore+CodexSQL.swift */, FE003005 /* RovoDevIndex.swift */, @@ -4610,6 +4613,7 @@ A50100000000000000000022 /* BrowserWebAuthnUserDescriptor.swift in Sources */, C0DE58990000000000000003 /* BrowserWebKitKeyDownDispatch.swift in Sources */, A5001534 /* BrowserWindowPortal.swift in Sources */, + CAFE58130000000000000001 /* CampfireLaunchArgumentNormalizer.swift in Sources */, CA52B0010000000000000000 /* CanvasAction.swift in Sources */, CA5CADF00000000000000002 /* CanvasDebugMenuButtons.swift in Sources */, CA52B0050000000000000000 /* CanvasHostedPanelContentView.swift in Sources */, diff --git a/cmuxTests/CampfireHookNotificationTests.swift b/cmuxTests/CampfireHookNotificationTests.swift index 14c9b7a051f6..5cf06db69b2b 100644 --- a/cmuxTests/CampfireHookNotificationTests.swift +++ b/cmuxTests/CampfireHookNotificationTests.swift @@ -40,9 +40,9 @@ struct CampfireHookNotificationTests { let notificationCommands = Array(context.state.snapshot().dropFirst(notificationStart)) #expect( notificationCommands.contains { - $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Campfire|Permission|Alice asked for permission to run a shell command") + $0.hasPrefix("notify_target_async \(context.workspaceId) \(context.surfaceId) Campfire|Permission|Alice asked for permission to run a shell command|c=needs-permission;p=0") }, - "Campfire permission notification should be localized in Swift, saw \(notificationCommands)" + "Campfire permission notification should be localized and notification-gated in Swift, saw \(notificationCommands)" ) #expect( notificationCommands.contains { diff --git a/cmuxTests/CampfireSupportTests.swift b/cmuxTests/CampfireSupportTests.swift index ad7d4041c8db..3ef55ce2f89a 100644 --- a/cmuxTests/CampfireSupportTests.swift +++ b/cmuxTests/CampfireSupportTests.swift @@ -30,12 +30,12 @@ struct CampfireSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let explicit = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "explicit-campfire-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 1_000) ) - let latest = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "latest-campfire-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) @@ -51,13 +51,12 @@ struct CampfireSupportTests { ), selectorComment) #expect(detected.kind == RestorableAgentKind.custom("campfire"), selectorComment) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(explicit.path), selectorComment) - #expect(Self.normalizedPath(detected.sessionId) != Self.normalizedPath(latest.path), selectorComment) + #expect(detected.sessionId == "explicit-campfire-session", selectorComment) #expect(detected.workingDirectory == workspace.path, selectorComment) } } - @Test func directProcessDetectionUsesCampfireAgentDirectorySessionsWhenNoSessionDirectoryIsSet() throws { + @Test func directProcessDetectionIgnoresCampfireAgentDirectoryWithoutSessionSelector() throws { let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-agent-dir-") defer { try? FileManager.default.removeItem(at: root) } let workspace = root.appendingPathComponent("repo", isDirectory: true) @@ -69,30 +68,27 @@ struct CampfireSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "campfire-agent-dir-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) ) - let detected = try #require(Self.detectedCampfireSnapshot( + let detected = Self.detectedCampfireSnapshot( arguments: ["/Users/example/.local/bin/campfire"], environment: [ "PWD": workspace.path, "CAMPFIRE_CODING_AGENT_DIR": agentRoot.path, ] - )) + ) - #expect(detected.kind == RestorableAgentKind.custom("campfire")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) - #expect(detected.workingDirectory == workspace.path) + #expect(detected == nil) } - @Test func directProcessDetectionIgnoresPiSessionDirForCampfire() throws { + @Test func directProcessDetectionIgnoresSessionDirectoriesForExplicitCampfireId() throws { // Campfire embeds Pi, so a Campfire process can inherit // PI_CODING_AGENT_SESSION_DIR from the user's Pi configuration. Session - // detection must still resolve Campfire sessions against the - // Campfire-specific directory, not the Pi one. + // detection must not bind either directory by latest-file heuristic. let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-pi-precedence-") defer { try? FileManager.default.removeItem(at: root) } let workspace = root.appendingPathComponent("repo", isDirectory: true) @@ -118,7 +114,7 @@ struct CampfireSupportTests { ) let detected = try #require(Self.detectedCampfireSnapshot( - arguments: ["/Users/example/.local/bin/campfire"], + arguments: ["/Users/example/.local/bin/campfire", "--session", "campfire-session"], environment: [ "PWD": workspace.path, "PI_CODING_AGENT_SESSION_DIR": piSessionsRoot.path, @@ -127,7 +123,8 @@ struct CampfireSupportTests { )) #expect(detected.kind == RestorableAgentKind.custom("campfire")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(campfireSession.path)) + #expect(detected.sessionId == "campfire-session") + #expect(detected.sessionId != campfireSession.path) #expect(Self.normalizedPath(detected.sessionId) != Self.normalizedPath(piSession.path)) #expect(detected.workingDirectory == workspace.path) } @@ -142,7 +139,7 @@ struct CampfireSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "campfire-dev-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) @@ -154,6 +151,8 @@ struct CampfireSupportTests { arguments: [ "/opt/homebrew/bin/bun", "/Users/example/campfire/packages/session/bin/campfire.ts", + "--session", + "campfire-dev-session", ], environment: [ "PWD": workspace.path, @@ -162,7 +161,7 @@ struct CampfireSupportTests { )) #expect(detected.kind == RestorableAgentKind.custom("campfire")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.sessionId == "campfire-dev-session") #expect(detected.workingDirectory == workspace.path) #expect(detected.launchCommand?.executablePath == "campfire") #expect(detected.resumeCommand?.contains("'campfire' '--session'") == true) @@ -179,7 +178,7 @@ struct CampfireSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "campfire-bunfs-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) @@ -191,6 +190,8 @@ struct CampfireSupportTests { arguments: [ "/Users/example/.local/bin/campfire", "/$bunfs/root/campfire", + "--session", + "campfire-bunfs-session", "--relay", "wss://relay.example/ws", ], @@ -201,9 +202,11 @@ struct CampfireSupportTests { )) #expect(detected.kind == RestorableAgentKind.custom("campfire")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.sessionId == "campfire-bunfs-session") #expect(detected.launchCommand?.arguments == [ "/Users/example/.local/bin/campfire", + "--session", + "campfire-bunfs-session", "--relay", "wss://relay.example/ws", ]) @@ -221,7 +224,7 @@ struct CampfireSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "campfire-runtime-prefix-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) @@ -235,6 +238,8 @@ struct CampfireSupportTests { "--import", "./loader.mjs", "/Users/example/campfire/packages/session/bin/campfire.ts", + "--session", + "campfire-runtime-prefix-session", "--relay", "wss://relay.example/ws", ], @@ -244,9 +249,11 @@ struct CampfireSupportTests { ] )) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.sessionId == "campfire-runtime-prefix-session") #expect(detected.launchCommand?.arguments == [ "campfire", + "--session", + "campfire-runtime-prefix-session", "--relay", "wss://relay.example/ws", ]) @@ -265,7 +272,7 @@ struct CampfireSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "campfire-override-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) @@ -277,6 +284,8 @@ struct CampfireSupportTests { arguments: [ "/Users/example/.local/bin/campfire", "/$bunfs/root/campfire", + "--session", + "campfire-override-session", "--relay", "wss://relay.example/ws", ], @@ -287,11 +296,59 @@ struct CampfireSupportTests { registration: registration )) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.sessionId == "campfire-override-session") #expect(detected.resumeCommand?.contains("'custom-campfire' 'restore'") == true) #expect(detected.resumeCommand?.contains("'campfire' '--session'") == false) } + @Test func campfireRegistrationOverrideStillNormalizesRuntimeLaunchArguments() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-override-runtime-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + + _ = try Self.writeSessionFile( + id: "campfire-override-runtime-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + var registration = CmuxVaultAgentRegistration.builtInCampfire + registration.resumeCommand = "custom-campfire restore {{sessionId}}" + + let detected = try #require(Self.detectedCampfireSnapshot( + processName: "node", + processPath: "/opt/homebrew/bin/node", + arguments: [ + "/opt/homebrew/bin/node", + "/Users/example/campfire/packages/session/bin/campfire.ts", + "--session", + "campfire-override-runtime-session", + "--relay", + "wss://relay.example/ws", + ], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ], + registration: registration + )) + + #expect(detected.sessionId == "campfire-override-runtime-session") + #expect(detected.launchCommand?.arguments == [ + "campfire", + "--session", + "campfire-override-runtime-session", + "--relay", + "wss://relay.example/ws", + ]) + #expect(detected.resumeCommand?.contains("/opt/homebrew/bin/node") == false) + #expect(detected.resumeCommand?.contains("packages/session/bin/campfire.ts") == false) + } + @Test func directProcessDetectionClassifiesCampfireDistInvocation() throws { let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-dist-invocation-") defer { try? FileManager.default.removeItem(at: root) } @@ -302,7 +359,7 @@ struct CampfireSupportTests { try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) - let latest = try Self.writeSessionFile( + _ = try Self.writeSessionFile( id: "campfire-dist-session", in: projectSessions, modifiedAt: Date(timeIntervalSince1970: 2_000) @@ -314,6 +371,8 @@ struct CampfireSupportTests { arguments: [ "/opt/homebrew/bin/bun", "/Users/example/campfire/packages/session/dist/campfire", + "--session", + "campfire-dist-session", ], environment: [ "PWD": workspace.path, @@ -322,7 +381,7 @@ struct CampfireSupportTests { )) #expect(detected.kind == RestorableAgentKind.custom("campfire")) - #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) + #expect(detected.sessionId == "campfire-dist-session") #expect(detected.workingDirectory == workspace.path) } @@ -481,6 +540,7 @@ struct CampfireSupportTests { let registration = CmuxVaultAgentRegistration.builtInCampfire #expect(registration.id == "campfire") #expect(registration.resumeCommand == "{{executable}} --session {{sessionId}}") + #expect(registration.sessionIdSource == .argvOption("--session")) #expect(registration.sessionDirectory == "~/.campfire/agent/sessions") } @@ -505,6 +565,70 @@ struct CampfireSupportTests { #expect(detected == nil) } + @Test func alternateOnlyDetectRuleUsesDefaultExecutableForRestore() throws { + var registration = CmuxVaultAgentRegistration.builtInCampfire + registration.detect = CmuxVaultAgentDetectRule( + alternateArgvContainsAny: ["packages/session/bin/campfire.ts"] + ) + + let detected = try #require(Self.detectedCampfireSnapshot( + processName: "node", + processPath: "/opt/homebrew/bin/node", + arguments: [ + "/opt/homebrew/bin/node", + "/Users/example/campfire/packages/session/bin/campfire.ts", + "--session", + "campfire-alternate-only-session", + "--relay", + "wss://relay.example/ws", + ], + environment: [ + "PWD": "/tmp/repo", + ], + registration: registration + )) + + #expect(detected.sessionId == "campfire-alternate-only-session") + #expect(detected.launchCommand?.executablePath == "campfire") + #expect(detected.launchCommand?.arguments == [ + "campfire", + "--session", + "campfire-alternate-only-session", + "--relay", + "wss://relay.example/ws", + ]) + #expect(detected.resumeCommand?.contains("'campfire' '--session'") == true) + #expect(detected.resumeCommand?.contains("/opt/homebrew/bin/node") == false) + } + + @Test func processDetectedCampfireDoesNotInferLatestSessionFile() throws { + let root = try Self.makeTemporaryDirectory(prefix: "cmux-campfire-no-latest-fallback-") + defer { try? FileManager.default.removeItem(at: root) } + let workspace = root.appendingPathComponent("repo", isDirectory: true) + let sessionsRoot = root.appendingPathComponent("sessions", isDirectory: true) + let projectDirectory = try #require(PiSessionLocator.projectDirectoryName(for: workspace.path)) + let projectSessions = sessionsRoot.appendingPathComponent(projectDirectory, isDirectory: true) + try FileManager.default.createDirectory(at: projectSessions, withIntermediateDirectories: true) + try FileManager.default.createDirectory(at: workspace, withIntermediateDirectories: true) + _ = try Self.writeSessionFile( + id: "unrelated-newer-campfire-session", + in: projectSessions, + modifiedAt: Date(timeIntervalSince1970: 2_000) + ) + + let detected = Self.detectedCampfireSnapshot( + processName: "campfire", + processPath: "/Users/example/.local/bin/campfire", + arguments: ["/Users/example/.local/bin/campfire"], + environment: [ + "PWD": workspace.path, + "CAMPFIRE_CODING_AGENT_SESSION_DIR": sessionsRoot.path, + ] + ) + + #expect(detected == nil) + } + private static func detectedCampfireSnapshot( processName: String = "campfire", processPath: String? = "/Users/example/.local/bin/campfire", diff --git a/cmuxTests/OmpSupportTests.swift b/cmuxTests/OmpSupportTests.swift index 292ff469a1c5..c1da48e4188d 100644 --- a/cmuxTests/OmpSupportTests.swift +++ b/cmuxTests/OmpSupportTests.swift @@ -244,6 +244,12 @@ struct OmpSupportTests { #expect(detected.kind == RestorableAgentKind.custom("omp")) #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) #expect(detected.workingDirectory == workspace.path) + #expect(detected.launchCommand?.executablePath == "omp") + #expect(detected.launchCommand?.arguments == [ + "omp", + "--model", + "anthropic/claude-sonnet-4-5", + ]) } @Test func hostedOmpIgnoresRuntimePreloadFlagsBeforeAgentScript() throws { @@ -281,6 +287,8 @@ struct OmpSupportTests { #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(latest.path)) #expect(detected.sessionId != "/tmp/preload-session-module.js") #expect(detected.workingDirectory == workspace.path) + #expect(detected.launchCommand?.executablePath == "omp") + #expect(detected.launchCommand?.arguments == ["omp"]) } @Test func hostedOmpParsesSessionSelectorsAfterAgentScript() throws { @@ -325,6 +333,12 @@ struct OmpSupportTests { #expect(Self.normalizedPath(detected.sessionId) == Self.normalizedPath(explicit.path)) #expect(Self.normalizedPath(detected.sessionId) != Self.normalizedPath(latest.path)) #expect(detected.workingDirectory == workspace.path) + #expect(detected.launchCommand?.executablePath == "omp") + #expect(detected.launchCommand?.arguments == [ + "omp", + "--session", + "omp-hosted-explicit-session", + ]) } @Test func taskManagerClassifiesOmpBeforeLegacyPiPackageNeedles() throws { diff --git a/docs/vault.md b/docs/vault.md index 9e30e6b5f038..a6425b2112ee 100644 --- a/docs/vault.md +++ b/docs/vault.md @@ -41,7 +41,7 @@ Pi Coding Agent, OMP, and Campfire are registered by default: "detect": { "processName": "campfire" }, - "sessionIdSource": { "type": "piSessionFile" }, + "sessionIdSource": { "type": "argvOption", "argvOption": "--session" }, "resumeCommand": "{{executable}} --session {{sessionId}}", "cwd": "preserve", "sessionDirectory": "~/.campfire/agent/sessions" diff --git a/tests/test_campfire_extension_install.py b/tests/test_campfire_extension_install.py index e4384f98f643..f1ca6b97d170 100644 --- a/tests/test_campfire_extension_install.py +++ b/tests/test_campfire_extension_install.py @@ -388,6 +388,12 @@ def main() -> int: check_env["FAKE_CMUX_ENV_LOG"] = str(fake_env_log) check_env["FAKE_CMUX_ORDER_LOG"] = str(fake_order_log) check_env["CAMPFIRE_SESSION_ROLE"] = "host" + check_env["CMUX_AGENT_LAUNCH_KIND"] = "claude" + check_env["CMUX_AGENT_LAUNCH_EXECUTABLE"] = "/usr/local/bin/claude" + check_env["CMUX_AGENT_LAUNCH_ARGV_B64"] = base64.b64encode( + b"/usr/local/bin/claude\0--resume\0stale-parent-session\0" + ).decode("ascii") + check_env["CMUX_AGENT_LAUNCH_CWD"] = "/tmp/stale-parent-project" check_source = """ const extensionPath = process.env.CMUX_TEST_CAMPFIRE_EXTENSION_PATH; const mod = await import(extensionPath); From 61fa87e2c68810bcdb83be0c49c74743c452c99e Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:50:40 -0700 Subject: [PATCH 20/24] Restore ghostty and bonsplit submodule pointers to main --- ghostty | 2 +- vendor/bonsplit | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/ghostty b/ghostty index 541e5e89db04..a78fe53efaae 160000 --- a/ghostty +++ b/ghostty @@ -1 +1 @@ -Subproject commit 541e5e89db0448d5cd85a7b348d8f6a64618c900 +Subproject commit a78fe53efaaea56b80d47569d85e0d7b76512aa7 diff --git a/vendor/bonsplit b/vendor/bonsplit index 01751efce3f0..fb5307f95997 160000 --- a/vendor/bonsplit +++ b/vendor/bonsplit @@ -1 +1 @@ -Subproject commit 01751efce3f01becea98a13efe48d4a011223b7d +Subproject commit fb5307f9599753bdf10bf15a407a16e5f7519ad7 From f8dcae220682f54b6ba8e185239c7f79456e9bde Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 7 Jul 2026 17:51:13 -0700 Subject: [PATCH 21/24] Revert unrelated renderer-realizer shim; scoped to main's implementation --- .../Surface/TerminalSurface+Renderer.swift | 5 ++-- .../GhosttyRuntimeTestStubs.c | 6 +--- .../include/GhosttyRuntimeTestStubs.h | 2 +- .../GhosttySurfaceRendererRealizer.swift | 28 ------------------- .../GhosttyRuntimeTestStubs.c | 6 ---- .../include/GhosttyRuntimeTestStubs.h | 1 - 6 files changed, 4 insertions(+), 44 deletions(-) delete mode 100644 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRendererRealizer.swift diff --git a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift index 6fead50aba32..d479f70b505e 100644 --- a/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift +++ b/Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface+Renderer.swift @@ -1,5 +1,4 @@ public import AppKit -public import CmuxTerminalCore public import Foundation public import GhosttyKit @@ -110,7 +109,7 @@ extension TerminalSurface { // mailbox is full). If it dropped, keep `rendererRealized = true` so the // controller retries on its next pass rather than desyncing from // Ghostty's still-realized swap chain. - if GhosttySurfaceRendererRealizer().setRealized(surface, false) { + if ghostty_surface_set_renderer_realized(surface, false) { rendererRealized = false return true } @@ -141,7 +140,7 @@ extension TerminalSurface { // the push drops, `rendererRealized` stays false, and the controller's // pass re-realizes any visible-but-unrealized surface as the backstop. We // never block the main actor waiting on the renderer thread. - if GhosttySurfaceRendererRealizer().setRealized(surface, true) { + if ghostty_surface_set_renderer_realized(surface, true) { rendererRealized = true } else { // Enqueue dropped (full mailbox, i.e. the renderer thread is not diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index 485b05e7acc8..9fbd9e34a6a9 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -58,11 +58,7 @@ void ghostty_surface_set_content_scale(void) {} void ghostty_surface_set_display_id(void) {} void ghostty_surface_set_focus(void) {} void ghostty_surface_set_occlusion(void) {} -bool ghostty_surface_set_renderer_realized(void *surface, bool realized) { - (void)surface; - (void)realized; - return false; -} +void ghostty_surface_set_renderer_realized(void) {} void ghostty_surface_set_size(void) {} void ghostty_surface_size(void) {} void ghostty_surface_text(void) {} diff --git a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 56c1e1f9452f..c6dc5dfa2266 100644 --- a/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminal/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -42,7 +42,7 @@ void ghostty_surface_set_content_scale(void); void ghostty_surface_set_display_id(void); void ghostty_surface_set_focus(void); void ghostty_surface_set_occlusion(void); -bool ghostty_surface_set_renderer_realized(void *surface, bool realized); +void ghostty_surface_set_renderer_realized(void); void ghostty_surface_set_size(void); void ghostty_surface_size(void); void ghostty_surface_text(void); diff --git a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRendererRealizer.swift b/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRendererRealizer.swift deleted file mode 100644 index 6609f77c0ff6..000000000000 --- a/Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Interop/GhosttySurfaceRendererRealizer.swift +++ /dev/null @@ -1,28 +0,0 @@ -public import GhosttyKit - -// lint:allow free-function — @_silgen_name FFI declaration: the symbol is -// exported by libghostty without a public header entry, so it must be declared -// as a bare function signature for the linker to bind. -@_silgen_name("ghostty_surface_set_renderer_realized") -private func cmux_ghostty_surface_set_renderer_realized(_ surface: ghostty_surface_t, _ realized: Bool) -> Bool - -/// Sets renderer-realization state for a Ghostty runtime surface. -public struct GhosttySurfaceRendererRealizer: Sendable { - /// Creates a renderer-realization service. - public init() {} - - /// Sets whether a runtime surface owns realized GPU renderer resources. - /// - /// Mirrors `ghostty_surface_set_renderer_realized` from the cmux libghostty - /// fork. The call asks Ghostty to enqueue a renderer realize/release - /// transition without blocking the caller. - /// - /// - Parameters: - /// - surface: The live runtime surface to update. - /// - realized: Whether the surface should hold renderer resources. - /// - Returns: Whether Ghostty accepted the renderer transition message. - @discardableResult - public func setRealized(_ surface: ghostty_surface_t, _ realized: Bool) -> Bool { - cmux_ghostty_surface_set_renderer_realized(surface, realized) - } -} diff --git a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c index e031ddc22473..3e67ed7a11c5 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c +++ b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/GhosttyRuntimeTestStubs.c @@ -5,12 +5,6 @@ bool ghostty_surface_clear_selection(void *surface) { return false; } -bool ghostty_surface_set_renderer_realized(void *surface, bool realized) { - (void)surface; - (void)realized; - return false; -} - void *ghostty_surface_quicklook_font(void *surface) { (void)surface; return 0; diff --git a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h index 759fd11d1789..b9c210917d34 100644 --- a/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h +++ b/Packages/macOS/CmuxTerminalCore/Tests/GhosttyRuntimeTestStubs/include/GhosttyRuntimeTestStubs.h @@ -8,7 +8,6 @@ // (its binary is not lib-prefixed), so the test runner provides this stub to // satisfy the link; no test calls it. bool ghostty_surface_clear_selection(void *surface); -bool ghostty_surface_set_renderer_realized(void *surface, bool realized); // Test-only stand-in for the GhosttyKit symbol referenced by // GhosttySurfaceRuntimeProbe.currentSurfaceFontSizePoints; no test calls it. From 322610bbf18f40f4f04dc43934552f2d833bb434 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:09:59 -0700 Subject: [PATCH 22/24] Drop PI_CODING_AGENT_SESSION_DIR from campfire resume environment The scanner already gates PI_CODING_AGENT_SESSION_DIR out when resolving Campfire session roots, but restore still replayed it into resumed Campfire processes. Since Campfire embeds Pi, a user's custom Pi session root could then receive resumed Campfire session state while cmux reads the Campfire root. Drop it for campfire resumes alongside PI_PACKAGE_DIR; pi/omp behavior is unchanged. --- .../AgentLaunchEnvironmentPolicy.swift | 16 +++++++++++----- .../AgentLaunchEnvironmentPolicyTests.swift | 15 ++++++++++++++- 2 files changed, 25 insertions(+), 6 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift index f524ac8fd554..1615a6ee74ba 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchEnvironmentPolicy.swift @@ -40,12 +40,18 @@ public struct AgentLaunchEnvironmentPolicy: Sendable { "HERMES_CODEX_BASE_URL", ] - /// Keys campfire computes itself on every boot. Replaying a captured - /// PI_PACKAGE_DIR would pin a resumed campfire to the previous binary's - /// extracted asset cache (version+fingerprint keyed) after an upgrade, so - /// it is dropped for campfire resumes specifically; pi/omp keep it (Nix - /// installs rely on it). + /// Keys campfire manages itself and must not inherit from a captured Pi + /// environment. Replaying a captured PI_PACKAGE_DIR would pin a resumed + /// campfire to the previous binary's extracted asset cache + /// (version+fingerprint keyed) after an upgrade, and replaying + /// PI_CODING_AGENT_SESSION_DIR would let the embedded Pi runtime resolve + /// session state under the user's Pi session root instead of the Campfire + /// root that cmux's scanner uses (`CAMPFIRE_CODING_AGENT_SESSION_DIR` / + /// `CAMPFIRE_CODING_AGENT_DIR`). Both are dropped for campfire resumes + /// specifically; pi/omp keep them (Nix installs and custom Pi session + /// roots rely on them). private let campfireManagedEnvironmentKeys: Set = [ + "PI_CODING_AGENT_SESSION_DIR", "PI_PACKAGE_DIR", ] diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift index b521d71a28f5..4087d6f5e52f 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchEnvironmentPolicyTests.swift @@ -20,7 +20,7 @@ struct AgentLaunchEnvironmentPolicyTests { ]) } - @Test("Preserves Campfire config roots and drops its self-managed package dir") + @Test("Preserves Campfire config roots and drops Pi-managed env") func preservesCampfireConfigRootsAndDropsManagedPackageDir() { let selected = AgentLaunchEnvironmentPolicy().selectedEnvironment( from: [ @@ -32,6 +32,10 @@ struct AgentLaunchEnvironmentPolicyTests { // boot; replaying a captured path would pin a resumed session // to the previous binary's cache after an upgrade. "PI_PACKAGE_DIR": "/tmp/stale-pi-cache", + // A user's Pi session root must not leak into a Campfire + // resume: the embedded Pi runtime would resolve session state + // there while cmux's scanner reads the Campfire root. + "PI_CODING_AGENT_SESSION_DIR": "/tmp/pi-sessions", ], kind: "campfire" ) @@ -43,6 +47,15 @@ struct AgentLaunchEnvironmentPolicyTests { ]) } + @Test("Keeps PI_CODING_AGENT_SESSION_DIR for pi resumes") + func keepsPiSessionDirForPi() { + let selected = AgentLaunchEnvironmentPolicy().selectedEnvironment( + from: ["PI_CODING_AGENT_SESSION_DIR": "/tmp/pi-sessions"], + kind: "pi" + ) + #expect(selected["PI_CODING_AGENT_SESSION_DIR"] == "/tmp/pi-sessions") + } + @Test("Keeps PI_PACKAGE_DIR for pi and omp resumes") func keepsPiPackageDirForPiKinds() { let selectedPi = AgentLaunchEnvironmentPolicy().selectedEnvironment( From 6fea0b7210a7b6f3fb51afdb3c68e4629519fbab Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:14:54 -0700 Subject: [PATCH 23/24] Recognize campfire script entrypoints under deno/tsx/ts-node hosts AgentLaunchCaptureTrust only treated node and bun as hosts that can run a Campfire entrypoint, so PID-based argv fallback dropped campfire hook captures launched via deno, tsx, or ts-node even though the rest of the Campfire support (normalizer, scanner, task manager) recognizes those hosts. Gate the campfire needle check on the same host set; the claude detection stays limited to node/bun. --- .../AgentLaunchCaptureTrust.swift | 22 ++++++++++++------- .../AgentLaunchCaptureTrustTests.swift | 20 +++++++++++++++++ 2 files changed, 34 insertions(+), 8 deletions(-) diff --git a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift index 5f64d7da51de..2ab29ba05461 100644 --- a/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift +++ b/Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchCaptureTrust.swift @@ -119,14 +119,20 @@ public enum AgentLaunchCaptureTrust { if let executableBase { descriptors.insert(executableBase) } - if nameBase == "node" || nameBase == "bun" || executableBase == "node" || executableBase == "bun" { - if arguments.dropFirst().contains(where: { argument in - let lowered = argument.lowercased() - return processBasename(argument) == "claude" - || lowered.contains("/.claude/") - || lowered.contains("/claude/versions/") - }) { - descriptors.insert("claude") + // Hosts that can run a Campfire script entrypoint; mirrors + // CampfireLaunchArgumentNormalizer's supported runtime set. + let scriptHostBases: Set = ["node", "bun", "deno", "tsx", "ts-node"] + let hostBases = Set([nameBase, executableBase].compactMap { $0 }) + if !hostBases.isDisjoint(with: scriptHostBases) { + if nameBase == "node" || nameBase == "bun" || executableBase == "node" || executableBase == "bun" { + if arguments.dropFirst().contains(where: { argument in + let lowered = argument.lowercased() + return processBasename(argument) == "claude" + || lowered.contains("/.claude/") + || lowered.contains("/claude/versions/") + }) { + descriptors.insert("claude") + } } if arguments.dropFirst().contains(where: { argument in let lowered = argument.replacingOccurrences(of: "\\", with: "/").lowercased() diff --git a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift index dd533274b464..063de8c8b97f 100644 --- a/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift +++ b/Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchCaptureTrustTests.swift @@ -104,6 +104,26 @@ struct AgentLaunchCaptureTrustTests { arguments: ["node", "/Users/alice/campfire/packages/session/dist/campfire"] ) ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "tsx", + arguments: ["tsx", "packages/session/bin/campfire.ts"], + kind: "campfire" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKind( + processName: "deno", + arguments: ["deno", "run", "-A", "/Users/alice/campfire/packages/session/bin/campfire.ts"], + kind: "campfire" + ) + ) + #expect( + AgentLaunchCaptureTrust.nativeProcessDescribesKnownAgent( + processName: "ts-node", + arguments: ["ts-node", "/Users/alice/campfire/packages/session/bin/campfire.ts"] + ) + ) #expect( AgentLaunchCaptureTrust.nativeProcessDescribesKind( processName: "acme-agent", From cd47acf20005045ec3429be493be58ce2d759b00 Mon Sep 17 00:00:00 2001 From: lawrencecchen <54008264+lawrencecchen@users.noreply.github.com> Date: Tue, 7 Jul 2026 18:31:00 -0700 Subject: [PATCH 24/24] Refresh Swift file length budget for campfire growth workflow-guard-tests failed on the file-length budget: the Campfire feature grows CLI/cmux.swift, VaultAgentProcessScanner, TaskManagerTypes, RestorableAgentSession, the CMUXAgentLaunch sanitizer files, and adds cmuxTests/CampfireSupportTests.swift past the tracked thresholds. Accept the feature growth in the budget; no unrelated entries changed. --- .github/swift-file-length-budget.tsv | 15 ++++++++------- 1 file changed, 8 insertions(+), 7 deletions(-) diff --git a/.github/swift-file-length-budget.tsv b/.github/swift-file-length-budget.tsv index 8142a23c7f2d..b254c3327d0e 100644 --- a/.github/swift-file-length-budget.tsv +++ b/.github/swift-file-length-budget.tsv @@ -1,7 +1,7 @@ # cmux-owned Swift file length budget. # Format: max_linesrelative path # Reduce counts as files shrink. CI fails if tracked files exceed this budget. -35600 CLI/cmux.swift +35690 CLI/cmux.swift 18229 Sources/AppDelegate.swift 16443 Sources/ContentView.swift 15163 Sources/TerminalController.swift @@ -42,8 +42,8 @@ 2404 Sources/Mobile/MobileHostService.swift 2328 cmuxTests/CJKIMEInputTests.swift 2242 Sources/TerminalNotificationStore.swift +2234 Sources/RestorableAgentSession.swift 2229 Sources/TerminalWindowPortal.swift -2225 Sources/RestorableAgentSession.swift 2133 cmuxTests/ShortcutAndCommandPaletteTests.swift 2126 cmuxTests/CmuxConfigTests.swift 2079 Sources/SessionPersistence.swift @@ -71,6 +71,7 @@ 1363 Sources/CMUXInstalledExtensionSidebarHostView.swift 1360 Sources/Feed/FeedButtonStyleDebugWindowController.swift 1317 Sources/FileExplorerStore.swift +1298 Sources/VaultAgentProcessScanner.swift 1295 cmuxTests/MobileHostAuthorizationTests.swift 1291 Packages/macOS/CmuxTerminalCore/Sources/CmuxTerminalCore/Config/GhosttyConfig.swift 1290 cmuxTests/TextBoxSubmitActionTests.swift @@ -82,7 +83,6 @@ 1204 cmuxTests/FileExplorerStoreTests.swift 1197 cmuxTests/CodexAppServerSessionTests.swift 1197 cmuxTests/VMDefaultCloudCommandTests.swift -1166 Sources/VaultAgentProcessScanner.swift 1147 cmuxTests/PiVaultAgentPersistenceTests.swift 1121 cmuxTests/AgentHibernationTests.swift 1110 Sources/AppDelegate+CmuxSSHURL.swift @@ -118,8 +118,8 @@ 847 cmuxTests/AgentSessionAutoResumeSettingsTests.swift 845 cmuxTests/SSHStartupSignalLifecycleTests.swift 841 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/TerminalOutputDeliveryQueueTests.swift +838 Sources/TaskManagerTypes.swift 834 Sources/MainWindowFocusController.swift -830 Sources/TaskManagerTypes.swift 828 Sources/PricingPlansScreen.swift 825 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/TerminalComposerView.swift 822 Sources/WorkspaceContentView.swift @@ -139,6 +139,7 @@ 752 cmuxUITests/CloseWorkspaceCmdDUITests.swift 749 cmuxTests/UpdatePillReleaseVisibilityTests.swift 738 Packages/macOS/CMUXProjectModel/Sources/CMUXProjectModel/XcodeProjectAdapter.swift +730 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 717 Packages/Shared/CmuxAuthRuntime/Sources/CmuxAuthRuntime/Coordinator/AuthCoordinator.swift 716 Sources/TaskManagerSnapshot.swift 714 Sources/AppleScriptSupport.swift @@ -146,6 +147,7 @@ 709 Packages/macOS/CmuxFoundation/Sources/CmuxFoundation/SidebarDrop/SidebarWorkspaceReorderDropResolver.swift 706 CLI/CMUXCLI+Config.swift 700 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceDetailView.swift +700 cmuxTests/CampfireSupportTests.swift 699 cmuxTests/TerminalNotificationClearAllTests.swift 698 cmuxTests/RestorableAgentHookProviderResumeTests.swift 696 cmuxTests/KeyboardShortcutContextTests.swift @@ -172,7 +174,6 @@ 650 Sources/Panels/MarkdownRemoteImageLoader.swift 648 cmuxTests/TerminalNotificationQueueTests.swift 646 Packages/iOS/CmuxMobileShellUI/Sources/CmuxMobileShellUI/WorkspaceListView.swift -644 Packages/macOS/CMUXAgentLaunch/Tests/CMUXAgentLaunchTests/AgentLaunchSanitizerTests.swift 642 cmuxTests/RemoteTmuxControlParserTests.swift 641 cmuxTests/CommandPaletteNucleoFFITests.swift 637 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTestSupport.swift @@ -183,14 +184,14 @@ 620 cmuxTests/FinderFileDropRegressionTests.swift 618 Sources/SettingsNavigation.swift 617 Packages/iOS/CmuxMobileShell/Tests/CmuxMobileShellTests/MobileShellRenderGridLivenessTests.swift +617 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift 608 cmuxUITests/FeedSidebarUITests.swift 607 Packages/macOS/CmuxTerminal/Sources/CmuxTerminal/Surface/TerminalSurface.swift 607 Sources/SessionIndexModels.swift 607 Sources/SleepyFaceView.swift 604 Packages/macOS/CmuxCommandPalette/Tests/CmuxCommandPaletteTests/CommandPaletteNucleoFFITests.swift 602 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceReorderCoordinator.swift -601 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizerPrimaryPolicies.swift -598 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift +600 Packages/macOS/CMUXAgentLaunch/Sources/CMUXAgentLaunch/AgentLaunchSanitizer.swift 598 Packages/macOS/CmuxWorkspaces/Sources/CmuxWorkspaces/Coordinators/WorkspaceGroupCoordinator.swift 596 cmuxTests/CmuxEventBusTests.swift 594 cmuxTests/PortalTabDragRoutingTests.swift