diff --git a/README.md b/README.md index 9a185ec05..23205c92a 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,13 @@ are separate resources. Claude fresh-agent panes use the isolated `crates/freshell-claude-sidecar` package, which wraps the Claude SDK over newline-delimited JSON on stdin/stdout. The sidecar is not a network service. +Freshclaude and Kilroy panes give their agent the same Freshell MCP server, so +the agent can list and drive tabs and panes; its default target is the pane it +runs in. The **Agent MCP server** switch (Settings → Advanced) controls this: +turning it off removes the server from Freshclaude and Kilroy agents started or +resumed after the change, with no restart. Terminal panes do not follow the +switch yet. + ### Rust server scope The Rust server supports the browser UI, terminal and session workflows, the diff --git a/crates/freshell-claude-sidecar/hosted-mcp.mjs b/crates/freshell-claude-sidecar/hosted-mcp.mjs deleted file mode 100644 index c06fc7abf..000000000 --- a/crates/freshell-claude-sidecar/hosted-mcp.mjs +++ /dev/null @@ -1,8 +0,0 @@ -import { usesHostedClaudeRuntime } from './hosted.mjs' - -// The managed launch context selects one fixed recipe. Scoped authentication -// stays in the inherited child environment and never enters SDK argv/IPC. -export function hostedMcpServers(selected, env = process.env) { - if (selected !== true || !usesHostedClaudeRuntime(env)) return undefined - return { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } -} diff --git a/crates/freshell-claude-sidecar/hosted.mjs b/crates/freshell-claude-sidecar/hosted.mjs index fc7d55852..6c8c15c9b 100644 --- a/crates/freshell-claude-sidecar/hosted.mjs +++ b/crates/freshell-claude-sidecar/hosted.mjs @@ -2,12 +2,16 @@ // which hosted rules apply. The ONE reader of FRESHELL_HOSTED_FRESH_AGENT: // the session host sets it to the provider it hosts, which for this runtime // is `claude` (freshclaude) or `kilroy` (crates/freshell-supervisor/src/ -// backend.rs). Two rules read it, and they differ on purpose: +// backend.rs). Two rules read it: // - every hosted sidecar logs CLI output by size only (isHostedSidecar); -// - the managed spawn check and the fixed hosted MCP recipe apply to hosted -// claude alone (usesHostedClaudeRuntime). Extending them to hosted kilroy -// is an explicit item of the api-hosted-ops workstream -// (docs/plans/2026-10-22-freshclaude-api-hosted-ops.md §2.4). +// - the managed spawn check and the fixed hosted MCP recipe apply to every +// hosted Claude runtime, freshclaude and kilroy alike +// (usesHostedClaudeRuntime): both run the same Claude CLI inside the same +// managed enclosure, and kilroy consumes the Claude launch recipes +// (FreshProvider::native_provider in freshell-runtime-protocol). + +/** The providers whose hosted sidecar runs the Claude CLI. */ +const HOSTED_CLAUDE_RUNTIMES = new Set(['claude', 'kilroy']) /** The provider the session host hosts, or undefined outside a session host. */ export function hostedProvider(env = process.env) { @@ -19,7 +23,7 @@ export function isHostedSidecar(env = process.env) { return hostedProvider(env) !== undefined } -/** Hosted claude: the SDK spawn is checked against the managed exec profile and the fixed MCP recipe is offered. */ +/** Hosted claude or kilroy: the SDK spawn is checked against the managed exec profile and the fixed MCP recipe is offered. */ export function usesHostedClaudeRuntime(env = process.env) { - return hostedProvider(env) === 'claude' + return HOSTED_CLAUDE_RUNTIMES.has(hostedProvider(env)) } diff --git a/crates/freshell-claude-sidecar/index.mjs b/crates/freshell-claude-sidecar/index.mjs index ec790006c..92cac7cfe 100644 --- a/crates/freshell-claude-sidecar/index.mjs +++ b/crates/freshell-claude-sidecar/index.mjs @@ -10,14 +10,17 @@ // { type:'create', protocol:2, requestId, sessionType?, sessionId?, // boot:{kind:'fresh'} | {kind:'resume', resume} // | {kind:'fork', resume, resumeSessionAt?, dropsTurn?}, -// cwd?, model?, permissionMode?, effort?, plugins?, hostedMcp? } +// cwd?, model?, permissionMode?, effort?, plugins?, hostedMcp?, +// freshellMcp?:{command,args}, freshellContext?:{tabId?,paneId?} } // — `boot` picks the conversation the CLI boots (bootOptions): a // fork keeps the parent through-AND-including resumeSessionAt // over the raw parentUuid chain, writes a NEW conversation and // never rewrites the parent's JSONL; dropsTurn arms the SDK's // fork-time discard guard. `sessionId` is the id Rust minted for -// a fresh or forked boot, when it minted one. One sidecar hosts -// one session: a second create is refused. +// a fresh or forked boot, when it minted one. `hostedMcp` and +// `freshellMcp` pick the Freshell MCP recipe (mcp-servers.mjs); +// `freshellContext` names the pane the CLI's environment carries. +// One sidecar hosts one session: a second create is refused. // { type:'configure', sessionId, requestId, settings } // { type:'send', sessionId, text, images? } // { type:'interrupt', sessionId } @@ -111,12 +114,14 @@ // nothing. If the PROCESS itself dies mid-turn, stdout simply ends with no // `sdk.stream.ended` — the one end the Rust side rings for itself. // -// Scope discipline (ADR "keep it minimal — only what the claude T2 invariant set -// needs"): the freshell MCP-server injection (createClaudeSdkMcpServers) is -// DELIBERATELY OMITTED — a pinned pure-text T2 turn never calls a tool, MCP tools -// are not in the T2 baseline, and injecting the MCP server would spawn an extra -// node grandchild bound to the REST API. The interactive permission/question -// RESPONSE channel IS in scope (permission-channel.mjs, a faithful port of +// The Freshell MCP server (G19): the create line names the recipe, the hosted +// one or the ordinary one the Rust server resolved while +// settings.codingCli.mcpServer is on (mcp-servers.mjs; never both, never an env +// key, since the SDK puts the MCP config on the CLI's argv). The endpoint and +// token reach the MCP child through the inherited environment the Rust server +// set on this process, and the pane context (`freshellContext`) is merged into +// the SDK env here. The interactive permission/question RESPONSE channel IS in +// scope (permission-channel.mjs, a faithful port of // server/sdk-bridge.ts): canUseTool parks a pending request, surfaces // sdk.permission.request / sdk.question.request, and waits for the Rust side's // permission.respond / question.respond (unknown respond ids are stderr-log-only @@ -131,7 +136,7 @@ import { createTurnCompleteGate } from './turn-complete-gate.mjs' import { checkpointAttemptOf, rememberCheckpointAttempt, takeCheckpointIdentity } from './checkpoint-identity.mjs' import { nextMonotonic } from './monotonic-clock.mjs' import { managedSdkSpawn } from './exec-profile.mjs' -import { hostedMcpServers } from './hosted-mcp.mjs' +import { mcpServersFor, withFreshellContext } from './mcp-servers.mjs' import { cliStderrLogger, errorText, log } from './log.mjs' import { isHostedSidecar } from './hosted.mjs' import { STREAM_ENDED_EXIT_CODE, streamEnd } from './stream-end.mjs' @@ -558,7 +563,7 @@ function handleCreate(req) { plugins: Array.isArray(req.plugins) ? req.plugins.map(path => ({ type: 'local', path })) : undefined, - mcpServers: hostedMcpServers(req.hostedMcp), + mcpServers: mcpServersFor(req, process.env, { fields: { sessionId } }), pathToClaudeCodeExecutable: process.env.CLAUDE_CMD || undefined, includePartialMessages: true, // This is the SDK's native persisted-user witness. Without the replay @@ -566,7 +571,7 @@ function handleCreate(req) { // sidecar, so checkpoint identity must remain unbound. extraArgs: { 'replay-user-messages': null }, abortController: abort, - env: createClaudeSdkCleanEnv(process.env), + env: withFreshellContext(createClaudeSdkCleanEnv(process.env), req.freshellContext), settingSources: ['user', 'project', 'local'], spawnClaudeCodeProcess: managedSdkSpawn(sdkStderr), stderr: sdkStderr, diff --git a/crates/freshell-claude-sidecar/mcp-servers.mjs b/crates/freshell-claude-sidecar/mcp-servers.mjs new file mode 100644 index 000000000..1193e25bd --- /dev/null +++ b/crates/freshell-claude-sidecar/mcp-servers.mjs @@ -0,0 +1,74 @@ +// mcp-servers.mjs — the MCP servers a session's query() gets, and the pane +// context its CLI environment carries (api-hosted-ops plan §2.4, G19). +// +// A session gets at most one Freshell MCP recipe: +// - hosted: inside a hosted Claude runtime (hosted.mjs), the session host's +// fixed command, when the lane selected it (`create.hostedMcp`). A hosted +// sidecar never gets the ordinary recipe; +// - ordinary: outside a session host, the command the Rust server resolved +// for this host while `settings.codingCli.mcpServer` is on +// (`create.freshellMcp`, `{ command, args }`). +// +// A recipe never carries an `env` key. The SDK hands the MCP config to the CLI +// on its command line (`--mcp-config `), where any process can read it, +// so the endpoint and token stay in the inherited environment: the Rust +// server sets FRESHELL/FRESHELL_URL/FRESHELL_TOKEN on this process, and the +// CLI passes its environment on to the stdio MCP child. + +import { usesHostedClaudeRuntime } from './hosted.mjs' +import { log as sidecarLog } from './log.mjs' + +/** The MCP server name both recipes register (its tools are `mcp__freshell__*`). */ +export const FRESHELL_MCP_SERVER_NAME = 'freshell' + +const HOSTED_RECIPE = Object.freeze({ command: 'node', args: Object.freeze(['/opt/freshell-mcp/server.js']) }) + +/** The pane-scoped context names: a session's CLI carries its own pane's, never another's. */ +const PANE_CONTEXT_ENV = ['FRESHELL_TERMINAL_ID', 'FRESHELL_TAB_ID', 'FRESHELL_PANE_ID'] + +/** `{ command, args }` copied from a well-formed ordinary recipe, else undefined. */ +function ordinaryRecipe(recipe) { + if (!recipe || typeof recipe !== 'object') return undefined + const { command, args } = recipe + if (typeof command !== 'string' || command.length === 0) return undefined + if (!Array.isArray(args) || !args.every((arg) => typeof arg === 'string')) return undefined + return { command, args: [...args] } +} + +/** + * The `mcpServers` option for the session `req` creates: the hosted recipe, + * the ordinary recipe, or undefined (never both, never an `env` key). An + * ordinary recipe that is present but malformed is left out and logged as a + * warning (`mcp.freshell_recipe_rejected`, with `fields`): the server already + * logged the launch as resolved, so this record is the only sign the pane + * runs without the Freshell MCP. + */ +export function mcpServersFor(req, env = process.env, { log = sidecarLog, fields = {} } = {}) { + if (usesHostedClaudeRuntime(env)) { + if (req?.hostedMcp !== true) return undefined + return { [FRESHELL_MCP_SERVER_NAME]: { command: HOSTED_RECIPE.command, args: [...HOSTED_RECIPE.args] } } + } + const sent = req?.freshellMcp + if (sent === undefined || sent === null) return undefined + const recipe = ordinaryRecipe(sent) + if (!recipe) { + log('warn', 'mcp.freshell_recipe_rejected', { ...fields, recipeType: Array.isArray(sent) ? 'array' : typeof sent }) + return undefined + } + return { [FRESHELL_MCP_SERVER_NAME]: recipe } +} + +/** + * `env` with the pane the session runs for (`create.freshellContext`, + * `{ tabId?, paneId? }`): the pane-scoped names come from the context alone, + * so a value inherited from elsewhere never reaches the CLI. A request with + * no context (an older server) leaves `env` as it is. + */ +export function withFreshellContext(env, context) { + if (!context || typeof context !== 'object') return env + const out = { ...env } + for (const name of PANE_CONTEXT_ENV) delete out[name] + if (typeof context.tabId === 'string' && context.tabId) out.FRESHELL_TAB_ID = context.tabId + if (typeof context.paneId === 'string' && context.paneId) out.FRESHELL_PANE_ID = context.paneId + return out +} diff --git a/crates/freshell-freshagent/src/claude.rs b/crates/freshell-freshagent/src/claude.rs index 57a4fac54..fcfa1a59b 100644 --- a/crates/freshell-freshagent/src/claude.rs +++ b/crates/freshell-freshagent/src/claude.rs @@ -115,6 +115,11 @@ pub struct FreshClaudeState { /// Host-only selection of the fixed Freshell stdio MCP recipe. Its endpoint /// and grant remain in the child environment, never in create IPC. hosted_mcp: bool, + /// The ordinary Freshell MCP (G19): the endpoint every sidecar's + /// environment carries and the recipe provider asked at each launch, both + /// wired by `freshell-server` ([`Self::set_freshell_endpoint`], + /// [`Self::set_freshell_mcp_provider`]); unwired in the session host. + freshell_mcp: crate::claude_freshell_mcp::ClaudeFreshellMcpWiring, /// Test seam (`test-support` feature, C04 rigs): the complete environment of /// every claude sidecar this state spawns, replacing the inherited one (the /// ownership tag is still layered on). `None` in production: the sidecar @@ -732,6 +737,9 @@ struct ClaudeConfiguration { commands: Option>, needs_configure: bool, runtime_cwd: Option, + /// The pane this runtime was launched for (G19): an undo's replacement and + /// a fork's child launch for the same pane. + freshell_context: Option, } impl ClaudeSession { @@ -795,6 +803,7 @@ impl FreshClaudeState { pub fn new(broadcast_tx: Arc>) -> Self { Self { hosted_mcp: false, + freshell_mcp: Default::default(), sidecar_environment: None, broadcast_tx, checkpoint_event_sink: crate::CheckpointEventSink::default(), @@ -838,6 +847,26 @@ impl FreshClaudeState { self } + /// Wire where every sidecar's CLI reaches this server (G19): its + /// environment carries `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN`. + /// Set-once; `false` when an endpoint was already wired. + pub fn set_freshell_endpoint( + &self, + endpoint: crate::claude_freshell_mcp::FreshellEndpoint, + ) -> bool { + self.freshell_mcp.set_endpoint(endpoint) + } + + /// Wire the Freshell MCP recipe provider (G19), asked at every sidecar + /// launch of a lane that did not select the hosted recipe. Set-once; + /// `false` when a provider was already wired. + pub fn set_freshell_mcp_provider( + &self, + provider: crate::claude_freshell_mcp::ClaudeFreshellMcpProvider, + ) -> bool { + self.freshell_mcp.set_provider(provider) + } + /// Test seam (see [`Self::sidecar_environment`]): every sidecar this state /// spawns gets exactly `environment` (plus its ownership tag) instead of /// inheriting the server's environment. @@ -887,7 +916,12 @@ impl FreshClaudeState { /// Spawn one claude sidecar process (create, attach-resume and rollback all /// come through here) for a session of `session_type`. async fn spawn_sidecar_process(&self, session_type: &str) -> Result { - spawn_sidecar(self.sidecar_environment.as_deref(), session_type).await + spawn_sidecar( + self.sidecar_environment.as_deref(), + self.freshell_mcp.endpoint(), + session_type, + ) + .await } pub fn set_checkpoint_event_persister( @@ -1393,11 +1427,17 @@ impl FreshClaudeState { /// vs kilroy, so a kilroy session resumes AS kilroy (the created/snapshot /// frames keep the flavor; never map by provider alone). Registers under /// the durable id itself and returns the constructed owner identity. + /// `tab_id`/`pane_id` name the pane that adopts the session (the client + /// names it on every handoff); the resumed CLI runs for that pane, so its + /// Freshell MCP defaults to it. + #[allow(clippy::too_many_arguments)] pub(crate) async fn resume_for_handoff( &self, session_type: &str, session_id: &str, cwd: Option<&str>, + tab_id: Option<&str>, + pane_id: Option<&str>, operation_id: &str, generation: u64, ) -> Result { @@ -1447,6 +1487,8 @@ impl FreshClaudeState { } }; let msg = FreshAgentAttach { + tab_id: tab_id.map(str::to_string), + pane_id: pane_id.map(str::to_string), provider: freshell_protocol::AgentProvider::Claude, session_id: session_id.to_string(), session_type: flavor, @@ -2423,7 +2465,13 @@ impl FreshClaudeState { self.hosted_mcp, ) .with_session_id(boot_session_id) - .with_model_selection(serde_json::to_value(&msg.model_selection).ok()); + .with_model_selection(serde_json::to_value(&msg.model_selection).ok()) + .with_freshell_context( + crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids( + msg.tab_id.as_deref(), + msg.pane_id.as_deref(), + ), + ); // b8ke focused episode-2 round-2 F2: the create's claim owns this // runtime's coordinator transition (the session-init adoption's Blocked // arm correlates its own in-flight claim through it). @@ -5714,7 +5762,13 @@ impl FreshClaudeState { &rec, self.hosted_mcp, ) - .with_session_id(minted_id); + .with_session_id(minted_id) + .with_freshell_context( + crate::claude_freshell_mcp::ClaudeFreshellContext::from_ids( + msg.tab_id.as_deref(), + msg.pane_id.as_deref(), + ), + ); // Register under the CLIENT's id: the consumer stamps the map key on every // envelope and the frozen client routes by envelope sessionId // (fresh-agent-ws.ts:180-183) -- a fresh key would strand the pane. The @@ -7977,6 +8031,7 @@ pub(crate) fn sidecar_node_command( /// `test-support` seam); otherwise it inherits this process's. async fn spawn_sidecar( environment: Option<&[(String, String)]>, + freshell_endpoint: Option<&crate::claude_freshell_mcp::FreshellEndpoint>, session_type: &str, ) -> Result { let entry = sidecar_entry_path(); @@ -7996,7 +8051,8 @@ async fn spawn_sidecar( crate::claude_spawn::ClaudeNodeCommand::new(&entry), &ownership_id, ) - .with_complete_environment(environment); + .with_complete_environment(environment) + .with_freshell_endpoint(freshell_endpoint); let node = command.node().to_string(); let (mut child, tree_guard) = command.spawn_guarded().map_err(|error| match error { crate::claude_spawn::ClaudeSpawnError::ExecProfile(classification) => classification, @@ -8047,7 +8103,7 @@ mod provider_exec_tests { .collect(), ) .unwrap(); - let mut result = spawn_sidecar(None, "freshclaude").await; + let mut result = spawn_sidecar(None, None, "freshclaude").await; if let Ok(SpawnedSidecar { child, .. }) = &mut result { let pid = child.id().unwrap(); child.kill().await.unwrap(); @@ -8674,6 +8730,8 @@ pub(crate) mod tests { fn attach_msg(session_id: &str) -> FreshAgentAttach { FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Claude, @@ -11246,6 +11304,7 @@ rl.on('line', (line) => { fn dedup_create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -12721,6 +12780,8 @@ rl.on('line', (line) => { // A delayed ATTACH: the tracked-and-alive fast path is BLOCKED // with the typed error (pre-d3: the silent parity return). st.handle_attach(freshell_protocol::FreshAgentAttach { + tab_id: None, + pane_id: None, provider: freshell_protocol::AgentProvider::Claude, session_id: placeholder.clone(), session_type: SessionType::Freshclaude, diff --git a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs index 5f7a4f662..4ea477505 100644 --- a/crates/freshell-freshagent/src/claude/end_of_output_tests.rs +++ b/crates/freshell-freshagent/src/claude/end_of_output_tests.rs @@ -252,6 +252,7 @@ impl Drop for EndingSession { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-freshagent/src/claude/establish.rs b/crates/freshell-freshagent/src/claude/establish.rs index 86ef0a105..bbc5ccc4a 100644 --- a/crates/freshell-freshagent/src/claude/establish.rs +++ b/crates/freshell-freshagent/src/claude/establish.rs @@ -392,6 +392,9 @@ impl FreshClaudeState { lease_guard: Option<&mut crate::FreshSessionLeaseGuard>, start: Option>, ) -> Result { + // The Freshell MCP recipe is resolved per launch (G19), so the + // setting applies to the next sidecar without a restart. + let spec = &self.freshell_mcp.launch(spec.clone()).await; let SpawnedSidecar { mut child, mut stdin, @@ -734,6 +737,7 @@ impl FreshClaudeState { ClaudeSession { configuration: ClaudeConfiguration { settings: configuration_settings, + freshell_context: spec.freshell_context.clone(), ..ClaudeConfiguration::default() }, stdin, diff --git a/crates/freshell-freshagent/src/claude/fork.rs b/crates/freshell-freshagent/src/claude/fork.rs index 74011376e..383e519d8 100644 --- a/crates/freshell-freshagent/src/claude/fork.rs +++ b/crates/freshell-freshagent/src/claude/fork.rs @@ -98,6 +98,9 @@ struct ForkParent { /// The settings the parent runs with (model, effort, permission mode, /// plugins, cwd): the child starts with the same. settings: crate::identity_sink::FreshAgentSettings, + /// The pane the parent runs for (G19): the pane follows the child, so the + /// child runs for it too. + freshell_context: Option, } impl FreshClaudeState { @@ -368,7 +371,8 @@ impl FreshClaudeState { &launch_settings, self.hosted_mcp, ) - .with_session_id(Some(child.clone())); + .with_session_id(Some(child.clone())) + .with_freshell_context(parent.freshell_context.clone()); let established = match self .establish_runtime( EstablishPlan { @@ -533,6 +537,7 @@ impl FreshClaudeState { .unwrap_or_else(|| live_key.clone()), in_turn: Arc::clone(&session.in_turn), settings: session.configuration.settings.clone(), + freshell_context: session.configuration.freshell_context.clone(), }) } diff --git a/crates/freshell-freshagent/src/claude/live_frames_tests.rs b/crates/freshell-freshagent/src/claude/live_frames_tests.rs index d9b93c0cd..c2ff7d261 100644 --- a/crates/freshell-freshagent/src/claude/live_frames_tests.rs +++ b/crates/freshell-freshagent/src/claude/live_frames_tests.rs @@ -179,6 +179,7 @@ impl Drop for ClaudeEnvReset { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-freshagent/src/claude/rollback.rs b/crates/freshell-freshagent/src/claude/rollback.rs index c674ca9d6..196a5b5ac 100644 --- a/crates/freshell-freshagent/src/claude/rollback.rs +++ b/crates/freshell-freshagent/src/claude/rollback.rs @@ -213,6 +213,7 @@ impl FreshClaudeState { session_type, last_synthesized_complete_at, live_settings, + freshell_context, ) = { let guard = self.sessions.lock().await; match guard.get(&map_key) { @@ -230,6 +231,8 @@ impl FreshClaudeState { // The settings the session runs with (G15): the // replacement launches with them, on every lane. s.configuration.settings.clone(), + // The pane it runs for (G19): the replacement's too. + s.configuration.freshell_context.clone(), ), None => { reply_sink(rollback_error_frame( @@ -1070,7 +1073,8 @@ impl FreshClaudeState { &launch_settings, self.hosted_mcp, ) - .with_session_id(Some(replacement.clone())); + .with_session_id(Some(replacement.clone())) + .with_freshell_context(freshell_context); // A lease the rollback took on the original completes onto the // replacement (the original's durable id resolves to it); a lease the // original already held as its live owner is cleared once it is gone. diff --git a/crates/freshell-freshagent/src/claude/teardown_tests.rs b/crates/freshell-freshagent/src/claude/teardown_tests.rs index d18b10da6..1bc904958 100644 --- a/crates/freshell-freshagent/src/claude/teardown_tests.rs +++ b/crates/freshell-freshagent/src/claude/teardown_tests.rs @@ -342,7 +342,7 @@ async fn spawn_sidecar_runs_the_sidecar_inside_its_own_process_group() { let script = dir.path().join("idle-sidecar.mjs"); std::fs::write(&script, "setInterval(() => {}, 1000)\n").unwrap(); std::env::set_var("FRESHELL_CLAUDE_SIDECAR", &script); - let spawned = spawn_sidecar(None, "freshclaude").await; + let spawned = spawn_sidecar(None, None, "freshclaude").await; std::env::remove_var("FRESHELL_CLAUDE_SIDECAR"); let SpawnedSidecar { diff --git a/crates/freshell-freshagent/src/claude_freshell_mcp.rs b/crates/freshell-freshagent/src/claude_freshell_mcp.rs new file mode 100644 index 000000000..001099869 --- /dev/null +++ b/crates/freshell-freshagent/src/claude_freshell_mcp.rs @@ -0,0 +1,239 @@ +//! The ordinary Freshell MCP server inside freshclaude and kilroy panes (G19, +//! api-hosted-ops plan §2.4). +//! +//! Terminal panes give their coding CLI the Freshell MCP server and the context +//! it needs (`FRESHELL_URL`, `FRESHELL_TOKEN`, the pane). A claude sidecar gets +//! the same through three pieces, all wired by `freshell-server` (the session +//! host wires none of them: a hosted lane keeps its own fixed recipe): +//! +//! - the endpoint ([`FreshellEndpoint`]): every sidecar's environment carries +//! `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN` from the server's own +//! bound port and token, and none of another pane's ids; +//! - the recipe ([`ClaudeFreshellMcpProvider`]): asked once per sidecar launch, +//! so `settings.codingCli.mcpServer` applies to the next launch without a +//! restart. Only the command and its arguments travel in the `create` line +//! (`freshellMcp`); the SDK puts that config on the CLI's argv, so the token +//! never goes there; +//! - the pane ([`ClaudeFreshellContext`]): the `create` line's +//! `freshellContext`, merged by the sidecar into the CLI's environment, from +//! which the CLI hands it to the MCP child. + +use std::sync::{Arc, OnceLock}; + +use futures_util::future::BoxFuture; +use serde::Serialize; + +pub use freshell_platform::freshell_context_env::FreshellEndpoint; + +use crate::claude_launch::ClaudeLaunchSpec; + +/// The command that starts the Freshell MCP server for a sidecar's CLI. +#[derive(Debug, Clone, PartialEq, Eq, Serialize)] +pub struct ClaudeFreshellMcp { + pub command: String, + pub args: Vec, +} + +/// What the server's Freshell MCP setting and command resolver answer for one +/// sidecar launch. +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum ClaudeFreshellMcpResolution { + /// `settings.codingCli.mcpServer` is off. + Disabled, + /// The setting is on, but the server command could not be resolved (the + /// reason). The pane starts without the MCP server; it never fails over it. + Unavailable(String), + /// The setting is on and this is the command. + Resolved(ClaudeFreshellMcp), +} + +/// Asked once per sidecar launch for the Freshell MCP recipe. +pub type ClaudeFreshellMcpProvider = + Arc BoxFuture<'static, ClaudeFreshellMcpResolution> + Send + Sync>; + +/// The pane a session runs for: the `create` line's `freshellContext`, which +/// becomes `FRESHELL_TAB_ID` / `FRESHELL_PANE_ID` in the CLI's environment. +#[derive(Debug, Clone, PartialEq, Eq, Default, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaudeFreshellContext { + #[serde(skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(skip_serializing_if = "Option::is_none")] + pub pane_id: Option, +} + +impl ClaudeFreshellContext { + /// The context of `tab_id` and `pane_id`; `None` when neither names + /// anything (an empty id names nothing). + pub fn from_ids(tab_id: Option<&str>, pane_id: Option<&str>) -> Option { + let id = |value: Option<&str>| value.filter(|v| !v.is_empty()).map(str::to_string); + let context = Self { + tab_id: id(tab_id), + pane_id: id(pane_id), + }; + (context.tab_id.is_some() || context.pane_id.is_some()).then_some(context) + } +} + +/// A claude lane's Freshell MCP wiring: set once each, after construction +/// (the `set_identity_sink` pattern), shared by every clone of the lane. +#[derive(Clone, Default)] +pub(crate) struct ClaudeFreshellMcpWiring { + endpoint: Arc>, + provider: Arc>, +} + +impl ClaudeFreshellMcpWiring { + /// Install the endpoint; `false` when one already was. + pub(crate) fn set_endpoint(&self, endpoint: FreshellEndpoint) -> bool { + self.endpoint.set(endpoint).is_ok() + } + + /// Install the recipe provider; `false` when one already was. + pub(crate) fn set_provider(&self, provider: ClaudeFreshellMcpProvider) -> bool { + self.provider.set(provider).is_ok() + } + + /// The endpoint every sidecar's environment carries, once installed. + pub(crate) fn endpoint(&self) -> Option<&FreshellEndpoint> { + self.endpoint.get() + } + + /// `spec` with the Freshell MCP recipe this launch gets: none for a lane + /// that selected the hosted recipe or has no provider, else the provider's + /// answer, asked now. Logged per launch (`freshagent.claude.mcp_injection`, + /// and `freshagent.claude.mcp_unavailable` when the command could not be + /// resolved). + pub(crate) async fn launch(&self, mut spec: ClaudeLaunchSpec) -> ClaudeLaunchSpec { + spec.freshell_mcp = None; + if spec.hosted_mcp { + return spec; + } + let Some(provider) = self.provider.get() else { + return spec; + }; + let resolution = provider().await; + if let ClaudeFreshellMcpResolution::Unavailable(error) = &resolution { + tracing::warn!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.mcp_unavailable", + request_id = %spec.request_id, + session_type = %spec.session_type, + error = %error, + "the Freshell MCP server command could not be resolved; the pane starts without it" + ); + } + tracing::info!( + target: "freshell_freshagent::claude", + event = "freshagent.claude.mcp_injection", + request_id = %spec.request_id, + session_type = %spec.session_type, + boot = spec.boot.kind(), + enabled = !matches!(resolution, ClaudeFreshellMcpResolution::Disabled), + resolved = matches!(resolution, ClaudeFreshellMcpResolution::Resolved(_)), + has_pane_context = spec.freshell_context.is_some(), + "the Freshell MCP selection for a claude sidecar launch" + ); + if let ClaudeFreshellMcpResolution::Resolved(recipe) = resolution { + spec.freshell_mcp = Some(recipe); + } + spec + } +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::claude_launch::ClaudeBoot; + use crate::identity_sink::FreshAgentSettings; + + fn recipe() -> ClaudeFreshellMcp { + ClaudeFreshellMcp { + command: "node".into(), + args: vec!["/srv/freshell-mcp/server.js".into()], + } + } + + fn spec(hosted_mcp: bool) -> ClaudeLaunchSpec { + ClaudeLaunchSpec::new( + "req", + "freshclaude", + ClaudeBoot::Fresh, + None, + &FreshAgentSettings::default(), + hosted_mcp, + ) + } + + fn answering(resolution: ClaudeFreshellMcpResolution) -> ClaudeFreshellMcpWiring { + let wiring = ClaudeFreshellMcpWiring::default(); + let provider: ClaudeFreshellMcpProvider = Arc::new(move || { + let resolution = resolution.clone(); + Box::pin(async move { resolution }) + }); + assert!(wiring.set_provider(provider)); + wiring + } + + #[tokio::test] + async fn a_launch_gets_the_recipe_the_provider_resolves_now() { + let launched = answering(ClaudeFreshellMcpResolution::Resolved(recipe())) + .launch(spec(false)) + .await; + assert_eq!(launched.freshell_mcp, Some(recipe())); + } + + #[tokio::test] + async fn a_disabled_or_unresolvable_recipe_launches_without_one() { + for resolution in [ + ClaudeFreshellMcpResolution::Disabled, + ClaudeFreshellMcpResolution::Unavailable("no tsx".into()), + ] { + let launched = answering(resolution.clone()).launch(spec(false)).await; + assert_eq!(launched.freshell_mcp, None, "{resolution:?}"); + } + } + + #[tokio::test] + async fn a_lane_without_a_provider_launches_without_a_recipe() { + let launched = ClaudeFreshellMcpWiring::default().launch(spec(false)).await; + assert_eq!(launched.freshell_mcp, None); + } + + /// The hosted lane keeps its fixed recipe and never gets the ordinary one, + /// even when a provider would answer one. + #[tokio::test] + async fn a_hosted_lane_never_gets_the_ordinary_recipe() { + let launched = answering(ClaudeFreshellMcpResolution::Resolved(recipe())) + .launch(spec(true)) + .await; + assert_eq!(launched.freshell_mcp, None); + } + + #[test] + fn a_pane_context_names_only_what_it_was_given() { + assert_eq!(ClaudeFreshellContext::from_ids(None, None), None); + assert_eq!(ClaudeFreshellContext::from_ids(Some(""), Some("")), None); + assert_eq!( + ClaudeFreshellContext::from_ids(Some("tab-1"), None), + Some(ClaudeFreshellContext { + tab_id: Some("tab-1".into()), + pane_id: None, + }) + ); + assert_eq!( + serde_json::to_value(ClaudeFreshellContext::from_ids(Some("t"), Some("p")).unwrap()) + .unwrap(), + serde_json::json!({ "tabId": "t", "paneId": "p" }) + ); + } + + #[test] + fn the_endpoint_and_provider_are_set_once() { + let wiring = ClaudeFreshellMcpWiring::default(); + assert!(wiring.endpoint().is_none()); + assert!(wiring.set_endpoint(FreshellEndpoint::local(4100, "tok"))); + assert!(!wiring.set_endpoint(FreshellEndpoint::local(4200, "other"))); + assert_eq!(wiring.endpoint().unwrap().url, "http://localhost:4100"); + } +} diff --git a/crates/freshell-freshagent/src/claude_launch.rs b/crates/freshell-freshagent/src/claude_launch.rs index 2d59850a2..e711f69c0 100644 --- a/crates/freshell-freshagent/src/claude_launch.rs +++ b/crates/freshell-freshagent/src/claude_launch.rs @@ -5,8 +5,8 @@ //! handoff resume, a rollback respawn) describes itself as one //! [`ClaudeLaunchSpec`], and [`ClaudeLaunchSpec::create_request`] is the only //! builder of the sidecar's `create` line. So no launch can drop a field another -//! carries: the pane's plugins and the hosted Freshell MCP reach the CLI on every -//! launch (G15). +//! carries: the pane's plugins, the hosted or ordinary Freshell MCP and the pane +//! context reach the CLI on every launch (G15, G19). //! //! The line names its stdio protocol ([`SIDECAR_PROTOCOL`]) and a [`ClaudeBoot`]: //! a fresh conversation, a resume, or a fork of a parent conversation at a point. @@ -20,6 +20,7 @@ use std::path::Path; use serde::{Deserialize, Serialize}; use serde_json::{json, Map, Value}; +use crate::claude_freshell_mcp::{ClaudeFreshellContext, ClaudeFreshellMcp}; use crate::claude_snapshot::{LocateError, TranscriptLocation}; use crate::identity_sink::FreshAgentSettings; @@ -166,6 +167,12 @@ pub(crate) struct ClaudeLaunchSpec { /// The id a fresh or forked boot writes the conversation under (the /// session's own id when it boots from its genesis, design §4.2). pub(crate) session_id: Option, + /// The ordinary Freshell MCP recipe (G19), resolved when the sidecar is + /// launched (`claude_freshell_mcp`); never for a hosted lane. + pub(crate) freshell_mcp: Option, + /// The pane the session runs for (G19): a create's or an attach's pane, or + /// the replaced or forked session's. + pub(crate) freshell_context: Option, } impl ClaudeLaunchSpec { @@ -193,9 +200,17 @@ impl ClaudeLaunchSpec { hosted_mcp, model_selection: None, session_id: None, + freshell_mcp: None, + freshell_context: None, } } + /// This launch running for the pane `context` (G19). + pub(crate) fn with_freshell_context(mut self, context: Option) -> Self { + self.freshell_context = context; + self + } + /// This launch writing a fresh or forked conversation under `session_id`. pub(crate) fn with_session_id(mut self, session_id: Option) -> Self { self.session_id = session_id; @@ -210,9 +225,9 @@ impl ClaudeLaunchSpec { /// The sidecar `create` line. Every line carries the same keys, whatever /// the launch: `type`, `protocol`, `requestId`, `sessionType`, `boot`, and - /// `sessionId`, `cwd`, `model`, `permissionMode`, `effort`, `plugins` (each - /// `null` when unset) and `hostedMcp`; `modelSelection` only when the - /// launch has one. + /// `sessionId`, `cwd`, `model`, `permissionMode`, `effort`, `plugins`, + /// `freshellMcp`, `freshellContext` (each `null` when unset) and + /// `hostedMcp`; `modelSelection` only when the launch has one. pub(crate) fn create_request(&self) -> Value { let mut line = Map::new(); line.insert("type".into(), json!("create")); @@ -227,6 +242,8 @@ impl ClaudeLaunchSpec { line.insert("effort".into(), json!(self.effort)); line.insert("plugins".into(), json!(self.plugins)); line.insert("hostedMcp".into(), json!(self.hosted_mcp)); + line.insert("freshellMcp".into(), json!(self.freshell_mcp)); + line.insert("freshellContext".into(), json!(self.freshell_context)); if let Some(selection) = &self.model_selection { line.insert("modelSelection".into(), selection.clone()); } @@ -522,10 +539,71 @@ mod tests { "effort": "high", "plugins": ["/w/.claude/plugins/review"], "hostedMcp": true, + "freshellMcp": null, + "freshellContext": null, }) ); } + /// G19: whatever the boot, the line hands the sidecar the ordinary + /// Freshell MCP recipe and the pane context the launch carries (only the + /// command and its arguments, never an environment), and names both keys + /// when unset. + #[test] + fn every_boot_carries_the_freshell_mcp_recipe_and_the_pane_context() { + let recipe = ClaudeFreshellMcp { + command: "node".into(), + args: vec!["/srv/freshell-mcp/server.js".into()], + }; + for boot in [ + ClaudeBoot::Fresh, + ClaudeBoot::Resume { resume: "P".into() }, + ClaudeBoot::Fork { + resume: "P".into(), + at: Some("a1".into()), + drops_turn: None, + }, + ] { + let kind = boot.kind(); + let mut spec = ClaudeLaunchSpec::new( + "req", + "kilroy", + boot.clone(), + None, + &FreshAgentSettings::default(), + false, + ) + .with_freshell_context(ClaudeFreshellContext::from_ids( + Some("tab-1"), + Some("pane-1"), + )); + spec.freshell_mcp = Some(recipe.clone()); + let line = spec.create_request(); + assert_eq!( + line["freshellMcp"], + json!({ "command": "node", "args": ["/srv/freshell-mcp/server.js"] }), + "{kind}" + ); + assert_eq!( + line["freshellContext"], + json!({ "tabId": "tab-1", "paneId": "pane-1" }), + "{kind}" + ); + + let bare = ClaudeLaunchSpec::new( + "req", + "kilroy", + boot, + None, + &FreshAgentSettings::default(), + false, + ) + .create_request(); + assert_eq!(bare.get("freshellMcp"), Some(&Value::Null), "{kind}"); + assert_eq!(bare.get("freshellContext"), Some(&Value::Null), "{kind}"); + } + } + /// G15: whatever the boot, the line hands the sidecar the session's plugins /// and the lane's hosted MCP selection, and names both keys when unset. #[test] diff --git a/crates/freshell-freshagent/src/claude_spawn.rs b/crates/freshell-freshagent/src/claude_spawn.rs index ca5e6c699..f040da33f 100644 --- a/crates/freshell-freshagent/src/claude_spawn.rs +++ b/crates/freshell-freshagent/src/claude_spawn.rs @@ -170,6 +170,7 @@ pub struct ClaudeNodeCommand { ownership_tag: Option, closed_stdin: bool, complete_environment: Option>, + freshell_endpoint: Option, } impl ClaudeNodeCommand { @@ -201,6 +202,7 @@ impl ClaudeNodeCommand { ownership_tag: None, closed_stdin: false, complete_environment: None, + freshell_endpoint: None, } } @@ -234,6 +236,20 @@ impl ClaudeNodeCommand { self } + /// When `Some`, the child reaches this server through `endpoint` (G19): + /// it gets `FRESHELL=1`, `FRESHELL_URL` and `FRESHELL_TOKEN`, and none of + /// the pane-scoped names (`FRESHELL_TERMINAL_ID`, `FRESHELL_TAB_ID`, + /// `FRESHELL_PANE_ID`) it would otherwise inherit, for example from a + /// server that itself runs inside a Freshell terminal: its session's pane + /// arrives with the `create` line. `None` leaves those names alone. + pub fn with_freshell_endpoint( + mut self, + endpoint: Option<&freshell_platform::freshell_context_env::FreshellEndpoint>, + ) -> Self { + self.freshell_endpoint = endpoint.cloned(); + self + } + /// The child reads no stdin (the catalog probe); the default is a pipe. pub fn with_closed_stdin(mut self) -> Self { self.closed_stdin = true; @@ -278,6 +294,13 @@ impl ClaudeNodeCommand { command.env_clear(); command.envs(environment.iter().map(|(key, value)| (key, value))); } + if let Some(endpoint) = &self.freshell_endpoint { + use freshell_platform::freshell_context_env as context; + for name in context::FRESHELL_PANE_CONTEXT_ENV_VARS { + command.env_remove(name); + } + command.envs(context::freshell_context_env(endpoint, None, None, None)); + } if let Some(ownership_id) = &self.ownership_tag { command.env(CLAUDE_SIDECAR_OWNERSHIP_ENV, ownership_id); } @@ -1014,6 +1037,70 @@ mod tests { assert_eq!(String::from_utf8_lossy(&output.stdout), "HOME=/rig/home\0"); } + /// G19: a sidecar reaches this server through the endpoint it is given, + /// never through an inherited one, and inherits no other pane's ids (here + /// `env -0` stands in for node and prints what the child got). + #[cfg(unix)] + #[tokio::test] + async fn a_child_gets_this_servers_endpoint_and_no_other_panes_ids() { + let env = env_from(&[("FRESHELL_CLAUDE_NODE", "/usr/bin/env")]); + let inherited = [ + ("HOME".to_string(), "/rig/home".to_string()), + ( + "FRESHELL_URL".to_string(), + "http://localhost:3001".to_string(), + ), + ("FRESHELL_TOKEN".to_string(), "outer-token".to_string()), + ( + "FRESHELL_TERMINAL_ID".to_string(), + "outer-terminal".to_string(), + ), + ("FRESHELL_TAB_ID".to_string(), "outer-tab".to_string()), + ("FRESHELL_PANE_ID".to_string(), "outer-pane".to_string()), + ]; + let endpoint = + freshell_platform::freshell_context_env::FreshellEndpoint::local(4100, "this-token"); + let output = ClaudeNodeCommand::from_parts("-0", &env, None) + .with_complete_environment(Some(&inherited)) + .with_freshell_endpoint(Some(&endpoint)) + .with_closed_stdin() + .command() + .output() + .await + .expect("run env"); + assert!(output.status.success(), "{output:?}"); + let mut got: Vec = String::from_utf8_lossy(&output.stdout) + .split('\0') + .filter(|entry| !entry.is_empty()) + .map(str::to_string) + .collect(); + got.sort(); + assert_eq!( + got, + [ + "FRESHELL=1", + "FRESHELL_TOKEN=this-token", + "FRESHELL_URL=http://localhost:4100", + "HOME=/rig/home", + ] + ); + } + + /// Without an endpoint the child's Freshell names are left as inherited. + #[test] + fn a_child_without_an_endpoint_keeps_its_freshell_names() { + let env = env_from(&[]); + let inherited = [("FRESHELL_TAB_ID".to_string(), "tab".to_string())]; + let command = ClaudeNodeCommand::from_parts("/srv/sidecar/index.mjs", &env, None) + .with_complete_environment(Some(&inherited)) + .command(); + assert_eq!( + envs(&command).get("FRESHELL_TAB_ID"), + Some(&Some("tab".to_string())) + ); + assert!(!envs(&command).contains_key("FRESHELL_URL")); + } + #[test] fn the_run_as_wrapper_needs_both_ids() { let env = env_from(&[("FRESHELL_PROVIDER_RUN_AS_UID", "1234")]); diff --git a/crates/freshell-freshagent/src/codex.rs b/crates/freshell-freshagent/src/codex.rs index cafb9f94e..e312db399 100644 --- a/crates/freshell-freshagent/src/codex.rs +++ b/crates/freshell-freshagent/src/codex.rs @@ -12580,6 +12580,7 @@ pub(crate) mod tests { let sink = crate::naming::test_support::RecordingSink::new(); st.set_session_naming(sink.clone()); let create = FreshAgentCreate { + pane_id: None, observed_epoch: None, observed_generation: None, naming_handle: Some("handle-codex-early".to_string()), @@ -14560,6 +14561,7 @@ pub(crate) mod tests { let create = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -14735,6 +14737,7 @@ pub(crate) mod tests { configure_fake_codex_cmd("{}"); st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -14895,6 +14898,7 @@ pub(crate) mod tests { let create_task = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -22075,6 +22079,7 @@ pub(crate) mod tests { let (st, mut rx) = state_with_bus(); st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -22132,6 +22137,8 @@ pub(crate) mod tests { fn attach_msg(session_id: &str) -> FreshAgentAttach { FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -22587,6 +22594,8 @@ pub(crate) mod tests { .await; st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -22766,6 +22775,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -22819,6 +22829,7 @@ pub(crate) mod tests { /// `request_id`. fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -23164,6 +23175,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -23234,6 +23246,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -23315,6 +23328,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -23384,6 +23398,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24012,6 +24027,7 @@ pub(crate) mod tests { ) -> String { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24063,6 +24079,7 @@ pub(crate) mod tests { ) -> String { st.handle_create( FreshAgentCreate { + pane_id: None, observed_epoch: None, observed_generation: None, naming_handle: Some(naming_handle.to_string()), @@ -24259,6 +24276,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24328,6 +24346,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24410,6 +24429,7 @@ pub(crate) mod tests { state .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -24839,6 +24859,8 @@ pub(crate) mod tests { configure_fake_codex_cmd("{}"); st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -24934,6 +24956,7 @@ pub(crate) mod tests { let create_task = tokio::spawn(async move { st2.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -25094,6 +25117,8 @@ pub(crate) mod tests { let attach_task = tokio::spawn(async move { st_attach .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -25190,6 +25215,8 @@ pub(crate) mod tests { configure_fake_codex_cmd("{}"); st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: freshell_protocol::AgentProvider::Codex, @@ -27868,6 +27895,8 @@ pub(crate) mod tests { // THE STALE ATTACH: observed generation is the PRE-advance value — // typed refusal (SESSION_RESERVED), never a recreation. st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: Some(before.epoch), observed_generation: Some(before.generation), provider: freshell_protocol::AgentProvider::Codex, @@ -28720,6 +28749,7 @@ pub(crate) mod tests { task_st .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -28830,6 +28860,7 @@ pub(crate) mod tests { task_st .handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -28945,6 +28976,7 @@ pub(crate) mod tests { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -29358,6 +29390,7 @@ pub(crate) mod tests { // adopted without consulting the coordinator. st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -29448,6 +29481,7 @@ pub(crate) mod tests { // observed generation — the fence discipline refuses typed. st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: Some(registry.boot_epoch()), observed_generation: Some(stale_generation), diff --git a/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs b/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs index 15cbbaf37..07ae9c7e3 100644 --- a/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs +++ b/crates/freshell-freshagent/src/codex_sidecar_tracking_tests.rs @@ -95,6 +95,7 @@ async fn create_tracked_session_with_resume( ) -> String { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -226,6 +227,7 @@ async fn failed_spawn_leaves_no_record() { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -627,6 +629,7 @@ async fn create_bail_after_spawn_leaves_no_record() { st.handle_create( FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-freshagent/src/lib.rs b/crates/freshell-freshagent/src/lib.rs index 460b7d627..e096b4e33 100644 --- a/crates/freshell-freshagent/src/lib.rs +++ b/crates/freshell-freshagent/src/lib.rs @@ -38,6 +38,7 @@ pub mod claude; pub(crate) mod claude_dead_sessions; +pub mod claude_freshell_mcp; pub(crate) mod claude_handshake; pub(crate) mod claude_launch; pub(crate) mod claude_live_events; diff --git a/crates/freshell-freshagent/src/opencode_ws.rs b/crates/freshell-freshagent/src/opencode_ws.rs index 5107643c5..5f58d3ec3 100644 --- a/crates/freshell-freshagent/src/opencode_ws.rs +++ b/crates/freshell-freshagent/src/opencode_ws.rs @@ -9720,6 +9720,7 @@ mod tests { fn create_msg(request_id: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -10528,6 +10529,8 @@ mod tests { // the terminal-owner gate must answer the TYPED refusal (never // the wildcard reuse that restarts the serve bridge). st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: real_id.clone(), session_type: SessionType::Freshopencode, @@ -10636,6 +10639,8 @@ mod tests { // THE DELAYED ATTACH with the STALE pair — the state precheck sees // Live (passes); the ADOPT validates the pair and refuses typed. st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: real_id.clone(), session_type: SessionType::Freshopencode, @@ -12912,6 +12917,8 @@ mod tests { fn attach_msg(session_id: &str) -> FreshAgentAttach { FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: AgentProvider::Opencode, @@ -24760,6 +24767,8 @@ mod tests { // THE MAP-HIT FENCED ATTACH — the incident's three wasted attempts, // now the documented recovery verb. st.handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: durable.clone(), session_type: SessionType::Freshopencode, diff --git a/crates/freshell-freshagent/src/session_handoff.rs b/crates/freshell-freshagent/src/session_handoff.rs index 7f556c5d1..0d760a8a9 100644 --- a/crates/freshell-freshagent/src/session_handoff.rs +++ b/crates/freshell-freshagent/src/session_handoff.rs @@ -4110,6 +4110,8 @@ impl SessionHandoffRunner { "freshclaude", &req.session_id, req.cwd.as_deref(), + req.tab_id.as_deref(), + req.pane_id.as_deref(), operation_id, generation, ) @@ -4121,6 +4123,8 @@ impl SessionHandoffRunner { "kilroy", &req.session_id, req.cwd.as_deref(), + req.tab_id.as_deref(), + req.pane_id.as_deref(), operation_id, generation, ) diff --git a/crates/freshell-freshagent/src/session_handoff/tests.rs b/crates/freshell-freshagent/src/session_handoff/tests.rs index 731593dd7..76f44eef4 100644 --- a/crates/freshell-freshagent/src/session_handoff/tests.rs +++ b/crates/freshell-freshagent/src/session_handoff/tests.rs @@ -761,6 +761,7 @@ async fn unsupported_atomic_handoff_refuses_before_mutation_and_clear_bypasses_p fn create_msg(sid: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, request_id: format!("handoff-create-{}", uuid::Uuid::new_v4()), session_type: SessionType::Freshclaude, @@ -6953,6 +6954,7 @@ const FRESH_CREATE_DURABLE_ID: &str = "aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa"; fn fresh_create_msg(session_type: SessionType) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, request_id: format!("handoff-create-fresh-{}", uuid::Uuid::new_v4()), session_type, @@ -7231,6 +7233,8 @@ async fn opencode_handoff_keeps_shared_serve_alive_and_session_id_stable() { // serve (the attach-resume lane — get_session on the fake serve answers). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, @@ -7362,6 +7366,8 @@ async fn an_opencode_placeholder_handoff_resolves_the_durable_owner_and_complete // identity the pane resolves to after the first send). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: durable_id.clone(), session_type: SessionType::Freshopencode, @@ -7485,6 +7491,8 @@ async fn opencode_handoff_stop_aborts_the_active_turn_through_the_manager_before // shared serve (the attach-resume lane — the test-6 pattern). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, @@ -7628,6 +7636,8 @@ async fn opencode_handoff_stop_aborts_a_daemon_turn_left_running_by_a_finished_l // shared serve (the test-6 pattern). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, @@ -7772,6 +7782,8 @@ async fn opencode_handoff_during_compaction_aborts_the_daemon_side_summarize() { // shared serve (the test-6 pattern). rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, @@ -8777,6 +8789,8 @@ async fn handoff_route_resolves_absent_session_type_when_unambiguous() { // Fresh owner: a durable opencode session through the shared serve. rig.fresh_opencode .handle_attach(FreshAgentAttach { + tab_id: None, + pane_id: None, provider: AgentProvider::Opencode, session_id: sid.clone(), session_type: SessionType::Freshopencode, diff --git a/crates/freshell-freshagent/src/terminal_tabs.rs b/crates/freshell-freshagent/src/terminal_tabs.rs index ceb3e7854..25ab07a72 100644 --- a/crates/freshell-freshagent/src/terminal_tabs.rs +++ b/crates/freshell-freshagent/src/terminal_tabs.rs @@ -390,29 +390,12 @@ fn build_terminal_base_env( tab_id: Option<&str>, pane_id: Option<&str>, ) -> BTreeMap { - let mut out = BTreeMap::new(); - out.insert("FRESHELL".to_string(), "1".to_string()); - let port_raw = env - .get("PORT") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| "3001".to_string()); - let url = env - .get("FRESHELL_URL") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| format!("http://localhost:{}", js_number_string(&port_raw))); - out.insert("FRESHELL_URL".to_string(), url); - out.insert( - "FRESHELL_TOKEN".to_string(), - env.get("AUTH_TOKEN").unwrap_or_default(), - ); - out.insert("FRESHELL_TERMINAL_ID".to_string(), terminal_id.to_string()); - if let Some(t) = tab_id.filter(|s| !s.is_empty()) { - out.insert("FRESHELL_TAB_ID".to_string(), t.to_string()); - } - if let Some(p) = pane_id.filter(|s| !s.is_empty()) { - out.insert("FRESHELL_PANE_ID".to_string(), p.to_string()); - } - out + freshell_platform::freshell_context_env::freshell_context_env( + &freshell_platform::freshell_context_env::FreshellEndpoint::from_env(env), + Some(terminal_id), + tab_id, + pane_id, + ) } /// One value-safe managed Codex rendering for this terminal tab's TUI and a @@ -463,25 +446,6 @@ fn build_codex_managed_launch_setup( }) } -/// JS `String(Number(s))` for the `PORT` template slot (mirrored from -/// `crates/freshell-ws/src/terminal.rs:1313`). -fn js_number_string(s: &str) -> String { - let t = s.trim(); - if t.is_empty() { - return "0".to_string(); - } - match t.parse::() { - Ok(n) if n.is_finite() => { - if n.fract() == 0.0 && n.abs() < 1e15 { - format!("{}", n as i64) - } else { - format!("{n}") - } - } - _ => "NaN".to_string(), - } -} - /// `wrapTerminalSpawnError` (`terminal-registry.ts:450-481`, mirrored from /// `crates/freshell-ws/src/terminal.rs:1334`): the user-facing spawn-failure /// message. diff --git a/crates/freshell-platform/src/freshell_context_env.rs b/crates/freshell-platform/src/freshell_context_env.rs new file mode 100644 index 000000000..68b22acd9 --- /dev/null +++ b/crates/freshell-platform/src/freshell_context_env.rs @@ -0,0 +1,165 @@ +//! The Freshell context a child process gets so it can reach this server +//! (the Freshell CLI and MCP client read it): `FRESHELL=1`, `FRESHELL_URL`, +//! `FRESHELL_TOKEN`, and the pane it runs for (`FRESHELL_TERMINAL_ID`, +//! `FRESHELL_TAB_ID`, `FRESHELL_PANE_ID`). +//! +//! One builder for every lane that hands it out: the terminal lanes +//! (`buildTerminalBaseEnv`, `terminal-registry.ts:1529-1542`) and the +//! freshclaude/kilroy sidecar, so the names and the omission rules cannot drift. + +use std::collections::BTreeMap; + +use crate::Env; + +/// The context names this module writes, in no particular order. +pub const FRESHELL: &str = "FRESHELL"; +pub const FRESHELL_URL: &str = "FRESHELL_URL"; +pub const FRESHELL_TOKEN: &str = "FRESHELL_TOKEN"; +pub const FRESHELL_TERMINAL_ID: &str = "FRESHELL_TERMINAL_ID"; +pub const FRESHELL_TAB_ID: &str = "FRESHELL_TAB_ID"; +pub const FRESHELL_PANE_ID: &str = "FRESHELL_PANE_ID"; + +/// The pane-scoped names: a child launched for one pane must never inherit +/// another pane's values (for example from the environment of a server that +/// itself runs inside a Freshell terminal). +pub const FRESHELL_PANE_CONTEXT_ENV_VARS: [&str; 3] = + [FRESHELL_TERMINAL_ID, FRESHELL_TAB_ID, FRESHELL_PANE_ID]; + +/// Where this server answers its REST API, and the token that authorizes it. +#[derive(Clone, PartialEq, Eq)] +pub struct FreshellEndpoint { + pub url: String, + pub token: String, +} + +impl std::fmt::Debug for FreshellEndpoint { + // The token never reaches a log line. + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + f.debug_struct("FreshellEndpoint") + .field("url", &self.url) + .field("token", &"") + .finish() + } +} + +impl FreshellEndpoint { + /// The endpoint of a server listening on `port` on this host. + pub fn local(port: u16, token: impl Into) -> Self { + Self { + url: format!("http://localhost:{port}"), + token: token.into(), + } + } + + /// The terminal lanes' endpoint, from the process environment + /// (`terminal-registry.ts:1529-1542`): `FRESHELL_URL` when set, else + /// `http://localhost:` (`PORT` defaults to 3001, coerced the way the + /// reference's `Number(...)` does), and `AUTH_TOKEN` (empty when unset). + pub fn from_env(env: &dyn Env) -> Self { + // `const port = Number(process.env.PORT || 3001)` (truthy: '' → 3001). + let port_raw = env + .get("PORT") + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| "3001".to_string()); + let url = env + .get(FRESHELL_URL) + .filter(|v| !v.is_empty()) + .unwrap_or_else(|| format!("http://localhost:{}", js_number_string(&port_raw))); + Self { + url, + token: env.get("AUTH_TOKEN").unwrap_or_default(), + } + } +} + +/// The context environment for a child: `FRESHELL=1`, the endpoint, and each +/// of `terminal_id`, `tab_id`, `pane_id` that is present and non-empty (an +/// absent or empty id leaves its key out, `...(tabId ? {...} : {})`). +pub fn freshell_context_env( + endpoint: &FreshellEndpoint, + terminal_id: Option<&str>, + tab_id: Option<&str>, + pane_id: Option<&str>, +) -> BTreeMap { + let mut out = BTreeMap::new(); + out.insert(FRESHELL.to_string(), "1".to_string()); + out.insert(FRESHELL_URL.to_string(), endpoint.url.clone()); + out.insert(FRESHELL_TOKEN.to_string(), endpoint.token.clone()); + for (name, value) in [ + (FRESHELL_TERMINAL_ID, terminal_id), + (FRESHELL_TAB_ID, tab_id), + (FRESHELL_PANE_ID, pane_id), + ] { + if let Some(value) = value.filter(|v| !v.is_empty()) { + out.insert(name.to_string(), value.to_string()); + } + } + out +} + +/// JS `String(Number(s))` for the `PORT` template slot: every real deployment is +/// a plain integer; whitespace-only → `0`, unparseable → `NaN` (faithful to the +/// reference's `Number(...)` coercion in the template literal). +pub fn js_number_string(s: &str) -> String { + let t = s.trim(); + if t.is_empty() { + return "0".to_string(); + } + match t.parse::() { + Ok(n) if n.is_finite() => { + if n.fract() == 0.0 && n.abs() < 1e15 { + format!("{}", n as i64) + } else { + format!("{n}") + } + } + _ => "NaN".to_string(), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn js_number_string_coercion() { + assert_eq!(js_number_string("17872"), "17872"); + assert_eq!(js_number_string(" 17872 "), "17872"); + assert_eq!(js_number_string("abc"), "NaN"); + assert_eq!(js_number_string(" "), "0"); + } + + #[test] + fn a_local_endpoint_names_this_hosts_port() { + let endpoint = FreshellEndpoint::local(3344, "tok"); + assert_eq!(endpoint.url, "http://localhost:3344"); + assert_eq!(endpoint.token, "tok"); + } + + #[test] + fn the_debug_form_never_shows_the_token() { + let shown = format!("{:?}", FreshellEndpoint::local(1, "secret-token")); + assert!(!shown.contains("secret-token"), "{shown}"); + } + + #[test] + fn a_context_without_a_terminal_carries_the_endpoint_and_the_pane() { + let out = freshell_context_env( + &FreshellEndpoint::local(9, "tok"), + None, + Some("tab-1"), + Some("pane-1"), + ); + let expected: BTreeMap = [ + ("FRESHELL", "1"), + ("FRESHELL_URL", "http://localhost:9"), + ("FRESHELL_TOKEN", "tok"), + ("FRESHELL_TAB_ID", "tab-1"), + ("FRESHELL_PANE_ID", "pane-1"), + ] + .into_iter() + .map(|(k, v)| (k.to_string(), v.to_string())) + .collect(); + assert_eq!(out, expected); + } +} diff --git a/crates/freshell-platform/src/lib.rs b/crates/freshell-platform/src/lib.rs index 937ce2cac..bd7f8440d 100644 --- a/crates/freshell-platform/src/lib.rs +++ b/crates/freshell-platform/src/lib.rs @@ -57,6 +57,7 @@ pub mod cli_launch; pub mod clock; pub mod codex_inputs; pub mod detect; +pub mod freshell_context_env; pub mod git_meta; pub mod host_stats_readers; pub mod managed_child_secrets; diff --git a/crates/freshell-protocol/src/client_messages.rs b/crates/freshell-protocol/src/client_messages.rs index b18997051..3ada8213c 100644 --- a/crates/freshell-protocol/src/client_messages.rs +++ b/crates/freshell-protocol/src/client_messages.rs @@ -900,6 +900,11 @@ pub struct FreshAgentCreate { /// optional; conn-less (REST/MCP) creates omit it. #[serde(skip_serializing_if = "Option::is_none")] pub tab_id: Option, + /// G19: the creating pane's id. With `tab_id` it is the pane context the + /// agent's Freshell MCP acts for (`FRESHELL_TAB_ID`/`FRESHELL_PANE_ID`). + /// Additive optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option, /// Unified agent names (Task 1 wire / Task 2 Rust side): the pane's /// pre-durable naming handle — see `TerminalCreate::naming_handle`. /// Additive optional. @@ -942,6 +947,12 @@ pub struct FreshAgentAttach { pub resume_session_id: Option, #[serde(skip_serializing_if = "Option::is_none")] pub session_ref: Option, + /// G19: the attaching pane. An attach that resumes the session hands the + /// agent's Freshell MCP this pane context. Additive optional. + #[serde(default, skip_serializing_if = "Option::is_none")] + pub tab_id: Option, + #[serde(default, skip_serializing_if = "Option::is_none")] + pub pane_id: Option, } #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] diff --git a/crates/freshell-protocol/src/settings.rs b/crates/freshell-protocol/src/settings.rs index 37bdc3605..cf9ac6ae2 100644 --- a/crates/freshell-protocol/src/settings.rs +++ b/crates/freshell-protocol/src/settings.rs @@ -151,3 +151,13 @@ pub struct ServerSettings { #[serde(skip_serializing_if = "Option::is_none")] pub default_cwd: Option, } + +impl ServerSettings { + /// Whether coding agents get the Freshell MCP server + /// (`settings.codingCli.mcpServer`, the Settings page's "Agent MCP server"). + /// The one gate every lane reads: freshclaude/kilroy sidecars today, the + /// terminal CLIs next (GitHub #446). + pub fn agent_mcp_server_enabled(&self) -> bool { + self.coding_cli.mcp_server + } +} diff --git a/crates/freshell-protocol/tests/roundtrip.rs b/crates/freshell-protocol/tests/roundtrip.rs index 0577a1381..b1f957380 100644 --- a/crates/freshell-protocol/tests/roundtrip.rs +++ b/crates/freshell-protocol/tests/roundtrip.rs @@ -880,3 +880,41 @@ fn session_name_updated_broadcast_roundtrips_and_conforms() { other => panic!("expected SessionNameUpdated, got {other:?}"), } } + +/// G19 (api-hosted-ops plan §2.4): a fresh-agent create and attach name the pane +/// they run for (`tabId`, `paneId`), so the agent's Freshell MCP acts for that +/// pane. Both are additive optionals: present ones round-trip, absent ones stay +/// absent on re-serialize, and frames from older clients still parse. +#[test] +fn fresh_agent_create_and_attach_carry_the_pane_they_run_for() { + let wire = r#"{"type":"freshAgent.create","requestId":"r1","sessionType":"freshclaude","provider":"claude","tabId":"tab-1","paneId":"pane-1"}"#; + match client_roundtrip(wire, "freshAgent.create") { + ClientMessage::FreshAgentCreate(m) => { + assert_eq!(m.tab_id.as_deref(), Some("tab-1")); + assert_eq!(m.pane_id.as_deref(), Some("pane-1")); + } + other => panic!("expected FreshAgentCreate, got {other:?}"), + } + let wire = r#"{"type":"freshAgent.create","requestId":"r2","sessionType":"kilroy","provider":"claude"}"#; + match client_roundtrip(wire, "freshAgent.create") { + ClientMessage::FreshAgentCreate(m) => assert_eq!(m.pane_id, None), + other => panic!("expected FreshAgentCreate, got {other:?}"), + } + + let wire = r#"{"type":"freshAgent.attach","sessionId":"s1","sessionType":"freshclaude","provider":"claude","tabId":"tab-1","paneId":"pane-1"}"#; + match client_roundtrip(wire, "freshAgent.attach") { + ClientMessage::FreshAgentAttach(m) => { + assert_eq!(m.tab_id.as_deref(), Some("tab-1")); + assert_eq!(m.pane_id.as_deref(), Some("pane-1")); + } + other => panic!("expected FreshAgentAttach, got {other:?}"), + } + let wire = r#"{"type":"freshAgent.attach","sessionId":"s1","sessionType":"freshcodex","provider":"codex"}"#; + match client_roundtrip(wire, "freshAgent.attach") { + ClientMessage::FreshAgentAttach(m) => { + assert_eq!(m.tab_id, None); + assert_eq!(m.pane_id, None); + } + other => panic!("expected FreshAgentAttach, got {other:?}"), + } +} diff --git a/crates/freshell-server/src/claude_freshell_mcp.rs b/crates/freshell-server/src/claude_freshell_mcp.rs new file mode 100644 index 000000000..ee026526c --- /dev/null +++ b/crates/freshell-server/src/claude_freshell_mcp.rs @@ -0,0 +1,178 @@ +//! The default server's Freshell MCP wiring for freshclaude and kilroy panes +//! (G19, api-hosted-ops plan §2.4): the recipe provider the claude lane asks at +//! every sidecar launch. +//! +//! It reads `settings.codingCli.mcpServer` from the live settings tree each +//! time, so a change from the Settings page applies to the next pane without a +//! restart, and resolves the same Freshell MCP server command the terminal +//! lanes inject (`build_mcp_server_command`, rendered for this host's own CLI). +//! The session host never installs it: a hosted lane keeps its fixed recipe. + +use std::sync::Arc; + +use freshell_freshagent::claude_freshell_mcp::{ + ClaudeFreshellMcp, ClaudeFreshellMcpProvider, ClaudeFreshellMcpResolution, +}; +use freshell_platform::cli_launch::ProviderTarget; +use freshell_platform::mcp_inject::{build_mcp_server_command, McpRuntime}; +use freshell_protocol::settings::ServerSettings; +use tokio::sync::RwLock; + +/// The CLI a freshclaude sidecar starts runs on this host, natively. +pub(crate) fn host_native_target() -> ProviderTarget { + if cfg!(windows) { + ProviderTarget::Windows + } else { + ProviderTarget::Unix + } +} + +/// The provider: `Disabled` while the setting is off, otherwise the resolved +/// command, or `Unavailable` with the reason it could not be resolved. +pub(crate) fn claude_mcp_provider( + settings: Arc>, + runtime: Arc, + target: ProviderTarget, +) -> ClaudeFreshellMcpProvider { + Arc::new(move || { + let settings = Arc::clone(&settings); + let runtime = Arc::clone(&runtime); + Box::pin(async move { + if !settings.read().await.agent_mcp_server_enabled() { + return ClaudeFreshellMcpResolution::Disabled; + } + // Resolving can run a short path-conversion helper (WSL); keep it + // off the async workers. + let resolved = tokio::task::spawn_blocking(move || { + build_mcp_server_command(runtime.as_ref(), target) + }) + .await; + match resolved { + Ok(Ok((command, args))) => { + ClaudeFreshellMcpResolution::Resolved(ClaudeFreshellMcp { command, args }) + } + Ok(Err(error)) => ClaudeFreshellMcpResolution::Unavailable(error.to_string()), + Err(join) => ClaudeFreshellMcpResolution::Unavailable(join.to_string()), + } + }) + }) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::settings_store::{router, SettingsRouterState, SettingsStore}; + use axum::body::Body; + use axum::http::{Request, StatusCode}; + use freshell_platform::mcp_inject::{McpInjectError, McpServerArg}; + use serde_json::json; + use std::path::PathBuf; + use tower::ServiceExt; + + /// An MCP runtime whose server command is a fixed path (or fails). + struct FixedRuntime(Option<&'static str>); + + impl McpRuntime for FixedRuntime { + fn tmp_dir(&self) -> PathBuf { + std::env::temp_dir() + } + fn is_wsl_environment(&self) -> bool { + false + } + fn convert_to_windows_path(&self, path: &str) -> String { + path.to_string() + } + fn server_command_args(&self) -> Result, McpInjectError> { + match self.0 { + Some(entry) => Ok(vec![McpServerArg::Path(entry.to_string())]), + None => Err(McpInjectError { + message: "Unable to resolve MCP dependency \"tsx\".".into(), + }), + } + } + } + + /// The real settings route over a real store in a throwaway home. + fn settings_route(home: &std::path::Path) -> (axum::Router, SettingsStore) { + std::fs::create_dir_all(home.join(".freshell")).unwrap(); + let store = SettingsStore::load(Some(home), vec!["claude".into()]); + let auth_token = Arc::new("tok".to_string()); + let broadcast_tx = Arc::new(tokio::sync::broadcast::channel::(16).0); + let state = SettingsRouterState { + store: store.clone(), + auth_token: Arc::clone(&auth_token), + broadcast_tx: Arc::clone(&broadcast_tx), + fresh_codex: freshell_freshagent::FreshCodexState::new( + auth_token, + broadcast_tx, + json!({}), + ), + registry: freshell_terminal::TerminalRegistry::new(), + ai_key: crate::ai_title::AiKeyCell::default(), + }; + (router(state), store) + } + + async fn patch(route: &axum::Router, body: serde_json::Value) { + let response = route + .clone() + .oneshot( + Request::builder() + .method("PATCH") + .uri("/api/settings") + .header("x-auth-token", "tok") + .header("content-type", "application/json") + .body(Body::from(body.to_string())) + .unwrap(), + ) + .await + .unwrap(); + assert_eq!(response.status(), StatusCode::OK); + } + + fn resolved(entry: &str) -> ClaudeFreshellMcpResolution { + ClaudeFreshellMcpResolution::Resolved(ClaudeFreshellMcp { + command: "node".into(), + args: vec![entry.into()], + }) + } + + /// The toggle on the Settings page reaches the next sidecar launch with no + /// restart: the provider reads the live settings tree the route updates. + #[tokio::test] + async fn the_settings_toggle_turns_the_recipe_off_and_on_without_a_restart() { + let home = tempfile::tempdir().unwrap(); + let (route, store) = settings_route(home.path()); + let provider = claude_mcp_provider( + store.shared_settings_lock(), + Arc::new(FixedRuntime(Some("/srv/freshell-mcp/server.js"))), + ProviderTarget::Unix, + ); + + // On by default. + assert_eq!(provider().await, resolved("/srv/freshell-mcp/server.js")); + patch(&route, json!({ "codingCli": { "mcpServer": false } })).await; + assert_eq!(provider().await, ClaudeFreshellMcpResolution::Disabled); + patch(&route, json!({ "codingCli": { "mcpServer": true } })).await; + assert_eq!(provider().await, resolved("/srv/freshell-mcp/server.js")); + } + + /// A command that cannot be resolved is reported with its reason, so the + /// lane logs it and starts the pane without the server. + #[tokio::test] + async fn an_unresolvable_command_is_unavailable_with_its_reason() { + let home = tempfile::tempdir().unwrap(); + let (_, store) = settings_route(home.path()); + let provider = claude_mcp_provider( + store.shared_settings_lock(), + Arc::new(FixedRuntime(None)), + ProviderTarget::Unix, + ); + match provider().await { + ClaudeFreshellMcpResolution::Unavailable(reason) => { + assert!(reason.contains("tsx"), "{reason}") + } + other => panic!("expected Unavailable, got {other:?}"), + } + } +} diff --git a/crates/freshell-server/src/fresh_agent_proxy_rest.rs b/crates/freshell-server/src/fresh_agent_proxy_rest.rs index 25d6334dd..01368a2ab 100644 --- a/crates/freshell-server/src/fresh_agent_proxy_rest.rs +++ b/crates/freshell-server/src/fresh_agent_proxy_rest.rs @@ -129,6 +129,7 @@ impl HostedFreshAgentRestGateway for HostedFreshAgentProxy { rest_agent_identity(&request.provider, &request.session_type)?; let runtime_provider = fresh_provider(&Some(provider), session_type).ok_or(())?; let message = freshell_protocol::FreshAgentCreate { + pane_id: None, request_id: request.request_id, session_type, cwd: request.cwd, diff --git a/crates/freshell-server/src/main.rs b/crates/freshell-server/src/main.rs index 23ecef2ed..c084bafbf 100644 --- a/crates/freshell-server/src/main.rs +++ b/crates/freshell-server/src/main.rs @@ -23,6 +23,7 @@ mod auto_title; mod auto_title_sweep; mod boot; mod checkpoints; +mod claude_freshell_mcp; mod diag; mod existence; mod existence_by_id; @@ -1058,6 +1059,22 @@ async fn main() -> ExitCode { fresh_claude_state .set_checkpoint_event_persister(Arc::clone(&checkpoint_event_persister)) .expect("Claude checkpoint event persister is installed once"); + // G19: freshclaude/kilroy panes get the Freshell MCP server the way + // terminal panes do. Every sidecar's CLI reaches THIS server (its own port + // and token, never values inherited from the environment it was started + // in), and the recipe is resolved at each launch from the live + // `codingCli.mcpServer` setting, so the toggle needs no restart. + fresh_claude_state.set_freshell_endpoint( + freshell_freshagent::claude_freshell_mcp::FreshellEndpoint::local( + port, + auth_token.as_str(), + ), + ); + fresh_claude_state.set_freshell_mcp_provider(claude_freshell_mcp::claude_mcp_provider( + settings_store.shared_settings_lock(), + Arc::new(freshell_platform::mcp_inject::RealMcpRuntime), + claude_freshell_mcp::host_native_target(), + )); // Task 12 (D8 for fresh agents): the ONE server-wide per-sessionRef create/resume // lease map, shared by every fresh-agent runtime (keys are provider-namespaced). diff --git a/crates/freshell-server/src/managed_provider_sdk_probe.mjs b/crates/freshell-server/src/managed_provider_sdk_probe.mjs index af7d343f6..222ff2cdb 100644 --- a/crates/freshell-server/src/managed_provider_sdk_probe.mjs +++ b/crates/freshell-server/src/managed_provider_sdk_probe.mjs @@ -1,7 +1,7 @@ import { readFileSync, existsSync } from 'node:fs' import { spawnSync } from 'node:child_process' import { query } from '/opt/freshell-claude-sidecar/node_modules/@anthropic-ai/claude-agent-sdk/sdk.mjs' -import { hostedMcpServers } from './hosted-mcp.mjs' +import { mcpServersFor } from './mcp-servers.mjs' import { managedSdkSpawn } from './exec-profile.mjs' const abort = new AbortController() @@ -45,14 +45,14 @@ try { const launch = managedSdkSpawn(() => {}) sdkQuery = query({ prompt, options: { cwd: process.cwd(), env, abortController: abort, - settingSources: ['user', 'project', 'local'], mcpServers: hostedMcpServers(true, env), + settingSources: ['user', 'project', 'local'], mcpServers: mcpServersFor({ hostedMcp: true }, env), spawnClaudeCodeProcess: options => { receipt.defaultChild = options.command.includes('@anthropic-ai/claude-agent-sdk-linux-x64/') && options.command.endsWith('/claude') const version = spawnSync(options.command, ['--version'], { env: options.env, encoding: 'utf8', timeout: 10_000 }) receipt.nativeVersion = /^2\.1\.237(?:\s|$)/.test(version.stdout ?? '') ? '2.1.237' : 'unexpected' const index = options.args.indexOf('--mcp-config') const config = index < 0 ? {} : JSON.parse(options.args[index + 1]) - receipt.fixedRecipe = JSON.stringify(config) === JSON.stringify({ mcpServers: hostedMcpServers(true, env) }) + receipt.fixedRecipe = JSON.stringify(config) === JSON.stringify({ mcpServers: mcpServersFor({ hostedMcp: true }, env) }) && !options.args.some(arg => arg.includes(token)) nativeChild = launch(options) const write = nativeChild.stdin.write diff --git a/crates/freshell-server/src/managed_provider_sdk_tests.rs b/crates/freshell-server/src/managed_provider_sdk_tests.rs index c9d6d6d74..23ecf61cf 100644 --- a/crates/freshell-server/src/managed_provider_sdk_tests.rs +++ b/crates/freshell-server/src/managed_provider_sdk_tests.rs @@ -11,8 +11,8 @@ async fn managed_provider_claude_default_sdk_initializes_fixed_mcp_without_a_pro .unwrap(); for (name, source) in [ ( - "hosted-mcp.mjs", - include_str!("../../freshell-claude-sidecar/hosted-mcp.mjs"), + "mcp-servers.mjs", + include_str!("../../freshell-claude-sidecar/mcp-servers.mjs"), ), ( "exec-profile.mjs", diff --git a/crates/freshell-session-host/src/providers/fresh_agent.rs b/crates/freshell-session-host/src/providers/fresh_agent.rs index fee32b87f..66bcfb67b 100644 --- a/crates/freshell-session-host/src/providers/fresh_agent.rs +++ b/crates/freshell-session-host/src/providers/fresh_agent.rs @@ -1099,6 +1099,7 @@ fn create_request_for_profile(profile: &FreshAgentProfile) -> FreshAgentCreate { ), }; FreshAgentCreate { + pane_id: None, request_id: format!("host-create-{}", uuid::Uuid::new_v4()), observed_epoch: None, observed_generation: None, diff --git a/crates/freshell-ws/src/terminal.rs b/crates/freshell-ws/src/terminal.rs index c67ec8791..186694eee 100644 --- a/crates/freshell-ws/src/terminal.rs +++ b/crates/freshell-ws/src/terminal.rs @@ -8790,50 +8790,12 @@ fn build_terminal_base_env( tab_id: Option<&str>, pane_id: Option<&str>, ) -> BTreeMap { - let mut out = BTreeMap::new(); - out.insert("FRESHELL".to_string(), "1".to_string()); - // `const port = Number(process.env.PORT || 3001)` (truthy: '' → 3001). - let port_raw = env - .get("PORT") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| "3001".to_string()); - let url = env - .get("FRESHELL_URL") - .filter(|v| !v.is_empty()) - .unwrap_or_else(|| format!("http://localhost:{}", js_number_string(&port_raw))); - out.insert("FRESHELL_URL".to_string(), url); - out.insert( - "FRESHELL_TOKEN".to_string(), - env.get("AUTH_TOKEN").unwrap_or_default(), - ); - out.insert("FRESHELL_TERMINAL_ID".to_string(), terminal_id.to_string()); - if let Some(t) = tab_id.filter(|s| !s.is_empty()) { - out.insert("FRESHELL_TAB_ID".to_string(), t.to_string()); - } - if let Some(p) = pane_id.filter(|s| !s.is_empty()) { - out.insert("FRESHELL_PANE_ID".to_string(), p.to_string()); - } - out -} - -/// JS `String(Number(s))` for the `PORT` template slot: every real deployment is -/// a plain integer; whitespace-only → `0`, unparseable → `NaN` (faithful to the -/// reference's `Number(...)` coercion in the template literal). -fn js_number_string(s: &str) -> String { - let t = s.trim(); - if t.is_empty() { - return "0".to_string(); - } - match t.parse::() { - Ok(n) if n.is_finite() => { - if n.fract() == 0.0 && n.abs() < 1e15 { - format!("{}", n as i64) - } else { - format!("{n}") - } - } - _ => "NaN".to_string(), - } + freshell_platform::freshell_context_env::freshell_context_env( + &freshell_platform::freshell_context_env::FreshellEndpoint::from_env(env), + Some(terminal_id), + tab_id, + pane_id, + ) } /// `wrapTerminalSpawnError` (`terminal-registry.ts:450-481`): the user-facing @@ -11585,14 +11547,6 @@ mod cli_create_helper_tests { assert_eq!(out2.get("FRESHELL_URL").unwrap(), "http://example:9"); } - #[test] - fn js_number_string_coercion() { - assert_eq!(js_number_string("17872"), "17872"); - assert_eq!(js_number_string(" 17872 "), "17872"); - assert_eq!(js_number_string("abc"), "NaN"); - assert_eq!(js_number_string(" "), "0"); - } - /// DEV-0006 S5.e: managed codex launch defaults ON; only the exact string /// "0" opts out. Mode scoping is unchanged: non-codex modes never plan. #[test] diff --git a/crates/freshell-ws/tests/common/claude_rig.rs b/crates/freshell-ws/tests/common/claude_rig.rs index c22847909..9be80ecb7 100644 --- a/crates/freshell-ws/tests/common/claude_rig.rs +++ b/crates/freshell-ws/tests/common/claude_rig.rs @@ -78,8 +78,17 @@ pub struct RigOptions { /// The session handoff route (`POST /api/sessions/handoff`) on the rig's /// server, driven by a runner over the same lanes (main.rs's wiring). pub handoff: bool, + /// The ordinary Freshell MCP wiring main.rs installs (G19): the claude lane + /// gets the rig server's own endpoint (its bound loopback port and + /// [`AUTH_TOKEN`]) and a recipe provider answering what this cell holds at + /// each sidecar launch (a test changes it to flip the setting). + pub freshell_mcp: Option, } +/// What the rig's Freshell MCP recipe provider answers, changeable mid-test. +pub type RigFreshellMcp = + Arc>; + pub struct ClaudeRig { pub kind: RigKind, /// `ws://127.0.0.1:/ws` of the rig's server (an ephemeral loopback port). @@ -530,6 +539,94 @@ impl ClaudeRig { }); RuntimeProcesses { pids, probes } } + + /// The options of the `query()` calls the simulator recorded, in order. + pub fn query_options(&self) -> Vec { + self.ledger_rows("query") + .into_iter() + .map(|row| row["options"].clone()) + .collect() + } + + /// The first recorded `query()` options matching `predicate`, once recorded. + pub async fn wait_for_query(&self, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + loop { + if let Some(options) = self.query_options().into_iter().find(|o| predicate(o)) { + return options; + } + assert!( + tokio::time::Instant::now() < deadline, + "no query() call for {what}; recorded: {:?}", + self.query_options() + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + + /// One finished turn per prompt (each `freshAgent.result`), each on disk + /// before the next: the CLI flushes a transcript line 95-200 ms after its + /// SDK frame (T3), and resumes, undo and fork read the transcript. Returns + /// the durable id the first turn reported. + pub async fn converse( + &self, + conn: &mut RigConnection, + session_id: &str, + prompts: &[&str], + ) -> String { + for (n, prompt) in prompts.iter().enumerate() { + let results_before = conn.events_seen("freshAgent.result"); + conn.send_text(session_id, &format!("send-{n}"), prompt) + .await; + let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; + while conn.events_seen("freshAgent.result") == results_before { + assert!( + tokio::time::Instant::now() < deadline, + "turn {n} never finished" + ); + conn.read_for(Duration::from_millis(50)).await; + } + let path = self + .wait_for_transcript(&conn.init_session_id(session_id)) + .await; + loop { + let lines: Vec = std::fs::read_to_string(&path) + .unwrap_or_default() + .lines() + .filter_map(|line| serde_json::from_str(line).ok()) + .collect(); + let at = lines.iter().rposition(|line| { + line["type"] == "user" && line["message"]["content"][0]["text"] == *prompt + }); + if at.is_some_and(|at| lines[at..].iter().any(|line| line["type"] == "assistant")) { + break; + } + assert!( + tokio::time::Instant::now() < deadline, + "the turn for {prompt:?} never reached disk" + ); + tokio::time::sleep(Duration::from_millis(20)).await; + } + } + conn.init_session_id(session_id) + } + + /// The first sidecar this rig booted dies on its own (SIGKILL of the Node + /// process): its session is untracked, the state a reload or a handoff + /// resumes from. Returns once `conn` has read the exit. + pub async fn kill_first_sidecar(&self, conn: &mut RigConnection) { + let pid = self.ledger_rows("boot")[0]["pid"] + .as_i64() + .expect("the boot row names the sidecar's pid") as i32; + // SAFETY: a plain signal to the sidecar this rig spawned. + assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); + conn.wait_for("the sidecar's exit", |v| { + v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["code"] == "SIDECAR_EXITED" + }) + .await; + } } /// A runtime's sidecar and simulated CLI, observed since they ran @@ -1004,6 +1101,21 @@ async fn spawn_rig_server( .await .expect("bind an ephemeral loopback port"); let addr = listener.local_addr().expect("local addr"); + if let Some(resolution) = options.freshell_mcp.clone() { + // main.rs wires the same two pieces from its bound port and token. + assert!(lane.set_freshell_endpoint( + freshell_freshagent::claude_freshell_mcp::FreshellEndpoint { + url: format!("http://{addr}"), + token: AUTH_TOKEN.to_string(), + } + )); + let provider: freshell_freshagent::claude_freshell_mcp::ClaudeFreshellMcpProvider = + Arc::new(move || { + let answer = resolution.lock().expect("rig mcp resolution").clone(); + Box::pin(async move { answer }) + }); + assert!(lane.set_freshell_mcp_provider(provider)); + } let server = tokio::spawn(async move { let _ = axum::serve(listener, router).await; }); diff --git a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs index 2863afb0a..ecd0fdf86 100644 --- a/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs +++ b/crates/freshell-ws/tests/freshagent_claude_kill_after_restart.rs @@ -139,6 +139,7 @@ fn new_generation( fn create_msg_resume(request_id: &str, durable: &str) -> FreshAgentCreate { FreshAgentCreate { + pane_id: None, naming_handle: None, observed_epoch: None, observed_generation: None, @@ -164,6 +165,8 @@ fn create_msg_resume(request_id: &str, durable: &str) -> FreshAgentCreate { fn attach_msg(seat: &str, durable: &str) -> FreshAgentAttach { FreshAgentAttach { + tab_id: None, + pane_id: None, observed_epoch: None, observed_generation: None, provider: AgentProvider::Claude, diff --git a/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs index 230b0ab1c..67bfe50cf 100644 --- a/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs +++ b/crates/freshell-ws/tests/freshagent_claude_launch_parity.rs @@ -17,7 +17,7 @@ mod common; use serde_json::{json, Value}; -use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions, RIG_FRAME_BUDGET}; +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions}; async fn hosted_rig() -> (ClaudeRig, String) { let rig = ClaudeRig::start_with_options( @@ -48,80 +48,6 @@ async fn create_with_plugin(conn: &mut RigConnection, plugin: &str) -> String { .to_string() } -/// One finished turn per prompt (each `freshAgent.result`), so the conversation is -/// on disk; returns the durable id the first turn reported. -async fn converse( - rig: &ClaudeRig, - conn: &mut RigConnection, - session_id: &str, - prompts: &[&str], -) -> String { - for (n, prompt) in prompts.iter().enumerate() { - let results_before = conn.events_seen("freshAgent.result"); - conn.send_text(session_id, &format!("send-{n}"), prompt) - .await; - let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; - while conn.events_seen("freshAgent.result") == results_before { - assert!( - tokio::time::Instant::now() < deadline, - "turn {n} never finished" - ); - conn.read_for(std::time::Duration::from_millis(50)).await; - } - } - rig.wait_for_transcripts().await; - conn.frames - .iter() - .find_map(|v| { - (v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.session.init" - && v["event"]["sessionId"] == session_id) - .then(|| v["event"]["cliSessionId"].as_str().map(str::to_string)) - .flatten() - }) - .expect("the first turn reported the durable id") -} - -/// The session's sidecar dies on its own (SIGKILL of the Node process): the session -/// is untracked, the state a reload or a handoff resumes from. -async fn kill_the_sidecar(rig: &ClaudeRig, conn: &mut RigConnection) { - let pid = rig.ledger_rows("boot")[0]["pid"] - .as_i64() - .expect("the boot row names the sidecar's pid") as i32; - // SAFETY: a plain signal to the sidecar this rig spawned. - assert_eq!(unsafe { libc::kill(pid, libc::SIGKILL) }, 0); - conn.wait_for("the sidecar's exit", |v| { - v["type"] == "freshAgent.event" - && v["event"]["type"] == "freshAgent.error" - && v["event"]["code"] == "SIDECAR_EXITED" - }) - .await; -} - -/// The options of the `query()` calls the simulator recorded, in order. -fn query_options(rig: &ClaudeRig) -> Vec { - rig.ledger_rows("query") - .into_iter() - .map(|row| row["options"].clone()) - .collect() -} - -/// The first recorded `query()` options matching `predicate`, once recorded. -async fn wait_for_query(rig: &ClaudeRig, what: &str, predicate: impl Fn(&Value) -> bool) -> Value { - let deadline = tokio::time::Instant::now() + RIG_FRAME_BUDGET; - loop { - if let Some(options) = query_options(rig).into_iter().find(|o| predicate(o)) { - return options; - } - assert!( - tokio::time::Instant::now() < deadline, - "no query() call for {what}; recorded: {:?}", - query_options(rig) - ); - tokio::time::sleep(std::time::Duration::from_millis(20)).await; - } -} - fn assert_carries_plugin_and_hosted_mcp(options: &Value, plugin: &str, launch: &str) { assert_eq!( options["plugins"], @@ -140,7 +66,7 @@ async fn the_create_carries_the_panes_plugins_and_the_hosted_mcp() { let mut conn = rig.connect().await; create_with_plugin(&mut conn, &plugin).await; - let options = query_options(&rig); + let options = rig.query_options(); assert_eq!(options.len(), 1, "one sidecar booted: {options:?}"); assert_carries_plugin_and_hosted_mcp(&options[0], &plugin, "create"); } @@ -150,8 +76,10 @@ async fn an_attach_resume_carries_the_bound_plugins_and_the_hosted_mcp() { let (rig, plugin) = hosted_rig().await; let mut conn = rig.connect().await; let session_id = create_with_plugin(&mut conn, &plugin).await; - let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; - kill_the_sidecar(&rig, &mut conn).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + rig.kill_first_sidecar(&mut conn).await; // The pane attaches again (a reload): the lane resumes the session. conn.send(json!({ @@ -167,7 +95,9 @@ async fn an_attach_resume_carries_the_bound_plugins_and_the_hosted_mcp() { }) .await; - let resumed = wait_for_query(&rig, "the attach-resume", |o| o["resume"] == durable).await; + let resumed = rig + .wait_for_query("the attach-resume", |o| o["resume"] == durable) + .await; assert_carries_plugin_and_hosted_mcp(&resumed, &plugin, "attach-resume"); } @@ -176,8 +106,10 @@ async fn a_handoff_resume_carries_the_bound_plugins_and_the_hosted_mcp() { let (rig, plugin) = hosted_rig().await; let mut conn = rig.connect().await; let session_id = create_with_plugin(&mut conn, &plugin).await; - let durable = converse(&rig, &mut conn, &session_id, &["first prompt"]).await; - kill_the_sidecar(&rig, &mut conn).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + rig.kill_first_sidecar(&mut conn).await; let (status, answer) = rig .post_json( @@ -193,7 +125,9 @@ async fn a_handoff_resume_carries_the_bound_plugins_and_the_hosted_mcp() { .await; assert_eq!(status, 200, "the handoff resumes the session: {answer}"); - let resumed = wait_for_query(&rig, "the handoff resume", |o| o["resume"] == durable).await; + let resumed = rig + .wait_for_query("the handoff resume", |o| o["resume"] == durable) + .await; assert_carries_plugin_and_hosted_mcp(&resumed, &plugin, "handoff resume"); } @@ -202,13 +136,9 @@ async fn a_rollback_respawn_carries_the_bound_plugins_and_the_hosted_mcp() { let (rig, plugin) = hosted_rig().await; let mut conn = rig.connect().await; let session_id = create_with_plugin(&mut conn, &plugin).await; - let durable = converse( - &rig, - &mut conn, - &session_id, - &["first prompt", "second prompt"], - ) - .await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt", "second prompt"]) + .await; // The client sends the owner pair of the latest `session.runtimeOwner` frame // for the durable id with every rollback. @@ -239,9 +169,10 @@ async fn a_rollback_respawn_carries_the_bound_plugins_and_the_hosted_mcp() { }) .await; assert_eq!(ack["event"]["type"], "freshAgent.rolledBack", "{ack}"); - let fork = wait_for_query(&rig, "the undo's fork", |o| { - o["forkSession"] == true && o["resume"] == durable - }) - .await; + let fork = rig + .wait_for_query("the undo's fork", |o| { + o["forkSession"] == true && o["resume"] == durable + }) + .await; assert_carries_plugin_and_hosted_mcp(&fork, &plugin, "rollback respawn"); } diff --git a/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs new file mode 100644 index 000000000..7b3e98bc5 --- /dev/null +++ b/crates/freshell-ws/tests/freshagent_claude_mcp_launch.rs @@ -0,0 +1,364 @@ +//! G19 (api-hosted-ops plan §2.4): a freshclaude or kilroy pane's CLI gets the +//! ordinary Freshell MCP server, the way a terminal pane's CLI does. +//! +//! Every sidecar launch (create, attach-resume, handoff resume, undo/redo +//! respawn, fork) asks the lane's recipe provider, so the setting +//! (`settings.codingCli.mcpServer`) applies to the next launch without a +//! restart. The recipe travels in the `create` line as a command and its +//! arguments only; the endpoint and token are in the sidecar's environment, +//! which the server sets from its own bound port and token; the pane the +//! session runs for travels as `freshellContext` and lands in the CLI's +//! environment, from which the CLI hands all of it to the MCP child. +//! +//! Each test runs the real sidecar over the CLI simulator (RealSidecarSim), +//! whose ledger records the options of every `query()` call, including the +//! Freshell context values of the CLI environment and a digest of the token. + +mod common; + +use std::sync::Arc; + +use serde_json::{json, Value}; +use sha2::{Digest, Sha256}; + +use common::claude_rig::{ClaudeRig, RigConnection, RigKind, RigOptions}; +use common::AUTH_TOKEN; +use freshell_freshagent::claude_freshell_mcp::{ClaudeFreshellMcp, ClaudeFreshellMcpResolution}; + +/// The recipe the rig's provider resolves (the simulator records it as given). +fn recipe() -> ClaudeFreshellMcp { + ClaudeFreshellMcp { + command: "node".into(), + args: vec!["/srv/freshell/dist/tools/freshell-mcp/server.js".into()], + } +} + +fn resolved() -> ClaudeFreshellMcpResolution { + ClaudeFreshellMcpResolution::Resolved(recipe()) +} + +/// A RealSidecarSim rig wired the way main.rs wires the default server, with +/// a durable ledger (resumes read the session back) and the handoff route. +async fn rig_with( + resolution: ClaudeFreshellMcpResolution, + hosted: bool, +) -> ( + ClaudeRig, + Arc>, +) { + let cell = Arc::new(std::sync::Mutex::new(resolution)); + let rig = ClaudeRig::start_with_options( + RigKind::Sim, + RigOptions { + durable_ledger: true, + handoff: true, + hosted_claude: hosted, + env: vec![("KILROY_ENABLED".into(), "1".into())], + freshell_mcp: Some(Arc::clone(&cell)), + }, + ) + .await; + (rig, cell) +} + +/// The simulator's digest of a credential value (`query.mjs`). +fn digest(value: &str) -> String { + format!("{:x}", Sha256::digest(value.as_bytes()))[..12].to_string() +} + +/// The launch handed the CLI the ordinary recipe (command and arguments, no +/// environment) and an environment that reaches the rig's server for `pane`. +fn assert_mcp_for_pane(rig: &ClaudeRig, options: &Value, pane: Option<(&str, &str)>, launch: &str) { + assert_eq!( + options["mcpServers"], + json!({ "freshell": { "command": "node", "args": ["/srv/freshell/dist/tools/freshell-mcp/server.js"] } }), + "the {launch} hands the CLI the Freshell MCP recipe and nothing else: {options}" + ); + let mut context = json!({ "FRESHELL": "1", "FRESHELL_URL": rig.http_url }); + if let Some((tab, pane)) = pane { + context["FRESHELL_TAB_ID"] = json!(tab); + context["FRESHELL_PANE_ID"] = json!(pane); + } + assert_eq!( + options["envContext"], context, + "the {launch}'s CLI reaches this server for its pane: {options}" + ); + assert_eq!( + options["envDigests"]["FRESHELL_TOKEN"], + json!(digest(AUTH_TOKEN)), + "the {launch}'s CLI carries this server's token: {options}" + ); +} + +/// Create a session for `pane` (`tabId`, `paneId`); returns the session id. +async fn create_for_pane( + conn: &mut RigConnection, + request_id: &str, + session_type: &str, + pane: (&str, &str), +) -> String { + let created = conn + .request_create( + request_id, + json!({ "sessionType": session_type, "tabId": pane.0, "paneId": pane.1 }), + ) + .await; + assert_eq!(created["type"], "freshAgent.created", "{created}"); + created["sessionId"] + .as_str() + .expect("a session id") + .to_string() +} + +/// The owner pair (epoch, generation) of the session's runtime owner record, +/// once the server has broadcast it (it can trail the first turn's result). +async fn owner_pair(conn: &mut RigConnection, durable: &str) -> (u64, u64) { + conn.wait_for("the session's owner record", |v| { + v["type"] == "session.runtimeOwner" + && v["provider"] == "claude" + && v["sessionId"] == durable + }) + .await; + conn.owner_pair(durable) + .expect("the session's owner record names its pair") +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_freshclaude_and_a_kilroy_create_hand_the_cli_the_freshell_mcp_for_their_pane() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + create_for_pane( + &mut conn, + "create-claude", + "freshclaude", + ("tab-1", "pane-1"), + ) + .await; + create_for_pane(&mut conn, "create-kilroy", "kilroy", ("tab-2", "pane-2")).await; + + let options = rig.query_options(); + assert_eq!(options.len(), 2, "two sidecars booted: {options:?}"); + assert_mcp_for_pane( + &rig, + &options[0], + Some(("tab-1", "pane-1")), + "freshclaude create", + ); + assert_mcp_for_pane( + &rig, + &options[1], + Some(("tab-2", "pane-2")), + "kilroy create", + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn the_setting_applies_to_the_next_launch_without_a_restart() { + let (rig, setting) = rig_with(ClaudeFreshellMcpResolution::Disabled, false).await; + let mut conn = rig.connect().await; + create_for_pane(&mut conn, "create-off", "freshclaude", ("tab-1", "pane-1")).await; + *setting.lock().unwrap() = resolved(); + create_for_pane(&mut conn, "create-on", "freshclaude", ("tab-1", "pane-2")).await; + *setting.lock().unwrap() = + ClaudeFreshellMcpResolution::Unavailable("no MCP server build".into()); + create_for_pane( + &mut conn, + "create-unresolved", + "freshclaude", + ("tab-1", "pane-3"), + ) + .await; + + let options = rig.query_options(); + assert_eq!(options.len(), 3, "three sidecars booted: {options:?}"); + assert!( + options[0].get("mcpServers").is_none(), + "with the setting off the CLI gets no MCP server: {}", + options[0] + ); + assert_mcp_for_pane( + &rig, + &options[1], + Some(("tab-1", "pane-2")), + "create after the setting turned on", + ); + assert!( + options[2].get("mcpServers").is_none(), + "a command that cannot be resolved starts the pane without it: {}", + options[2] + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_hosted_lane_keeps_its_fixed_recipe_and_never_gets_the_ordinary_one() { + let (rig, _) = rig_with(resolved(), true).await; + let mut conn = rig.connect().await; + create_for_pane( + &mut conn, + "create-hosted", + "freshclaude", + ("tab-1", "pane-1"), + ) + .await; + + let options = rig.query_options(); + assert_eq!( + options[0]["mcpServers"], + json!({ "freshell": { "command": "node", "args": ["/opt/freshell-mcp/server.js"] } }), + "{}", + options[0] + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_attach_resume_hands_the_cli_the_freshell_mcp_for_the_attaching_pane() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + let session_id = + create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + rig.kill_first_sidecar(&mut conn).await; + + // The pane, moved to another tab meanwhile, attaches again (a reload). + conn.send(json!({ + "type": "freshAgent.attach", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "sessionRef": { "provider": "claude", "sessionId": durable }, + "tabId": "tab-9", + "paneId": "pane-1", + })) + .await; + let resumed = rig + .wait_for_query("the attach-resume", |o| o["resume"] == durable) + .await; + assert_mcp_for_pane(&rig, &resumed, Some(("tab-9", "pane-1")), "attach-resume"); +} + +/// A terminal-to-agent switch or a stop-and-reopen posts the handoff for the +/// pane that adopts the session (the client always names it); the resumed CLI +/// runs for that pane. +async fn a_handoff_resume_runs_for_the_adopting_pane(session_type: &str) { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + let session_id = + create_for_pane(&mut conn, "create-1", session_type, ("tab-1", "pane-1")).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + rig.kill_first_sidecar(&mut conn).await; + + let (status, answer) = rig + .post_json( + "/api/sessions/handoff", + &json!({ + "provider": "claude", + "sessionId": durable, + "targetKind": "fresh-agent", + "sessionType": session_type, + "cwd": rig.workspace.to_str().expect("utf-8 path"), + "tabId": "tab-7", + "paneId": "pane-7", + }), + ) + .await; + assert_eq!(status, 200, "the handoff resumes the session: {answer}"); + let resumed = rig + .wait_for_query("the handoff resume", |o| o["resume"] == durable) + .await; + assert_mcp_for_pane( + &rig, + &resumed, + Some(("tab-7", "pane-7")), + &format!("{session_type} handoff resume"), + ); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_freshclaude_handoff_resume_runs_for_the_adopting_pane() { + a_handoff_resume_runs_for_the_adopting_pane("freshclaude").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_kilroy_handoff_resume_runs_for_the_adopting_pane() { + a_handoff_resume_runs_for_the_adopting_pane("kilroy").await; +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn an_undo_respawn_runs_for_the_same_pane() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + let session_id = + create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt", "second prompt"]) + .await; + + let (epoch, generation) = owner_pair(&mut conn, &durable).await; + conn.send(json!({ + "type": "freshAgent.undo", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "undo-1", + "mode": "step", + "observedEpoch": epoch, + "observedGeneration": generation, + })) + .await; + let ack = conn + .wait_for("the undo's ack", |v| { + v["type"] == "freshAgent.event" && v["event"]["requestId"] == "undo-1" + }) + .await; + assert_eq!(ack["event"]["type"], "freshAgent.rolledBack", "{ack}"); + let undone = rig + .wait_for_query("the undo's respawn", |o| { + o["forkSession"] == true && o["resume"] == durable + }) + .await; + assert_mcp_for_pane(&rig, &undone, Some(("tab-1", "pane-1")), "undo respawn"); +} + +#[tokio::test(flavor = "multi_thread", worker_threads = 2)] +async fn a_fork_child_runs_for_the_forking_pane() { + let (rig, _) = rig_with(resolved(), false).await; + let mut conn = rig.connect().await; + let session_id = + create_for_pane(&mut conn, "create-1", "freshclaude", ("tab-1", "pane-1")).await; + let durable = rig + .converse(&mut conn, &session_id, &["first prompt"]) + .await; + + let (epoch, generation) = owner_pair(&mut conn, &durable).await; + conn.send(json!({ + "type": "freshAgent.fork", + "provider": "claude", + "sessionType": "freshclaude", + "sessionId": session_id, + "requestId": "fork-1", + "tabId": "tab-1", + "observedEpoch": epoch, + "observedGeneration": generation, + })) + .await; + let forked = conn + .wait_for("the fork's answer", |v| { + (v["type"] == "freshAgent.forked" && v["requestId"] == "fork-1") + || (v["type"] == "freshAgent.event" + && v["event"]["type"] == "freshAgent.error" + && v["event"]["sessionId"] == session_id.as_str()) + }) + .await; + assert_eq!(forked["type"], "freshAgent.forked", "{forked}"); + let child = forked["sessionId"] + .as_str() + .expect("forked names the child"); + let options = rig + .wait_for_query("the fork's child", |o| o["sessionId"] == child) + .await; + assert_mcp_for_pane(&rig, &options, Some(("tab-1", "pane-1")), "fork child"); +} diff --git a/port/contract/ws-protocol.schema.json b/port/contract/ws-protocol.schema.json index 2b72ca5ad..e89f919f6 100644 --- a/port/contract/ws-protocol.schema.json +++ b/port/contract/ws-protocol.schema.json @@ -1906,6 +1906,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "permissionMode": { "type": "string" }, @@ -1997,6 +2001,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "provider": { "enum": [ "claude", @@ -2039,6 +2047,10 @@ ], "type": "string" }, + "tabId": { + "minLength": 1, + "type": "string" + }, "type": { "const": "freshAgent.attach", "type": "string" @@ -3267,6 +3279,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "provider": { "enum": [ "claude", @@ -3309,6 +3325,10 @@ ], "type": "string" }, + "tabId": { + "minLength": 1, + "type": "string" + }, "type": { "const": "freshAgent.attach", "type": "string" @@ -3489,6 +3509,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "permissionMode": { "type": "string" }, @@ -3580,6 +3604,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "provider": { "enum": [ "claude", @@ -3622,6 +3650,10 @@ ], "type": "string" }, + "tabId": { + "minLength": 1, + "type": "string" + }, "type": { "const": "freshAgent.attach", "type": "string" @@ -4532,6 +4564,10 @@ "minimum": 0, "type": "integer" }, + "paneId": { + "minLength": 1, + "type": "string" + }, "permissionMode": { "type": "string" }, diff --git a/shared/ws-protocol.ts b/shared/ws-protocol.ts index 48c2dcda8..9eb375a16 100644 --- a/shared/ws-protocol.ts +++ b/shared/ws-protocol.ts @@ -867,6 +867,10 @@ export const FreshAgentCreateSchema = z.object({ /** D8: the creating tab's client-side id; the server composes the ledger row's * `tabKey` as `deviceId:tabId`. Non-strict schema — tolerated by older servers. */ tabId: z.string().min(1).optional(), + /** G19: the creating pane's id. With `tabId` it is the pane context the + * agent's Freshell MCP acts for (FRESHELL_TAB_ID / FRESHELL_PANE_ID). + * Additive optional. */ + paneId: z.string().min(1).optional(), /** Unified agent names (Task 1): the pane's pre-durable naming handle — see * `terminal.create.namingHandle`. Additive optional. */ namingHandle: z.string().min(1).optional(), @@ -886,6 +890,10 @@ export const FreshAgentAttachSchema = z.object({ resumeSessionId: z.string().optional(), cwd: z.string().optional(), sessionRef: SessionLocatorSchema.optional(), + /** G19: the attaching pane. An attach that resumes the session hands the + * agent's Freshell MCP this pane context. Additive optional. */ + tabId: z.string().min(1).optional(), + paneId: z.string().min(1).optional(), /** kata b8ke delayed-request fence: attach can cold-resume an untracked * session (registering a runtime), so it carries the observed * (epoch, generation) pair. */ diff --git a/src/components/fresh-agent/FreshAgentView.tsx b/src/components/fresh-agent/FreshAgentView.tsx index a2639a112..9d57742eb 100644 --- a/src/components/fresh-agent/FreshAgentView.tsx +++ b/src/components/fresh-agent/FreshAgentView.tsx @@ -616,6 +616,7 @@ function captureAttachmentAttempt( function buildFreshAgentAttachMessage( content: FreshAgentPaneContent, + pane: { tabId: string, paneId: string }, cwd?: string, observedFence?: ObservedOwnerFence, ) { @@ -627,6 +628,10 @@ function buildFreshAgentAttachMessage( provider: content.provider, ...(sessionRef ? { sessionRef } : {}), ...(cwd ? { cwd } : {}), + // G19: an attach that resumes the session hands the agent's Freshell MCP + // this pane as its context. + tabId: pane.tabId, + paneId: pane.paneId, ...(observedFence ? { observedEpoch: observedFence.epoch, observedGeneration: observedFence.generation } : {}), @@ -1780,9 +1785,9 @@ export function FreshAgentView({ if (attachmentAttemptKey(state, content, attachDecisionSerialRef.current) !== attempt.key) return false if (isLifecycleStartSuperseded(state, 'fresh-agent', content, attempt.fence)) return false const cwd = getFreshOpenCodeRouteCwd(attempt.content, { sessionCwd: freshOpenCodeRouteCwdRef.current }) - sendFreshAgentMessage(buildFreshAgentAttachMessage(attempt.content, cwd, attempt.fence)) + sendFreshAgentMessage(buildFreshAgentAttachMessage(attempt.content, { tabId, paneId }, cwd, attempt.fence)) return true - }, [appStore, sendFreshAgentMessage]) + }, [appStore, paneId, sendFreshAgentMessage, tabId]) // kata b8ke: the opened-as-CLI-elsewhere attach action — THIS pane adopts // the committed terminal owner (keeping its sessionRef + createRequestId, @@ -2025,6 +2030,8 @@ export function FreshAgentView({ // D8 (restore-open-sessions-only): the server composes the ledger row's // tabKey as `deviceId:tabId` from the connection identity + this field. tabId, + // G19: with tabId, the pane the agent's Freshell MCP acts for. + paneId, ...(namingHandle ? { namingHandle } : {}), // kata b8ke delayed-request fence (round-2 review: the fence is the // (epoch, generation) PAIR from the runtime-owner record observed when @@ -2034,7 +2041,7 @@ export function FreshAgentView({ ? { observedEpoch: observedFence.epoch, observedGeneration: observedFence.generation } : {}), } as const - }, [providerDefaults, tabId]) + }, [paneId, providerDefaults, tabId]) const startNewConversation = useCallback(async () => { const current = paneContentRef.current diff --git a/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts b/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts new file mode 100644 index 000000000..1058d8a31 --- /dev/null +++ b/test/e2e-browser/specs/freshclaude-freshell-mcp-rust.spec.ts @@ -0,0 +1,200 @@ +// FRESHCLAUDE FRESHELL MCP -- api-hosted-ops plan §2.4 / §5.5 (audit G19): +// a freshclaude or kilroy pane's agent gets the ordinary Freshell MCP server, +// the way a terminal pane's CLI does, end to end through the real Rust +// server, the PRODUCTION Claude sidecar and the Claude CLI simulator +// (helpers/claude-sim-lane.ts). The simulator starts the stdio MCP command +// the Rust server resolved (dist/tools/freshell-mcp/server.js, built here by +// ensureMcpServerBuilt) with the environment the CLI got, the way the pinned +// CLI does (conformance scenario `mcp`), and calls its tools for the model. +// +// 1. the agent calls the Freshell MCP `list-tabs` and its reply lists the +// open tabs, its own among them: the command Rust resolved, the endpoint +// and the token in the sidecar's environment all work against the real +// server; +// 2. an untargeted `split-pane` splits the agent's own pane, even while the +// user is looking at another tab (the pane context the CLI got); +// 3. with Settings → Advanced → "Agent MCP server" turned off, a new pane's +// agent has no Freshell MCP server: the same call is "No such tool". +// +// CLOUD-LEGALITY: no CLI binary and no network (the simulator stands in for +// the CLI; the MCP server talks only to this test's own loopback server); +// every wait polls observed state. This spec must NOT be added to +// CLOUD_SKIP_SPECS. + +import { test, expect } from '../helpers/fixtures.js' +import { ClaudeSimLane, onPrompt, type ClaudeSimLaneOptions, type ClaudeSimPane } from '../helpers/claude-sim-lane.js' +import { ensureMcpServerBuilt, REPO_ROOT } from '../helpers/mcp-stdio-client.js' + +const PROMPT = 'which tabs are open?' +const REPLY_PREFIX = 'Open tabs: ' +const SPLIT_PROMPT = 'split your pane' +const SPLIT_PREFIX = 'Split: ' +/** A tab the agent's list-tabs must find, named so its title is unmistakable. */ +const PROBE_TAB = 'g19 probe tab' + +/** + * The model calls the Freshell MCP tool's list-tabs, then repeats its answer; + * asked to split its pane, it thinks for a few seconds (time for the user to + * switch tabs), then calls split-pane with no target. + */ +const program = { + rules: [ + onPrompt(PROMPT, [ + { kind: 'tool', data: { name: 'mcp__freshell__freshell', input: { action: 'list-tabs' } } }, + { kind: 'text', data: { text: REPLY_PREFIX, echoToolResult: true } }, + ]), + onPrompt(SPLIT_PROMPT, [ + { kind: 'text', data: { chunks: 32, delayMs: 250 } }, + { kind: 'tool', data: { name: 'mcp__freshell__freshell', input: { action: 'split-pane', mode: 'shell' } } }, + { kind: 'text', data: { text: SPLIT_PREFIX, echoToolResult: true } }, + ]), + ], +} + +async function withLane( + browser: Parameters[0], + options: ClaudeSimLaneOptions, + body: (lane: ClaudeSimLane) => Promise, +) { + const lane = await ClaudeSimLane.start(browser, options) + try { + await body(lane) + } finally { + await lane.attachLogsOnFailure(test.info()) + await lane.stop() + } +} + +async function api(lane: ClaudeSimLane, method: 'GET' | 'POST', route: string, body?: unknown): Promise { + const response = await fetch(`${lane.info.baseUrl}${route}`, { + method, + headers: { 'x-auth-token': lane.info.token, ...(body ? { 'content-type': 'application/json' } : {}) }, + ...(body ? { body: JSON.stringify(body) } : {}), + }) + expect(response.ok, `${method} ${route} answered ${response.status}`).toBe(true) + return response.json() +} + +/** The agent's turn in `pane` whose reply starts with `prefix`. */ +async function replyTurn(pane: ClaudeSimPane, prefix: string) { + const turn = pane.paneRoot.getByRole('article', { name: /transcript turn$/ }).filter({ hasText: prefix }) + await expect(turn).toHaveCount(1, { timeout: 30_000 }) + return turn +} + +/** The agent's reply to PROMPT in `pane`, once its turn is over. */ +async function reply(lane: ClaudeSimLane, pane: ClaudeSimPane) { + await lane.send(pane, PROMPT) + await lane.waitForIdle(pane) + return replyTurn(pane, REPLY_PREFIX) +} + +/** The server's tab list (GET /api/tabs), the list the agent's MCP server reads. */ +async function serverTabs(lane: ClaudeSimLane): Promise<{ ids: string[]; activeTabId: string | null }> { + const { data } = await api(lane, 'GET', '/api/tabs') + return { ids: data.tabs.map((tab: { id: string }) => tab.id), activeTabId: data.activeTabId ?? null } +} + +/** How many panes a pane layout holds. */ +function leafCount(node: any): number { + if (!node) return 0 + if (node.type === 'leaf') return 1 + return (node.children ?? []).reduce((sum: number, child: any) => sum + leafCount(child), 0) +} + +/** The `query()` options the simulated CLI recorded for its sessions, in order. */ +function queryOptions(lane: ClaudeSimLane): Array> { + return lane.ledger().filter((row) => row.kind === 'query').map((row) => row.options as Record) +} + +test.describe('freshclaude Freshell MCP (production sidecar over the CLI simulator, Rust server)', () => { + test.setTimeout(180_000) + + test.beforeAll(() => { + // The server resolves the built MCP server (NODE_ENV=production). + ensureMcpServerBuilt(REPO_ROOT) + }) + + for (const flavour of ['freshclaude', 'kilroy'] as const) { + test(`${flavour}: the agent lists the open tabs through the Freshell MCP server`, async ({ browser }) => { + await withLane(browser, { flavour, program }, async (lane) => { + const pane = await lane.openPane() + const probeTabId: string = (await api(lane, 'POST', '/api/tabs', { name: PROBE_TAB, mode: 'shell' })).data.tabId + // The browser mirrors its layout to the server a moment after the + // layout settles (a debounce), so a just-opened pane's tab reaches the + // server's tab list shortly after it opens. + await expect.poll(async () => (await serverTabs(lane)).ids, { + timeout: 15_000, + message: 'the server knows the browser\'s tabs', + }).toEqual(expect.arrayContaining([pane.tabId, probeTabId])) + + const turn = await reply(lane, pane) + // The real server's own tab list (GET /api/tabs), read through the MCP + // server with the endpoint and token the server gave the sidecar; the + // agent's own tab (the FRESHELL_TAB_ID it got) is in it. + await expect(turn).toContainText('"status": "ok"') + await expect(turn).toContainText(pane.tabId) + await expect(turn).toContainText(PROBE_TAB) + await expect(turn).not.toContainText('No such tool') + + // The CLI got the recipe the server resolved, never with the token in it. + const [options] = queryOptions(lane) + expect(options.mcpServers?.freshell?.args?.some((arg: string) => arg.endsWith('freshell-mcp/server.js'))).toBe(true) + expect(JSON.stringify(options.mcpServers)).not.toContain(lane.info.token) + expect(options.envContext).toMatchObject({ + FRESHELL: '1', + FRESHELL_TAB_ID: pane.tabId, + FRESHELL_PANE_ID: pane.paneId, + }) + + // While the model thinks, the user switches to the probe tab. The + // untargeted split must still split the agent's own pane, not the + // pane the user is looking at. + await lane.send(pane, SPLIT_PROMPT) + await api(lane, 'POST', `/api/tabs/${encodeURIComponent(probeTabId)}/select`, {}) + await expect.poll(async () => (await serverTabs(lane)).activeTabId, { + timeout: 5_000, + message: 'the probe tab is the active tab', + }).toBe(probeTabId) + expect( + leafCount(await lane.harness.getPaneLayout(pane.tabId)), + 'the model is still thinking when the user switches tabs', + ).toBe(1) + await expect.poll(() => lane.paneStatus(pane), { timeout: 30_000, message: 'the split turn ends' }).toBe('idle') + await expect.poll(async () => leafCount(await lane.harness.getPaneLayout(pane.tabId)), { + timeout: 15_000, + message: 'the agent\'s own tab gains the new pane', + }).toBe(2) + expect(leafCount(await lane.harness.getPaneLayout(probeTabId)), 'the active tab is left alone').toBe(1) + // Back on the agent's tab, its reply shows the split succeeded. + await api(lane, 'POST', `/api/tabs/${encodeURIComponent(pane.tabId)}/select`, {}) + await expect.poll(() => lane.harness.getActiveTabId(), { timeout: 5_000 }).toBe(pane.tabId) + const splitTurn = await replyTurn(pane, SPLIT_PREFIX) + await expect(splitTurn).toContainText('"status": "ok"') + }) + }) + } + + test('freshclaude: with "Agent MCP server" turned off in Settings, a new pane\'s agent has no Freshell MCP', async ({ browser }) => { + await withLane(browser, { flavour: 'freshclaude', program }, async (lane) => { + const { page } = lane + await page.getByRole('button', { name: /^Settings/ }).click() + await page.getByRole('tab', { name: /^Advanced$/i }).click() + const toggle = page.getByRole('switch', { name: 'Agent MCP server' }) + await expect(toggle).toBeChecked() + await toggle.click() + await expect(toggle).not.toBeChecked() + await expect.poll(async () => (await api(lane, 'GET', '/api/settings')).codingCli?.mcpServer, { + timeout: 15_000, + message: 'the server saved the setting', + }).toBe(false) + await page.getByRole('button', { name: /^Coding Agents/ }).click() + + const pane = await lane.openPane() + const turn = await reply(lane, pane) + await expect(turn).toContainText('No such tool available: mcp__freshell__freshell') + const [options] = queryOptions(lane) + expect(options.mcpServers).toBeUndefined() + }) + }) +}) diff --git a/test/fixtures/claude-cli-sim/mcp.mjs b/test/fixtures/claude-cli-sim/mcp.mjs new file mode 100644 index 000000000..e24752e77 --- /dev/null +++ b/test/fixtures/claude-cli-sim/mcp.mjs @@ -0,0 +1,175 @@ +// The stdio MCP servers of a simulated CLI session, the way the pinned CLI +// runs them (conformance scenario `mcp`, claude-cli-scenarios.mjs): +// - each stdio server in `options.mcpServers` starts when the session does, +// with the CLI's environment (the SDK `env`) plus the server's own `env`; +// - a server is `connected` once it answered `initialize` and `tools/list`, +// else `failed`; a model turn waits until every server has settled, then +// its system/init lists the servers and their tools (`mcp____`); +// - a call of a listed tool is a `tools/call`: its content is the tool +// result (an error result when the server says so or goes away); +// - a call of an MCP tool no connected server lists is the CLI's +// "No such tool available" error result; +// - the servers stop with the session. +// Servers of other transports (http, sse, sdk) are reported `failed`: the +// simulator only runs stdio servers. + +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' + +/** How long a server may take to answer initialize and tools/list (the CLI's MCP_TIMEOUT). */ +const DEFAULT_CONNECT_TIMEOUT_MS = 30_000 +const PROTOCOL_VERSION = '2025-06-18' + +/** `mcp____` split into its server and tool names, or null. */ +export function parseMcpToolName(name) { + const match = /^mcp__(.+?)__(.+)$/.exec(String(name)) + return match ? { server: match[1], tool: match[2] } : null +} + +/** The CLI's error result for a tool the model called that no server offers. */ +export function noSuchToolResult(name) { + const message = `No such tool available: ${name}` + return { content: `Error: ${message}`, toolUseResult: `Error: ${message}` } +} + +class StdioServer { + constructor(name, config, { env, cwd, timeoutMs }) { + this.name = name + this.status = 'pending' + this.tools = [] + this.pending = new Map() + this.nextId = 1 + this.child = spawn(config.command, Array.isArray(config.args) ? config.args : [], { + cwd, + env: { ...env, ...(config.env ?? {}) }, + stdio: ['pipe', 'pipe', 'ignore'], + }) + this.child.on('error', () => this.fail()) + this.child.stdin.on('error', () => {}) + this.child.once('exit', () => this.fail()) + createInterface({ input: this.child.stdout }).on('line', (line) => this.onLine(line)) + this.ready = this.connect(timeoutMs) + } + + async connect(timeoutMs) { + let timer + const timeout = new Promise((_, reject) => { timer = setTimeout(() => reject(new Error('MCP server connection timed out')), timeoutMs) }) + try { + await Promise.race([this.handshake(), timeout]) + this.status = 'connected' + } catch { + this.fail() + } finally { + clearTimeout(timer) + } + } + + async handshake() { + await this.request('initialize', { + protocolVersion: PROTOCOL_VERSION, + capabilities: {}, + clientInfo: { name: 'claude-code', version: 'simulated' }, + }) + this.notify('notifications/initialized') + const listed = await this.request('tools/list', {}) + this.tools = (listed?.tools ?? []).map((tool) => String(tool?.name)).filter(Boolean) + } + + onLine(line) { + let message + try { message = JSON.parse(line) } catch { return } + if (message?.id === undefined || !this.pending.has(message.id)) return + const { resolve, reject } = this.pending.get(message.id) + this.pending.delete(message.id) + if (message.error) reject(new Error(String(message.error.message ?? 'MCP error'))) + else resolve(message.result) + } + + request(method, params) { + if (this.status === 'failed') return Promise.reject(new Error(`MCP server "${this.name}" is not connected`)) + const id = this.nextId++ + return new Promise((resolve, reject) => { + this.pending.set(id, { resolve, reject }) + this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', id, method, params })}\n`) + }) + } + + notify(method) { + this.child.stdin.write(`${JSON.stringify({ jsonrpc: '2.0', method })}\n`) + } + + fail() { + this.status = this.status === 'stopped' ? 'stopped' : 'failed' + for (const { reject } of this.pending.values()) reject(new Error(`MCP server "${this.name}" closed`)) + this.pending.clear() + } + + stop() { + this.status = 'stopped' + this.fail() + try { this.child.stdin.end() } catch { /* already gone */ } + try { this.child.kill('SIGTERM') } catch { /* already gone */ } + } +} + +export class McpServers { + /** Start every configured server with the CLI's environment `env`. */ + constructor(configs, { env, cwd }) { + const timeoutMs = Number(env.MCP_TIMEOUT) > 0 ? Number(env.MCP_TIMEOUT) : DEFAULT_CONNECT_TIMEOUT_MS + this.servers = new Map() + this.unsupported = [] + for (const [name, config] of Object.entries(configs ?? {})) { + if ((config?.type ?? 'stdio') === 'stdio' && typeof config?.command === 'string') { + this.servers.set(name, new StdioServer(name, config, { env, cwd, timeoutMs })) + } else { + this.unsupported.push(name) + } + } + } + + /** Resolves once every server has connected or failed. */ + settled() { + return Promise.all([...this.servers.values()].map((server) => server.ready)) + } + + /** The init message's `mcp_servers`. */ + statuses() { + return [ + ...[...this.servers.values()].map((server) => ({ name: server.name, status: server.status === 'connected' ? 'connected' : server.status === 'pending' ? 'pending' : 'failed' })), + ...this.unsupported.map((name) => ({ name, status: 'failed' })), + ] + } + + /** The qualified names of every connected server's tools. */ + toolNames() { + return [...this.servers.values()] + .filter((server) => server.status === 'connected') + .flatMap((server) => server.tools.map((tool) => `mcp__${server.name}__${tool}`)) + } + + /** Whether a connected server lists the qualified tool `name`. */ + offers(name) { + return this.toolNames().includes(name) + } + + /** + * Call the qualified tool `name`: `{ content, isError }` from the server, or + * an error result when the call fails. + */ + async call(name, input) { + const parsed = parseMcpToolName(name) + const server = parsed && this.servers.get(parsed.server) + if (!server || server.status !== 'connected') return { content: noSuchToolResult(name).content, isError: true } + try { + const result = await server.request('tools/call', { name: parsed.tool, arguments: input ?? {} }) + const content = Array.isArray(result?.content) ? result.content : [] + return { content, isError: result?.isError === true } + } catch (error) { + return { content: `Error: ${error?.message ?? error}`, isError: true } + } + } + + stop() { + for (const server of this.servers.values()) server.stop() + } +} diff --git a/test/fixtures/claude-cli-sim/program.mjs b/test/fixtures/claude-cli-sim/program.mjs index cc5a808b9..367d14866 100644 --- a/test/fixtures/claude-cli-sim/program.mjs +++ b/test/fixtures/claude-cli-sim/program.mjs @@ -14,11 +14,18 @@ // there. A matching prompt rule replaces the prompt-marker behavior for that // turn; its emissions run in order as the model's side of the turn: // -// text / activity { text?, chunks?, delayMs? } one streamed assistant text block +// text / activity { text?, chunks?, delayMs?, echoToolResult? } +// one streamed assistant text block; with +// echoToolResult it repeats what the turn's last +// tool call returned, after `text` // tool { name, input, id?, seconds?, output?, isError?, inputStreamMs? } // a tool call and its result; with inputStreamMs // the model streams the call's input for that long -// (a long Write) before the call completes +// (a long Write) before the call completes. An +// MCP tool (`mcp____`) is called on +// the session's stdio server, and its answer is the +// result; a tool no connected server lists is the +// CLI's "No such tool available" error (mcp.mjs) // approval { tool, input, id?, suggestions?, blockedPath?, decisionReason? } // a tool call that asks canUseTool, with these fields; under // bypassPermissions (where the pinned CLI asks for no tool diff --git a/test/fixtures/claude-cli-sim/prompt-markers.mjs b/test/fixtures/claude-cli-sim/prompt-markers.mjs index c462c89df..5ea9d0b30 100644 --- a/test/fixtures/claude-cli-sim/prompt-markers.mjs +++ b/test/fixtures/claude-cli-sim/prompt-markers.mjs @@ -15,6 +15,8 @@ // (the CLI retries each), then answers as for no marker // HANG the model request never completes // MCP:hang a call to the hung stdio MCP tool (mcp__hang__wait) +// MCP:call= a call of the MCP tool (qualified, mcp____) +// with no arguments, then a text reply // AGENT:async a background Agent; its completion starts a CLI-initiated turn // PLAN:exit an ExitPlanMode tool call carrying PLAN_TEXT // @@ -35,10 +37,11 @@ export const PLAN_TEXT = '# Plan\n\n1. Edit the file\n2. Run the tests' const TOOL_RE = /\bTOOL:sleep=(\d+(?:\.\d+)?)(?:,timeout=(\d+))?/ const APIERR_RE = /\bAPIERR:(\d{3})\b/ const RETRY_RE = /\bRETRY:(\d{3})x(\d+)\b/ +const MCP_CALL_RE = /\bMCP:call=(mcp__[A-Za-z0-9_-]+)/ /** * @param {string} text the text of the last user message - * @returns {{kind:'text'}|{kind:'slow',chunks:number,delayMs:number}|{kind:'tool',command:string,seconds:number,timeoutMs?:number}|{kind:'api_error',status:number}|{kind:'retry',status:number,attempts:number}|{kind:'hang'}|{kind:'mcp_hang'}|{kind:'agent_async'}|{kind:'exit_plan'}} + * @returns {{kind:'text'}|{kind:'slow',chunks:number,delayMs:number}|{kind:'tool',command:string,seconds:number,timeoutMs?:number}|{kind:'api_error',status:number}|{kind:'retry',status:number,attempts:number}|{kind:'hang'}|{kind:'mcp_hang'}|{kind:'mcp_call',tool:string}|{kind:'agent_async'}|{kind:'exit_plan'}} */ export function parsePromptMarkers(text) { const value = String(text ?? '') @@ -47,6 +50,8 @@ export function parsePromptMarkers(text) { const retry = RETRY_RE.exec(value) if (retry) return { kind: 'retry', status: Number(retry[1]), attempts: Number(retry[2]) } if (/\bMCP:hang\b/.test(value)) return { kind: 'mcp_hang' } + const mcpCall = MCP_CALL_RE.exec(value) + if (mcpCall) return { kind: 'mcp_call', tool: mcpCall[1] } if (/\bAGENT:async\b/.test(value)) return { kind: 'agent_async' } if (/\bPLAN:exit\b/.test(value)) return { kind: 'exit_plan' } if (/\bTOOL:touch\b/.test(value)) return { kind: 'tool', seconds: 0, command: `touch ${TOUCH_FILE}` } diff --git a/test/fixtures/claude-cli-sim/query.mjs b/test/fixtures/claude-cli-sim/query.mjs index 16d5beecd..a003ec92d 100644 --- a/test/fixtures/claude-cli-sim/query.mjs +++ b/test/fixtures/claude-cli-sim/query.mjs @@ -20,7 +20,9 @@ // - tool heartbeats every 30 s, Bash task promotion and the // BASH_MAX_TIMEOUT_MS cap, background agents and the CLI-initiated turn // their completion starts, API errors, canUseTool with suggestions and -// ExitPlanMode. +// ExitPlanMode; +// - stdio MCP servers started with the CLI's environment, listed once +// connected, and called for their tools (mcp.mjs). // // The model's side of each turn comes from prompt markers // (prompt-markers.mjs, shared with the real-CLI loopback provider) or from a @@ -48,7 +50,8 @@ // `query`, the ordinal of its query() call in // this process. Rows: `query` (the options, env // reduced to key names plus short sha256 -// digests of credential values), `boot` (the session id +// digests of credential values and the +// Freshell context values), `boot` (the session id // and the stand-in CLI's pid), `prompt` (each // command as it starts: uuid, text, content and // the model, effort and permission mode it runs @@ -83,6 +86,7 @@ import { usage, } from './sdk-shapes.mjs' import { toolCall } from './tools.mjs' +import { McpServers } from './mcp.mjs' import { claudeConfigDir, CLI_VERSION, @@ -126,6 +130,7 @@ function markerSteps(text) { case 'retry': return [{ kind: 'api_retry', data: { status: marker.status, attempts: marker.attempts } }, { kind: 'text', data: { text: 'ok' } }] case 'hang': return [{ kind: 'hang', data: {} }] case 'mcp_hang': return [{ kind: 'tool', data: { name: HANG_MCP_QUALIFIED_TOOL, input: {}, hang: true } }] + case 'mcp_call': return [{ kind: 'tool', data: { name: marker.tool, input: {} } }] case 'agent_async': return [{ kind: 'agent', data: {} }] case 'exit_plan': return [{ kind: 'exit_plan', data: { plan: PLAN_TEXT } }] default: return [{ kind: 'text', data: { text: 'ok' } }] @@ -224,6 +229,9 @@ class SimulatedCli { constructor({ prompt, options = {} }) { this.options = options this.env = { ...process.env, ...(options.env ?? {}) } + // What the CLI process itself (and so its MCP servers) gets: the SDK's + // `env` option in place of this process's environment, as the SDK does. + this.cliEnv = options.env ?? process.env this.scale = Math.max(0, Number(this.env.FRESHELL_CLAUDE_SIM_TIME_SCALE ?? 1) || 0) this.channel = createChannel() this.program = new ProgramEngine(loadProgram(this.env)) @@ -254,6 +262,8 @@ class SimulatedCli { this.sessionId = options.forkSession ? (options.sessionId ?? randomUUID()) : (options.resume ?? options.sessionId ?? randomUUID()) this.spawnCli() + // The CLI starts its stdio MCP servers with its own environment (mcp.mjs). + this.mcp = new McpServers(options.mcpServers, { env: this.cliEnv, cwd: this.cwd }) this.background(this.pumpInput(prompt)) this.background(this.boot()) @@ -379,6 +389,7 @@ class SimulatedCli { for (const timer of this.timers) clearTimeout(timer) this.timers.clear() this.turn?.abort() + this.mcp?.stop() this.init.reject(error ?? new Error('Query closed before the CLI booted')) this.channel.end(error) if (!this.cliExited) { @@ -473,7 +484,7 @@ class SimulatedCli { } systemInit() { - const mcpServers = Object.keys(this.options.mcpServers ?? {}).map((name) => ({ name, status: 'connected' })) + const mcpServers = this.mcp.statuses() const plugins = (this.options.plugins ?? []).map((plugin) => ({ name: path.basename(plugin.path ?? String(plugin)), path: plugin.path ?? String(plugin) })) return { type: 'system', @@ -484,6 +495,7 @@ class SimulatedCli { ...(this.env.FRESHELL_CLAUDE_SIM_TERMINAL_COMMANDS === undefined ? {} : { terminal_slash_commands: this.env.FRESHELL_CLAUDE_SIM_TERMINAL_COMMANDS.split(',').filter(Boolean) }), + tools: [...CATALOG.init.tools, ...this.mcp.toolNames()], mcp_servers: mcpServers, plugins, model: this.model, @@ -590,6 +602,8 @@ class SimulatedCli { async runModelTurn(turn) { const { cmd } = turn await this.sleep(T.startedToInit) + // A turn's request waits for the MCP servers to connect (or fail). + await this.mcp.settled() this.emit(this.systemInit()) await this.sleep(T.initToStatus) this.emit({ type: 'system', subtype: 'status', status: 'requesting' }) @@ -620,6 +634,7 @@ class SimulatedCli { // origin task-notification: no prompt of its own, and no SDK message. this.transcript.append({ type: 'user', message: { role: 'user', content: taskNotificationXml(task) }, origin: { kind: 'task-notification' } }) this.transcript.append({ type: 'attachment', attachment: { type: 'total_tokens_reminder', text: '15000000 tokens left' } }) + await this.mcp.settled() this.emit(this.systemInit()) await this.sleep(T.initToStatus) this.emit({ type: 'system', subtype: 'status', status: 'requesting' }) @@ -728,7 +743,10 @@ class SimulatedCli { /** One streamed text block. Returns false when the turn was aborted (and has been ended). */ async streamText(turn, data) { - const pieces = data.chunks ? Array.from({ length: data.chunks }, (_, i) => `chunk${i} `) : [data.text ?? 'ok'] + // A model that repeats what its last tool call returned (`echoToolResult`, + // after an optional `text` prefix). + const reply = data.echoToolResult ? `${data.text ?? ''}${turn.lastToolResultText ?? ''}` : (data.text ?? 'ok') + const pieces = data.chunks ? Array.from({ length: data.chunks }, (_, i) => `chunk${i} `) : [reply] const pace = data.chunks ? (data.delayMs ?? SLOW_DELAY_MS) : 0 this.emitStream(this.messageStart()) this.emitStream({ type: 'content_block_start', index: 0, content_block: { type: 'text', text: '' } }) @@ -764,6 +782,7 @@ class SimulatedCli { toolResult(toolUseId, content, { isError = false, toolUseResult, extra = {} } = {}) { const uuid = randomUUID() const block = { tool_use_id: toolUseId, type: 'tool_result', content, ...(isError ? { is_error: true } : { is_error: false }) } + if (this.turn) this.turn.lastToolResultText = Array.isArray(content) ? content.map((part) => part?.text ?? '').join('') : String(content ?? '') this.emit({ type: 'user', message: { role: 'user', content: [block] }, parent_tool_use_id: null, uuid, timestamp: new Date().toISOString(), tool_use_result: toolUseResult, ...extra }) this.transcript.append({ type: 'user', uuid, message: { role: 'user', content: [block] }, toolUseResult }) // Sends absorbed while the tool ran join the turn as queued_command @@ -1013,7 +1032,7 @@ class SimulatedCli { } async mcpServerStatus() { - return this.control(() => Object.keys(this.options.mcpServers ?? {}).map((name) => ({ name, status: 'connected' }))) + return this.control(() => this.mcp.statuses()) } async initializationResult() { @@ -1022,14 +1041,18 @@ class SimulatedCli { } /** The credentials whose value a test may need to tell apart (as a digest, never the value). */ -const DIGESTED_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN'] +const DIGESTED_CREDENTIALS = ['ANTHROPIC_API_KEY', 'ANTHROPIC_AUTH_TOKEN', 'CLAUDE_CODE_OAUTH_TOKEN', 'FRESHELL_TOKEN'] + +/** The non-secret Freshell context names whose values the ledger records (the token is digested). */ +const RECORDED_CONTEXT = ['FRESHELL', 'FRESHELL_URL', 'FRESHELL_TERMINAL_ID', 'FRESHELL_TAB_ID', 'FRESHELL_PANE_ID'] /** A short sha256 digest of a credential value: enough to compare, useless to read. */ const credentialDigest = (value) => createHash('sha256').update(String(value)).digest('hex').slice(0, 12) /** * The options as a ledger row: functions and controllers by kind, env by key - * names only, plus a short digest of each credential the child got. + * names only, plus a short digest of each credential the child got and the + * values of the non-secret Freshell context names. */ function describeOptions(options) { const out = {} @@ -1038,6 +1061,7 @@ function describeOptions(options) { const env = value ?? {} out.envKeys = Object.keys(env).sort() out.envDigests = Object.fromEntries(DIGESTED_CREDENTIALS.filter((name) => env[name] !== undefined).map((name) => [name, credentialDigest(env[name])])) + out.envContext = Object.fromEntries(RECORDED_CONTEXT.filter((name) => env[name] !== undefined).map((name) => [name, env[name]])) } else if (key === 'abortController') out.abortController = Boolean(value) else if (typeof value === 'function') out[key] = '[function]' else out[key] = value diff --git a/test/fixtures/claude-cli-sim/tools.mjs b/test/fixtures/claude-cli-sim/tools.mjs index a5270bc9c..d05a6c460 100644 --- a/test/fixtures/claude-cli-sim/tools.mjs +++ b/test/fixtures/claude-cli-sim/tools.mjs @@ -7,6 +7,7 @@ import path from 'node:path' import { randomUUID } from 'node:crypto' import { PLAN_TEXT } from './prompt-markers.mjs' import { READ_ONLY_COMMANDS, T } from './sdk-shapes.mjs' +import { noSuchToolResult } from './mcp.mjs' /** Whether the pinned CLI would ask canUseTool for this call in the current permission mode. */ function needsPermission(cli, name, input) { @@ -131,6 +132,17 @@ export async function toolCall(cli, turn, step) { if (step.kind === 'agent') return launchAgent(cli, turn, toolUseId, input) + // An MCP tool no connected server lists does not exist for the CLI: it + // answers at once, before any permission check and before the rest of the + // model's message has streamed. (A scripted hang stands in for a hung + // server's call, configured or not.) + if (name.startsWith('mcp__') && data.hang !== true && !cli.mcp.offers(name)) { + const missing = noSuchToolResult(name) + cli.toolResult(toolUseId, missing.content, { isError: true, toolUseResult: missing.toolUseResult }) + cli.trailingStream('tool_use') + return 'done' + } + if (step.kind === 'approval' || step.kind === 'question' || needsPermission(cli, name, input)) { cli.emitState('requires_action') const decision = await askPermission(cli, turn, name, input, toolUseId, data) @@ -156,9 +168,37 @@ export async function toolCall(cli, turn, step) { cli.toolResult(toolUseId, `User has answered your questions: ${JSON.stringify(input.answers ?? {})}`, { toolUseResult: { questions: input.questions, answers: input.answers ?? {} } }) return 'done' } + if (name.startsWith('mcp__') && data.hang !== true) return executeMcpTool(cli, turn, toolUseId, name, input) return executeTool(cli, turn, toolUseId, name, input, data) } +/** + * Call a connected server's tool: its result is the tool result. A running + * call sends a heartbeat every 30 s like any tool; sends pushed meanwhile are + * absorbed into this turn (T10). + */ +async function executeMcpTool(cli, turn, toolUseId, name, input) { + turn.absorbing = true + const call = cli.mcp.call(name, input) + let beats = 0 + for (;;) { + const next = (beats + 1) * T.heartbeat + const step = await Promise.race([ + call.then(() => 'settled'), + cli.wait(next - beats * T.heartbeat, turn).then((aborted) => (aborted ? 'aborted' : 'beat')), + ]) + if (step !== 'beat') break + cli.emit({ type: 'tool_progress', tool_use_id: `${toolUseId}-heartbeat-${beats}`, tool_name: name, parent_tool_use_id: toolUseId, elapsed_time_seconds: Math.floor(next / 1000), heartbeat: true }) + beats += 1 + } + turn.absorbing = false + if (turn.aborted) return cli.abortTool(turn, toolUseId) + const { content, isError } = await call + cli.toolResult(toolUseId, content, { isError, toolUseResult: content }) + startAbsorbed(cli, turn) + return 'done' +} + /** Run a tool; sends pushed meanwhile are absorbed into this turn (T10). */ async function executeTool(cli, turn, toolUseId, name, input, data) { turn.absorbing = true diff --git a/test/integration/providers/claude-cli-contract.test.ts b/test/integration/providers/claude-cli-contract.test.ts index 2b24df958..95fb1cfad 100644 --- a/test/integration/providers/claude-cli-contract.test.ts +++ b/test/integration/providers/claude-cli-contract.test.ts @@ -453,6 +453,36 @@ describe(`pinned Claude CLI ${PINNED_CLI_VERSION} contract (real CLI, loopback p expect(cliTurn).toEqual(['lifecycle u1 completed', 'idle', 'task_notification', 'running', 'init', 'result', 'idle']) expect(messagesOf(r, 'S').filter((m) => m.type === 'command_lifecycle').every((m) => m.command_uuid === 'u1')).toBe(true) }, SCENARIO_TIMEOUT) + + it('G19: a stdio MCP server starts with the CLI\'s environment and its tools are listed and called; an MCP tool no server offers is an error result', async () => { + const r = await real('mcp') + const environment = { + FRESHELL: '1', + FRESHELL_URL: 'http://127.0.0.1:9/probe', + FRESHELL_TOKEN_SHA256: expect.stringMatching(/^[0-9a-f]{64}$/), + FRESHELL_TAB_ID: 'tab-s', + FRESHELL_PANE_ID: 'pane-s', + FRESHELL_TERMINAL_ID: null, + } + expect(r.observations.serverEnvironment).toEqual(environment) + expect(r.observations.connected).toEqual({ + mcpServers: [{ name: 'freshell', status: 'connected' }], + mcpTools: ['mcp__freshell__context'], + toolResults: [{ isError: false, content: ['text'], text: environment, toolUseResult: ['text'] }], + }) + expect(r.observations.missing).toEqual({ + mcpServers: [], + mcpTools: [], + toolResults: [{ + isError: true, + content: 'string', + text: 'Error: No such tool available: mcp__freshell__freshell', + toolUseResult: 'string', + }], + }) + expect(results(r, 'S').map((m) => m.subtype)).toEqual(['success']) + expect(results(r, 'M').map((m) => m.subtype)).toEqual(['success']) + }, SCENARIO_TIMEOUT) }) describeSimulatorConformance(real) diff --git a/test/integration/providers/claude-cli-scenarios.mjs b/test/integration/providers/claude-cli-scenarios.mjs index 6f36cc1aa..12d1c8297 100644 --- a/test/integration/providers/claude-cli-scenarios.mjs +++ b/test/integration/providers/claude-cli-scenarios.mjs @@ -30,6 +30,9 @@ import { } from './claude-local-provider.mjs' import { HANG_MCP_SERVER_NAME } from '../../fixtures/claude-cli-sim/prompt-markers.mjs' +/** The stdio MCP server the `mcp` scenario connects (it stands in for tools/freshell-mcp). */ +const FRESHELL_MCP_PROBE_SERVER = path.join(path.dirname(fileURLToPath(import.meta.url)), 'claude-freshell-mcp-probe-server.mjs') + export const SIM_QUERY_MODULE = path.join(DEFAULT_REPO_ROOT, 'test/fixtures/claude-cli-sim/query.mjs') const SESSION_STATE_ENV = { CLAUDE_CODE_EMIT_SESSION_STATE_EVENTS: '1' } const sleep = (ms) => new Promise((resolve) => setTimeout(resolve, ms)) @@ -323,6 +326,32 @@ async function sha256(file) { return createHash('sha256').update(await fs.readFile(file)).digest('hex') } +/** + * What a turn that called an MCP tool showed: the servers and MCP tools its + * init listed, and each tool result (its error flag and text; a JSON text + * without the reporting process's pid). + */ +function mcpTurnShape(session) { + const init = session.messages((m) => m.type === 'system' && m.subtype === 'init')[0] + const results = session.messages((m) => m.type === 'user' && Array.isArray(m.message?.content)) + .flatMap((m) => m.message.content.filter((block) => block?.type === 'tool_result').map((block) => { + const text = Array.isArray(block.content) ? block.content.map((part) => part?.text ?? '').join('') : String(block.content ?? '') + let parsed + try { parsed = JSON.parse(text); delete parsed.pid } catch { parsed = text } + return { + isError: block.is_error === true, + content: Array.isArray(block.content) ? block.content.map((part) => part?.type) : typeof block.content, + text: parsed, + toolUseResult: Array.isArray(m.tool_use_result) ? m.tool_use_result.map((part) => part?.type) : typeof m.tool_use_result, + } + })) + return { + mcpServers: init?.mcp_servers ?? null, + mcpTools: (init?.tools ?? []).filter((name) => String(name).startsWith('mcp__')), + toolResults: results, + } +} + /** The chain entries of a transcript (lines that carry a uuid), in file order, without their payloads. */ function transcriptShape(lines) { return (lines ?? []).filter((line) => typeof line.uuid === 'string').map((line) => ({ @@ -671,6 +700,32 @@ const SCENARIOS = { await Promise.all([mcp.close(), bash.close()]) }, + /** + * G19: the CLI starts a stdio MCP server with its own environment, lists the + * server's tools once it connected, and returns a tool call's text result; + * a call of an MCP tool no configured server offers is an error result. + */ + async mcp(ctx) { + const report = path.join(ctx.fixture.root, 'mcp-probe-report.json') + const s = ctx.open('S', { + options: { mcpServers: { freshell: { type: 'stdio', command: process.execPath, args: [FRESHELL_MCP_PROBE_SERVER, report] } } }, + env: { FRESHELL: '1', FRESHELL_URL: 'http://127.0.0.1:9/probe', FRESHELL_TOKEN: 'probe-token', FRESHELL_TAB_ID: 'tab-s', FRESHELL_PANE_ID: 'pane-s' }, + }) + s.push('MCP:call=mcp__freshell__context', 'u1') + await s.waitForResults(1, 30_000) + ctx.observe('connected', mcpTurnShape(s)) + const seen = JSON.parse(await fs.readFile(report, 'utf8')) + delete seen.pid + ctx.observe('serverEnvironment', seen) + await s.close() + + const m = ctx.open('M') + m.push('MCP:call=mcp__freshell__freshell', 'u2') + await m.waitForResults(1, 30_000) + ctx.observe('missing', mcpTurnShape(m)) + await m.close() + }, + /** T18: an API error ends the turn with a success-subtype error result. */ async 'api-error'(ctx) { const X = ctx.uuid('X') diff --git a/test/integration/providers/claude-cli-sim.test.ts b/test/integration/providers/claude-cli-sim.test.ts index d89efe330..7e46af9f2 100644 --- a/test/integration/providers/claude-cli-sim.test.ts +++ b/test/integration/providers/claude-cli-sim.test.ts @@ -188,6 +188,23 @@ describe('Claude CLI simulator programs', () => { expect(results(s.messages)).toEqual([]) }) + it('an MCP tool step calls the session\'s stdio server, and a text step can repeat what it returned', async () => { + const probe = path.join(REPO_ROOT, 'test/integration/providers/claude-freshell-mcp-probe-server.mjs') + const program = { rules: [{ on: 'msg:prompt', match: { text: 'what did the server see' }, emit: [ + { kind: 'tool', data: { name: 'mcp__freshell__context', input: {} } }, + { kind: 'text', data: { text: 'The server saw: ', echoToolResult: true } }, + ] }] } + const s = await open( + { mcpServers: { freshell: { type: 'stdio', command: process.execPath, args: [probe] } } }, + { FRESHELL_CLAUDE_SIM_PROGRAM: JSON.stringify(program), FRESHELL: '1', FRESHELL_TAB_ID: 'tab-p', FRESHELL_PANE_ID: 'pane-p' }, + ) + s.prompt.push('what did the server see') + await s.until(() => results(s.messages).length === 1) + const reply = s.messages.filter((m) => m.type === 'assistant').at(-1).message.content[0].text as string + expect(reply.startsWith('The server saw: ')).toBe(true) + expect(JSON.parse(reply.slice('The server saw: '.length))).toMatchObject({ FRESHELL: '1', FRESHELL_TAB_ID: 'tab-p', FRESHELL_PANE_ID: 'pane-p' }) + }) + it('a completion rule ends the turn with the scripted subtype; a `once` rule then falls back to the prompt markers', async () => { const s = await open({}, program([{ on: 'msg:prompt', once: true, emit: [{ kind: 'text', data: { text: 'partial' } }, { kind: 'completion', data: { subtype: 'error_max_turns', text: 'too many turns' } }] }])) s.prompt.push('first') diff --git a/test/integration/providers/claude-freshell-mcp-probe-server.mjs b/test/integration/providers/claude-freshell-mcp-probe-server.mjs new file mode 100644 index 000000000..a3299a9a9 --- /dev/null +++ b/test/integration/providers/claude-freshell-mcp-probe-server.mjs @@ -0,0 +1,59 @@ +// A minimal stdio MCP server standing in for the Freshell MCP server +// (tools/freshell-mcp/server.ts) in the G19 contract and simulator tests. +// +// node claude-freshell-mcp-probe-server.mjs +// +// On start it writes what the Freshell MCP client would read from its +// environment to : the Freshell context names, with the token as +// a sha256 digest (never its value). Its one tool, `context`, answers with the +// same report as text, so a model turn can show what the server saw. +import { createHash } from 'node:crypto' +import { writeFileSync } from 'node:fs' +import readline from 'node:readline' + +const PROBE_TOOL = 'context' + +const report = { + pid: process.pid, + FRESHELL: process.env.FRESHELL ?? null, + FRESHELL_URL: process.env.FRESHELL_URL ?? null, + FRESHELL_TOKEN_SHA256: process.env.FRESHELL_TOKEN + ? createHash('sha256').update(process.env.FRESHELL_TOKEN).digest('hex') + : null, + FRESHELL_TAB_ID: process.env.FRESHELL_TAB_ID ?? null, + FRESHELL_PANE_ID: process.env.FRESHELL_PANE_ID ?? null, + FRESHELL_TERMINAL_ID: process.env.FRESHELL_TERMINAL_ID ?? null, +} +if (process.argv[2]) writeFileSync(process.argv[2], `${JSON.stringify(report)}\n`) + +const lines = readline.createInterface({ input: process.stdin }) +const send = (message) => process.stdout.write(`${JSON.stringify(message)}\n`) + +lines.on('line', (line) => { + let message + try { message = JSON.parse(line) } catch { return } + if (message.method === 'initialize') { + send({ + jsonrpc: '2.0', + id: message.id, + result: { + protocolVersion: message.params?.protocolVersion ?? '2024-11-05', + capabilities: { tools: {} }, + serverInfo: { name: 'freshell-probe', version: '1.0.0' }, + }, + }) + } else if (message.method === 'tools/list') { + send({ + jsonrpc: '2.0', + id: message.id, + result: { + tools: [{ name: PROBE_TOOL, description: 'Reports the Freshell context this server received.', inputSchema: { type: 'object', properties: {} } }], + }, + }) + } else if (message.method === 'tools/call') { + send({ jsonrpc: '2.0', id: message.id, result: { content: [{ type: 'text', text: JSON.stringify(report) }] } }) + } else if (message.id !== undefined) { + send({ jsonrpc: '2.0', id: message.id, result: {} }) + } +}) +lines.on('close', () => process.exit(0)) diff --git a/test/integration/providers/claude-freshell-mcp.mjs b/test/integration/providers/claude-freshell-mcp.mjs new file mode 100644 index 000000000..2ed16a743 --- /dev/null +++ b/test/integration/providers/claude-freshell-mcp.mjs @@ -0,0 +1,195 @@ +// Contract harness (audit G19, api-hosted-ops plan §2.4/§3.2): the REAL Claude sidecar +// (crates/freshell-claude-sidecar/index.mjs) over the pinned SDK and its bundled native CLI +// (resolvePinnedClaude), in the shared throwaway fixture (createClaudeFixture), against an +// isolated local provider endpoint (the real Anthropic API is never contacted). +// +// The sidecar is launched the way the Rust server launches it for a freshclaude pane: its +// environment carries FRESHELL=1, FRESHELL_URL and a random FRESHELL_TOKEN, and its create +// line names the ordinary Freshell MCP recipe (`freshellMcp`, a probe stdio MCP server that +// stands in for tools/freshell-mcp) and the pane (`freshellContext`). One turn asks the +// model to call the probe's tool. The receipt records what the CLI and the MCP child got: +// - the CLI's argv carries the MCP config but never the token; +// - the MCP child started and received the endpoint, the token and the pane context; +// - the CLI listed the probe's tool (the server connected) and the tool call returned the +// probe's report. +import fs from 'node:fs/promises' +import path from 'node:path' +import { spawn } from 'node:child_process' +import { createInterface } from 'node:readline' +import { createHash, randomBytes, randomUUID } from 'node:crypto' +import { fileURLToPath } from 'node:url' +import { createClaudeFixture, FAKE_MODEL, resolvePinnedClaude, startLocalProvider } from './claude-local-provider.mjs' + +const repoRoot = process.argv[2] +if (!repoRoot) throw new Error('repository root argument is required') + +const here = path.dirname(fileURLToPath(import.meta.url)) +const PROBE_SERVER = path.join(here, 'claude-freshell-mcp-probe-server.mjs') +const PROBE_TOOL = 'mcp__freshell__context' +const PROMPT = 'call the freshell probe' +const sidecarDir = path.join(repoRoot, 'crates/freshell-claude-sidecar') + +const pinned = await resolvePinnedClaude(repoRoot) +const fixture = await createClaudeFixture({ prefix: 'freshell-claude-freshell-mcp-' }) +const token = `fresh-token-${randomBytes(16).toString('hex')}` +const freshellUrl = 'http://127.0.0.1:9/freshell-probe-endpoint' +const context = { tabId: 'tab-g19', paneId: 'pane-g19' } +const reportFile = path.join(fixture.root, 'probe-report.json') +const sleep = ms => new Promise(resolve => setTimeout(resolve, ms)) + +let provider +let sidecar +let failureStage = 'initialization' +const receipt = { + verdict: 'FAIL', + sdkVersion: pinned.sdkVersion, + bundledCliVersion: pinned.cliVersion, + cliArgvHasMcpConfig: false, + cliArgvHasToken: null, + probe: null, + probeToolListed: false, + toolResultReport: null, + toolNames: null, +} + +try { + failureStage = 'local provider' + // The model calls the probe's tool for the scripted prompt, then ends the turn. + provider = await startLocalProvider({ + respond: ({ promptText, afterTool, tools }) => { + if (afterTool) return { blocks: [{ type: 'text', text: 'done' }] } + if (promptText.includes(PROMPT) && tools.includes(PROBE_TOOL)) { + return { blocks: [{ type: 'tool_use', name: PROBE_TOOL, input: {} }] } + } + return undefined + }, + }) + + failureStage = 'sidecar start' + sidecar = spawn(process.execPath, [path.join(sidecarDir, 'index.mjs')], { + cwd: sidecarDir, + env: fixture.env(provider.url, { + CLAUDE_CMD: pinned.nativeCli, + FRESHELL_CLAUDE_ONECLI_API_KEY: '1', + // What the Rust server sets on every sidecar (claude_spawn::with_freshell_endpoint). + FRESHELL: '1', + FRESHELL_URL: freshellUrl, + FRESHELL_TOKEN: token, + }), + stdio: ['pipe', 'pipe', 'pipe'], + }) + let stderr = '' + sidecar.stderr.setEncoding('utf8').on('data', (chunk) => { stderr = (stderr + chunk).slice(-4000) }) + sidecar.stdin.on('error', () => {}) + const frames = [] + const waiters = [] + createInterface({ input: sidecar.stdout }).on('line', (line) => { + let frame + try { frame = JSON.parse(line) } catch { return } + frames.push(frame) + for (const waiter of [...waiters]) if (waiter.match(frame)) { waiters.splice(waiters.indexOf(waiter), 1); waiter.resolve(frame) } + }) + const waitFor = (match, ms, what) => new Promise((resolve, reject) => { + const existing = frames.find(match) + if (existing) return resolve(existing) + const timer = setTimeout(() => reject(new Error(`timed out waiting for ${what}; stderr=${stderr.slice(-800)}`)), ms) + waiters.push({ match, resolve: frame => { clearTimeout(timer); resolve(frame) } }) + }) + const write = message => sidecar.stdin.write(`${JSON.stringify(message)}\n`) + + failureStage = 'create' + const requestId = randomUUID() + write({ + type: 'create', protocol: 2, boot: { kind: 'fresh' }, requestId, cwd: fixture.cwd, model: FAKE_MODEL, + permissionMode: 'bypassPermissions', hostedMcp: false, + freshellMcp: { command: process.execPath, args: [PROBE_SERVER, reportFile] }, + freshellContext: context, + }) + const created = await waitFor(frame => frame.requestId === requestId && (frame.type === 'created' || frame.type === 'create.failed'), 45_000, 'created') + if (created.type !== 'created') throw new Error(`the sidecar refused the create: ${created.message}`) + + failureStage = 'cli argv' + const argv = (await fs.readFile(`/proc/${created.cliPid}/cmdline`, 'utf8')).split('\0') + const mcpConfigAt = argv.indexOf('--mcp-config') + receipt.cliArgvHasMcpConfig = mcpConfigAt >= 0 && argv[mcpConfigAt + 1]?.includes(PROBE_SERVER) === true + receipt.cliArgvHasToken = argv.some(arg => arg.includes(token)) + + failureStage = 'turn' + write({ type: 'send', sessionId: created.sessionId, text: PROMPT }) + const init = await waitFor(frame => frame.type === 'sdk.session.init', 45_000, 'the turn init') + // The sidecar forwards the CLI's tool list as `{ name }` objects. + receipt.toolNames = (init.tools ?? []).map(tool => (typeof tool === 'string' ? tool : tool?.name)).filter(name => String(name).startsWith('mcp__')) + receipt.probeToolListed = receipt.toolNames.includes(PROBE_TOOL) + await waitFor(frame => frame.type === 'sdk.result', 45_000, 'the turn result') + // The CLI's tool result for the probe call: the user message carrying the + // tool_result block reaches the sidecar as a transcript announcement only, + // and the CLI flushes the line about 100 ms after the frame, so poll the + // transcript for it. + failureStage = 'tool result' + const toolResultReport = async () => { + for (const relative of await fixture.transcripts()) { + const text = await fs.readFile(path.join(fixture.home, '.claude', relative), 'utf8') + for (const line of text.split('\n').filter(Boolean)) { + let row + try { row = JSON.parse(line) } catch { continue } + const blocks = Array.isArray(row.message?.content) ? row.message.content : [] + for (const block of blocks) { + if (block?.type !== 'tool_result') continue + const body = Array.isArray(block.content) ? block.content.map(part => part?.text ?? '').join('') : String(block.content ?? '') + try { return JSON.parse(body) } catch { return { unparsed: body.slice(0, 200) } } + } + } + } + return null + } + for (let waited = 0; waited < 10_000 && receipt.toolResultReport === null; waited += 50) { + receipt.toolResultReport = await toolResultReport() + if (receipt.toolResultReport === null) await sleep(50) + } + + failureStage = 'probe report' + for (let waited = 0; waited < 10_000 && receipt.probe === null; waited += 50) { + try { receipt.probe = JSON.parse(await fs.readFile(reportFile, 'utf8')) } catch { await sleep(50) } + } + + const tokenDigest = createHash('sha256').update(token).digest('hex') + const probeOk = receipt.probe !== null + && receipt.probe.FRESHELL === '1' + && receipt.probe.FRESHELL_URL === freshellUrl + && receipt.probe.FRESHELL_TOKEN_SHA256 === tokenDigest + && receipt.probe.FRESHELL_TAB_ID === context.tabId + && receipt.probe.FRESHELL_PANE_ID === context.paneId + receipt.probeSawContext = probeOk + receipt.toolCallReturnedReport = receipt.toolResultReport?.FRESHELL_TOKEN_SHA256 === tokenDigest + if (receipt.probe) receipt.probe = { ...receipt.probe, FRESHELL_TOKEN_SHA256: receipt.probe.FRESHELL_TOKEN_SHA256 === tokenDigest ? 'matches' : 'differs' } + if (receipt.toolResultReport?.FRESHELL_TOKEN_SHA256) receipt.toolResultReport = { ...receipt.toolResultReport, FRESHELL_TOKEN_SHA256: receipt.toolResultReport.FRESHELL_TOKEN_SHA256 === tokenDigest ? 'matches' : 'differs' } + if (receipt.cliArgvHasMcpConfig && receipt.cliArgvHasToken === false && probeOk && receipt.probeToolListed && receipt.toolCallReturnedReport) { + receipt.verdict = 'PASS' + } + + failureStage = 'shutdown' + write({ type: 'shutdown' }) + sidecar.stdin.end() + await new Promise(resolve => { + if (sidecar.exitCode !== null) return resolve() + const timer = setTimeout(resolve, 5_000) + sidecar.once('exit', () => { clearTimeout(timer); resolve() }) + }) +} catch (error) { + receipt.failureStage = failureStage + process.stderr.write(`${JSON.stringify({ + severity: 'error', + component: 'claude-freshell-mcp-contract', + stage: failureStage, + name: error?.name ?? 'Error', + message: String(error?.message ?? 'unknown error').replaceAll(token, '').slice(0, 800), + })}\n`) +} finally { + if (sidecar && sidecar.exitCode === null) sidecar.kill('SIGKILL') + if (receipt.probe?.pid) { try { process.kill(receipt.probe.pid, 'SIGKILL') } catch {} } + await provider?.close() + await fixture.cleanup() +} + +process.stdout.write(`${JSON.stringify(receipt).replaceAll(token, '')}\n`) +if (receipt.verdict !== 'PASS') process.exitCode = 1 diff --git a/test/integration/providers/claude-freshell-mcp.test.ts b/test/integration/providers/claude-freshell-mcp.test.ts new file mode 100644 index 000000000..617899eb9 --- /dev/null +++ b/test/integration/providers/claude-freshell-mcp.test.ts @@ -0,0 +1,72 @@ +// @vitest-environment node + +import { spawn, type ChildProcess } from 'node:child_process' +import path from 'node:path' +import { fileURLToPath } from 'node:url' +import { afterEach, describe, expect, it } from 'vitest' +import { PINNED_CLI_VERSION, PINNED_SDK_VERSION } from './claude-local-provider.mjs' + +const __dirname = path.dirname(fileURLToPath(import.meta.url)) +const REPO_ROOT = path.resolve(__dirname, '../../..') +const HARNESS = path.join(__dirname, 'claude-freshell-mcp.mjs') +const children = new Set() + +afterEach(() => { + for (const child of children) child.kill('SIGKILL') + children.clear() +}) + +// G19 (api-hosted-ops plan §2.4, §3.2): a freshclaude pane's Freshell MCP +// server reaches the server through the inherited environment, never the +// CLI's argv. Against the pinned SDK and CLI: the real sidecar hands the CLI +// the recipe the Rust server resolved; the CLI starts the stdio MCP child with +// the endpoint, the token and the pane context the sidecar merged in; the +// server connects (its tool is listed and callable); and the token is nowhere +// on the CLI's command line, where the MCP config itself travels. +describe('Freshell MCP in a Claude sidecar against the pinned CLI and a local provider', () => { + it('connects the MCP child with the endpoint, token and pane in its environment and keeps the token off the CLI argv', async () => { + const child = spawn(process.execPath, [HARNESS, REPO_ROOT], { + cwd: REPO_ROOT, + env: { PATH: process.env.PATH ?? '/usr/bin:/bin', LANG: 'C.UTF-8' }, + stdio: ['ignore', 'pipe', 'pipe'], + }) + children.add(child) + let stdout = '' + let stderr = '' + child.stdout!.setEncoding('utf8').on('data', (chunk: string) => { stdout += chunk }) + child.stderr!.setEncoding('utf8').on('data', (chunk: string) => { stderr += chunk }) + const exit = await new Promise<{ code: number | null; signal: NodeJS.Signals | null }>((resolve, reject) => { + const timer = setTimeout(() => { + child.kill('SIGTERM') + reject(new Error(`Freshell MCP contract timed out; stderr=${stderr.slice(-2000)}`)) + }, 150_000) + timer.unref() + child.once('error', (error) => { clearTimeout(timer); reject(error) }) + child.once('exit', (code, signal) => { clearTimeout(timer); resolve({ code, signal }) }) + }) + children.delete(child) + + const lines = stdout.trim().split('\n').filter(Boolean) + expect(lines, stderr).toHaveLength(1) + const receipt = JSON.parse(lines[0]!) as Record + expect(receipt, `${lines[0]}\n${stderr}`).toMatchObject({ + sdkVersion: PINNED_SDK_VERSION, + bundledCliVersion: PINNED_CLI_VERSION, + cliArgvHasMcpConfig: true, + cliArgvHasToken: false, + probeToolListed: true, + probeSawContext: true, + toolCallReturnedReport: true, + probe: { + FRESHELL: '1', + FRESHELL_URL: 'http://127.0.0.1:9/freshell-probe-endpoint', + FRESHELL_TOKEN_SHA256: 'matches', + FRESHELL_TAB_ID: 'tab-g19', + FRESHELL_PANE_ID: 'pane-g19', + FRESHELL_TERMINAL_ID: null, + }, + verdict: 'PASS', + }) + expect(exit, stderr).toEqual({ code: 0, signal: null }) + }, 160_000) +}) diff --git a/test/integration/providers/claude-local-provider.mjs b/test/integration/providers/claude-local-provider.mjs index 73290be35..27fe87d05 100644 --- a/test/integration/providers/claude-local-provider.mjs +++ b/test/integration/providers/claude-local-provider.mjs @@ -374,6 +374,8 @@ export async function startLocalProvider({ replyText = 'ok', respond } = {}) { toolUse('Bash', { command: marker.command, description: 'local provider command', ...(marker.timeoutMs ? { timeout: marker.timeoutMs } : {}) }) } else if (marker.kind === 'mcp_hang') { toolUse(HANG_MCP_QUALIFIED_TOOL, {}) + } else if (marker.kind === 'mcp_call' && !afterTool) { + toolUse(marker.tool, {}) } else if (marker.kind === 'exit_plan') { toolUse('ExitPlanMode', { plan: PLAN_TEXT }) } else if (marker.kind === 'agent_async') { diff --git a/test/unit/claude-sidecar/hosted.test.ts b/test/unit/claude-sidecar/hosted.test.ts index 53ff387e7..1deea7310 100644 --- a/test/unit/claude-sidecar/hosted.test.ts +++ b/test/unit/claude-sidecar/hosted.test.ts @@ -3,17 +3,16 @@ // One place decides whether the claude sidecar is hosted (hosted.mjs). The // session host sets FRESHELL_HOSTED_FRESH_AGENT to the provider it hosts: // `claude` for freshclaude, `kilroy` for kilroy. Every hosted sidecar logs CLI -// output by size only. The managed spawn check and the fixed hosted MCP recipe -// apply to hosted claude alone today; extending them to hosted kilroy is an -// explicit item of the api-hosted-ops workstream -// (docs/plans/2026-10-22-freshclaude-api-hosted-ops.md §2.4), not a silent -// side effect of sharing the predicate. +// output by size only, and every hosted sidecar runs the Claude CLI inside the +// managed enclosure, so the managed spawn check and the fixed hosted MCP recipe +// apply to hosted kilroy as they do to hosted claude +// (docs/plans/2026-10-22-freshclaude-api-hosted-ops.md §2.4). import { describe, expect, it } from 'vitest' // @ts-expect-error JavaScript sidecar module has no declaration file. import { hostedProvider, isHostedSidecar, usesHostedClaudeRuntime } from '../../../crates/freshell-claude-sidecar/hosted.mjs' // @ts-expect-error JavaScript sidecar module has no declaration file. -import { hostedMcpServers } from '../../../crates/freshell-claude-sidecar/hosted-mcp.mjs' +import { mcpServersFor } from '../../../crates/freshell-claude-sidecar/mcp-servers.mjs' // @ts-expect-error JavaScript sidecar module has no declaration file. import { managedSdkSpawn } from '../../../crates/freshell-claude-sidecar/exec-profile.mjs' @@ -34,18 +33,21 @@ describe('Claude sidecar hosted mode', () => { expect(isHostedSidecar(env())).toBe(false) }) - it('gives the managed spawn check and the fixed MCP recipe to hosted claude only', () => { + it('gives the managed spawn check and the fixed MCP recipe to hosted claude and hosted kilroy alike', () => { expect(usesHostedClaudeRuntime(env('claude'))).toBe(true) - expect(usesHostedClaudeRuntime(env('kilroy'))).toBe(false) + expect(usesHostedClaudeRuntime(env('kilroy'))).toBe(true) + expect(usesHostedClaudeRuntime(env(''))).toBe(false) expect(usesHostedClaudeRuntime(env())).toBe(false) - expect(hostedMcpServers(true, env('claude'))).toEqual({ freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } }) - expect(hostedMcpServers(true, env('kilroy'))).toBeUndefined() - expect(hostedMcpServers(true, env())).toBeUndefined() + const fixedRecipe = { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } + expect(mcpServersFor({ hostedMcp: true }, env('claude'))).toEqual(fixedRecipe) + expect(mcpServersFor({ hostedMcp: true }, env('kilroy'))).toEqual(fixedRecipe) + expect(mcpServersFor({ hostedMcp: true }, env())).toBeUndefined() // The managed spawn is Linux-only by design. - expect(typeof managedSdkSpawn(() => {}, env('claude'))).toBe(process.platform === 'linux' ? 'function' : 'undefined') - expect(managedSdkSpawn(() => {}, env('kilroy'))).toBeUndefined() + const spawnKind = process.platform === 'linux' ? 'function' : 'undefined' + expect(typeof managedSdkSpawn(() => {}, env('claude'))).toBe(spawnKind) + expect(typeof managedSdkSpawn(() => {}, env('kilroy'))).toBe(spawnKind) expect(managedSdkSpawn(() => {}, env())).toBeUndefined() }) }) diff --git a/test/unit/claude-sidecar/mcp-servers.test.ts b/test/unit/claude-sidecar/mcp-servers.test.ts new file mode 100644 index 000000000..927341763 --- /dev/null +++ b/test/unit/claude-sidecar/mcp-servers.test.ts @@ -0,0 +1,158 @@ +// @vitest-environment node +// +// The MCP servers a freshclaude or kilroy sidecar hands the SDK's query() +// (mcp-servers.mjs), and the pane context it adds to the CLI's environment +// (api-hosted-ops plan §2.4, G19). +// +// Two recipes exist and a session gets at most one of them: +// - the ordinary recipe: the Freshell MCP command the server resolved for +// this host (`create.freshellMcp`), outside a session host; +// - the hosted recipe: the session host's fixed command, when the lane +// selected it (`create.hostedMcp`) inside a hosted Claude runtime. +// Neither ever carries an `env` key: the SDK passes the MCP config to the CLI +// on its command line (`--mcp-config `), so the endpoint and token stay +// in the inherited environment, which the MCP child receives from the CLI. + +import { describe, expect, it } from 'vitest' +// @ts-expect-error JavaScript sidecar module has no declaration file. +import { mcpServersFor, withFreshellContext } from '../../../crates/freshell-claude-sidecar/mcp-servers.mjs' +import { startRealSidecar, type RealSidecar } from './harness' + +const HOSTED_RECIPE = { freshell: { command: 'node', args: ['/opt/freshell-mcp/server.js'] } } +const ORDINARY = { command: '/usr/bin/node', args: ['--import', '/repo/tsx/loader.mjs', '/repo/tools/freshell-mcp/server.ts'] } +const hosted = (provider = 'claude') => ({ FRESHELL_HOSTED_FRESH_AGENT: provider }) + +/** The sidecar's structured log records so far (stderr, one JSON object per line). */ +function logRecords(sidecar: RealSidecar): Array> { + return sidecar.stderr().split('\n').flatMap((line) => { + try { return [JSON.parse(line)] } catch { return [] } + }) +} + +/** No server config in `servers` has an `env` key. */ +function expectNoEnvKey(servers: Record> | undefined) { + for (const config of Object.values(servers ?? {})) expect(Object.keys(config)).not.toContain('env') +} + +describe('mcpServersFor', () => { + it('hands an ordinary session the Freshell MCP command the server resolved', () => { + const servers = mcpServersFor({ freshellMcp: ORDINARY, hostedMcp: false }, {}) + expect(servers).toEqual({ freshell: ORDINARY }) + expectNoEnvKey(servers) + }) + + it('hands a hosted session that selected it the fixed hosted recipe, claude and kilroy alike', () => { + expect(mcpServersFor({ hostedMcp: true }, hosted('claude'))).toEqual(HOSTED_RECIPE) + expect(mcpServersFor({ hostedMcp: true }, hosted('kilroy'))).toEqual(HOSTED_RECIPE) + }) + + it('hands a session that has neither recipe no MCP servers', () => { + expect(mcpServersFor({}, {})).toBeUndefined() + expect(mcpServersFor({ hostedMcp: false, freshellMcp: null }, {})).toBeUndefined() + // The hosted selection means nothing outside a session host. + expect(mcpServersFor({ hostedMcp: true }, {})).toBeUndefined() + // A hosted sidecar whose lane did not select the hosted recipe gets none. + expect(mcpServersFor({ hostedMcp: false }, hosted())).toBeUndefined() + }) + + it('never hands a hosted sidecar the ordinary recipe: the hosted one wins, and alone it is ignored', () => { + const both = mcpServersFor({ hostedMcp: true, freshellMcp: ORDINARY }, hosted()) + expect(both).toEqual(HOSTED_RECIPE) + expect(mcpServersFor({ hostedMcp: false, freshellMcp: ORDINARY }, hosted())).toBeUndefined() + }) + + it('never puts an env key in a recipe, even when the request carries one', () => { + const servers = mcpServersFor({ freshellMcp: { ...ORDINARY, env: { FRESHELL_TOKEN: 'secret' } } }, {}) + expect(servers).toEqual({ freshell: ORDINARY }) + expectNoEnvKey(servers) + expectNoEnvKey(mcpServersFor({ hostedMcp: true }, hosted())) + }) + + it('leaves out a malformed ordinary recipe instead of handing the CLI a broken server, and says so', () => { + for (const freshellMcp of [ + { args: [] }, + { command: '', args: [] }, + { command: 'node', args: 'server.js' }, + { command: 'node', args: ['server.js', 7] }, + 'node server.js', + ]) { + const records: unknown[][] = [] + const log = (...record: unknown[]) => { records.push(record) } + expect(mcpServersFor({ freshellMcp }, {}, { log, fields: { sessionId: 's-1' } }), JSON.stringify(freshellMcp)).toBeUndefined() + expect(records, JSON.stringify(freshellMcp)).toEqual([ + ['warn', 'mcp.freshell_recipe_rejected', expect.objectContaining({ sessionId: 's-1' })], + ]) + } + }) + + it('logs nothing when the server sent no recipe or a well-formed one', () => { + const records: unknown[][] = [] + const log = (...record: unknown[]) => { records.push(record) } + mcpServersFor({}, {}, { log }) + mcpServersFor({ freshellMcp: null }, {}, { log }) + mcpServersFor({ freshellMcp: ORDINARY }, {}, { log }) + expect(records).toEqual([]) + }) +}) + +describe('withFreshellContext', () => { + it('adds the pane the session runs for, and only that pane, to the environment', () => { + const inherited = { PATH: '/bin', FRESHELL_URL: 'http://localhost:9', FRESHELL_TAB_ID: 'other-tab', FRESHELL_PANE_ID: 'other-pane', FRESHELL_TERMINAL_ID: 'term-1' } + expect(withFreshellContext(inherited, { tabId: 'tab-1', paneId: 'pane-1' })).toEqual({ + PATH: '/bin', FRESHELL_URL: 'http://localhost:9', FRESHELL_TAB_ID: 'tab-1', FRESHELL_PANE_ID: 'pane-1', + }) + // A context without a pane id names no pane. + expect(withFreshellContext(inherited, { tabId: 'tab-1' })).toEqual({ PATH: '/bin', FRESHELL_URL: 'http://localhost:9', FRESHELL_TAB_ID: 'tab-1' }) + }) + + it('leaves the environment alone when the request carries no context (an older server)', () => { + const inherited = { PATH: '/bin', FRESHELL_TAB_ID: 'tab-0' } + expect(withFreshellContext(inherited, undefined)).toEqual(inherited) + expect(withFreshellContext(inherited, null)).toEqual(inherited) + }) +}) + +describe('the sidecar hands query() the MCP recipe and the pane context', () => { + it('passes an ordinary recipe without env and puts the context in the CLI environment', async () => { + const sidecar = await startRealSidecar({ + env: { FRESHELL: '1', FRESHELL_URL: 'http://localhost:4100', FRESHELL_TOKEN: 'tok-mcp', FRESHELL_TAB_ID: 'stale-tab', FRESHELL_TERMINAL_ID: 'stale-terminal' }, + }) + await sidecar.create({ freshellMcp: ORDINARY, freshellContext: { tabId: 'tab-7', paneId: 'pane-7' } }) + const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') + expect(options.mcpServers).toEqual({ freshell: ORDINARY }) + expectNoEnvKey(options.mcpServers) + expect(options.envContext).toEqual({ + FRESHELL: '1', + FRESHELL_URL: 'http://localhost:4100', + FRESHELL_TAB_ID: 'tab-7', + FRESHELL_PANE_ID: 'pane-7', + }) + expect(options.envKeys).not.toContain('FRESHELL_TERMINAL_ID') + expect(options.envDigests.FRESHELL_TOKEN).toMatch(/^[0-9a-f]{12}$/) + }) + + it('passes no MCP servers when the server sent no recipe', async () => { + const sidecar = await startRealSidecar() + await sidecar.create({ freshellMcp: null, freshellContext: null }) + const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') + expect(options.mcpServers).toBeUndefined() + expect(logRecords(sidecar).filter((record) => record.event === 'mcp.freshell_recipe_rejected')).toEqual([]) + }) + + it('starts the session without a malformed recipe and logs a warning naming the session', async () => { + const sidecar = await startRealSidecar() + const sessionId = await sidecar.create({ freshellMcp: { command: 'node', args: ['server.js', 7] } }) + const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') + expect(options.mcpServers).toBeUndefined() + expect(logRecords(sidecar).filter((record) => record.event === 'mcp.freshell_recipe_rejected')).toEqual([ + expect.objectContaining({ severity: 'warn', component: 'claude-sidecar', sessionId }), + ]) + }) + + it('passes the fixed hosted recipe, never the ordinary one, in a hosted kilroy sidecar', async () => { + const sidecar = await startRealSidecar({ env: hosted('kilroy') }) + await sidecar.create({ sessionType: 'kilroy', hostedMcp: true, freshellMcp: ORDINARY }) + const { options } = await sidecar.waitForLedger((row) => row.kind === 'query', 'the query() options') + expect(options.mcpServers).toEqual(HOSTED_RECIPE) + }) +}) diff --git a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx index 93c5d6553..9a4f000e6 100644 --- a/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx +++ b/test/unit/client/components/fresh-agent/FreshAgentView.test.tsx @@ -2417,6 +2417,67 @@ describe('FreshAgentView', () => { })) }) + // G19: the agent's Freshell MCP acts for the pane it runs in, so a create + // names that pane (FRESHELL_TAB_ID / FRESHELL_PANE_ID in the agent's CLI). + it('names the pane a freshclaude create runs for', async () => { + const store = createStore() + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + sessionType: 'freshclaude', + provider: 'claude', + createRequestId: 'req-pane-context', + status: 'creating', + }, + })) + + render( + + + , + ) + + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.create')).toHaveLength(1)) + expect(sentFreshAgentMessages('freshAgent.create')[0]).toMatchObject({ + requestId: 'req-pane-context', + tabId: 'tab-1', + paneId: 'pane-1', + }) + }) + + // G19: an attach can resume the session's sidecar; the resumed agent's + // Freshell MCP acts for the pane that attached. + it('names the attaching pane on a kilroy attach', async () => { + const store = createStore() + const locator = { sessionId: CLAUDE_THREAD_ID, sessionType: 'kilroy' as const, provider: 'claude' as const } + store.dispatch(initLayout({ + tabId: 'tab-1', + paneId: 'pane-1', + content: { + kind: 'fresh-agent', + ...locator, + createRequestId: 'req-attach-context', + sessionRef: { provider: 'claude', sessionId: CLAUDE_THREAD_ID }, + status: 'idle', + }, + })) + + render( + + + , + ) + + await waitFor(() => expect(sentFreshAgentMessages('freshAgent.attach')).toHaveLength(1)) + expect(sentFreshAgentMessages('freshAgent.attach')[0]).toMatchObject({ + ...locator, + tabId: 'tab-1', + paneId: 'pane-1', + }) + }) + it('acquires a session id for a new non-Claude fresh-agent pane after freshAgent.created', async () => { const store = createStore() store.dispatch(initLayout({ @@ -3346,6 +3407,8 @@ describe('FreshAgentView', () => { provider: 'opencode', sessionRef: { provider: 'opencode', sessionId: 'ses_attach_route' }, cwd: '/repo/route-aware', + tabId: 'tab-1', + paneId: 'pane-1', }) }) expect(reconnectHandler).toBeTypeOf('function') @@ -3363,6 +3426,8 @@ describe('FreshAgentView', () => { provider: 'opencode', sessionRef: { provider: 'opencode', sessionId: 'ses_attach_route' }, cwd: '/repo/route-aware', + tabId: 'tab-1', + paneId: 'pane-1', }) }) }) @@ -7165,6 +7230,8 @@ describe('FreshAgentView', () => { sessionType: 'freshcodex', provider: 'codex', sessionRef: { provider: 'codex', sessionId: 'thread-refresh' }, + tabId: 'tab-1', + paneId: 'pane-1', }) }) await waitFor(() => { diff --git a/test/unit/shared/ws-protocol.fresh-agent-pane-context.test.ts b/test/unit/shared/ws-protocol.fresh-agent-pane-context.test.ts new file mode 100644 index 000000000..043a297a0 --- /dev/null +++ b/test/unit/shared/ws-protocol.fresh-agent-pane-context.test.ts @@ -0,0 +1,41 @@ +import { describe, expect, it } from 'vitest' +import { ClientMessageSchema } from '@shared/ws-protocol' + +// G19 (api-hosted-ops plan §2.4): a fresh-agent create and attach name the +// pane they run for, so the agent's Freshell MCP acts for that pane. The +// fields are optional; the client schema keeps them (zod strips unknown keys, +// so a field missing from the schema would never reach the server). +describe('freshAgent create/attach pane context', () => { + it('keeps the create\'s tabId and paneId', () => { + const create = { + type: 'freshAgent.create', + requestId: 'req-1', + sessionType: 'freshclaude', + provider: 'claude', + tabId: 'tab-1', + paneId: 'pane-1', + } + expect(ClientMessageSchema.parse(create)).toEqual(create) + }) + + it('keeps the attach\'s tabId and paneId', () => { + const attach = { + type: 'freshAgent.attach', + sessionId: 'session-1', + sessionType: 'kilroy', + provider: 'claude', + tabId: 'tab-1', + paneId: 'pane-1', + } + expect(ClientMessageSchema.parse(attach)).toEqual(attach) + }) + + it('still accepts frames without them, and refuses empty ids', () => { + const attach = { type: 'freshAgent.attach', sessionId: 'session-1', sessionType: 'freshclaude', provider: 'claude' } + expect(ClientMessageSchema.parse(attach)).toEqual(attach) + expect(ClientMessageSchema.safeParse({ ...attach, paneId: '' }).success).toBe(false) + expect(ClientMessageSchema.safeParse({ + type: 'freshAgent.create', requestId: 'req-2', sessionType: 'freshclaude', paneId: '', + }).success).toBe(false) + }) +})