Skip to main content

agentmux_srv/server/
editor_handlers.rs

1// Copyright 2025-2026, AgentMux Corp.
2// SPDX-License-Identifier: Apache-2.0
3
4use std::sync::Arc;
5
6use crate::backend::rpc::engine::WshRpcEngine;
7use crate::backend::rpc_types::{
8    COMMAND_WRITE_AGENT_CONFIG,
9    CommandWriteAgentConfigData, CommandWriteAgentConfigResult,
10};
11use crate::backend::agent_config::BUNDLE_SECTION_HEADING;
12use crate::backend::base::expand_home_dir_safe;
13use crate::backend::rpc_types::{
14    UnwatchMediaDirReq, WatchEditorFileReq, WatchMediaDirReq,
15    CommandReadEditorFileData, CommandReadEditorFileResult, CommandWriteEditorFileData,
16    DirEntry, EditorDrive, EditorRootsReq, GetEditorHomeResult, GetEditorRootsResult,
17    ListEditorDirReq, ListEditorDirResult,
18    CreateEditorDirReq, CreateEditorDirResult, CreateEditorFileReq, CreateEditorFileResult,
19    CreateScratchFileReq, CreateScratchFileResult, DeleteEditorFileReq, MoveScratchFileReq,
20    MoveScratchFileResult, OpenInShellReq, RenameEditorFileReq, RenameEditorFileResult,
21};
22use crate::backend::storage::store::Store;
23
24use super::AppState;
25
26// Per-process token written into scratch claim files. Stale claim files from a
27// previous process run have a different token and are ignored during reuse scans,
28// allowing crash-recovery (the scratch becomes reclaimable on restart).
29static SCRATCH_SESSION_TOKEN: std::sync::OnceLock<String> = std::sync::OnceLock::new();
30
31fn scratch_session_token() -> &'static str {
32    SCRATCH_SESSION_TOKEN.get_or_init(|| uuid::Uuid::new_v4().to_string())
33}
34
35/// The editor changes files only under the user's home folder (an allowlist).
36/// A WSL distro's file through `\\wsl.localhost` counts when it is under a
37/// distro user's home folder (`remote::wsl_fs::in_home`): the same rule, in
38/// the distro. Its `/mnt/<letter>` is a Windows drive and never counts here.
39fn editor_may_change(canonical: &std::path::Path, canonical_home: &std::path::Path) -> bool {
40    canonical.starts_with(canonical_home) || in_a_distro_home(canonical)
41}
42
43/// A home folder itself, the user's or a distro user's: never renamed or deleted.
44fn is_a_home(canonical: &std::path::Path, canonical_home: &std::path::Path) -> bool {
45    canonical == canonical_home
46        || crate::backend::remote::wsl_fs::share_of(canonical)
47            .is_some_and(|s| crate::backend::remote::wsl_fs::is_home(&s.linux))
48}
49
50fn in_a_distro_home(path: &std::path::Path) -> bool {
51    crate::backend::remote::wsl_fs::share_of(path)
52        .is_some_and(|s| crate::backend::remote::wsl_fs::in_home(&s.linux))
53}
54
55/// Enumerate drives/mounts to surface as editor file-tree roots alongside
56/// $HOME (via the `geteditorroots` RPC). On **macOS** this returns nothing, so
57/// the file-tree's roots are limited to $HOME — `/`, `/Volumes`, and external
58/// mounts aren't offered as starting points (root scoping for the tree, not a
59/// sandbox; see the note at the macOS arm). On Linux/Windows it exposes the
60/// filesystem root + mounts.
61/// Spec: docs/specs/SPEC_EDITOR_FILE_TREE_2026-05-26.md (multi-root follow-up).
62/// Also the drives section of `fs.places` (fs_handlers.rs), so the editor
63/// tree and the Files pane offer the same roots.
64#[cfg(target_os = "windows")]
65pub(super) fn list_drives() -> Vec<EditorDrive> {
66    let mut drives = Vec::new();
67    for letter in b'A'..=b'Z' {
68        let path = format!("{}:\\", letter as char);
69        if std::path::Path::new(&path).exists() {
70            drives.push(EditorDrive {
71                name: format!("{}:", letter as char),
72                path,
73            });
74        }
75    }
76    drives
77}
78
79// macOS: scope the editor file-tree ROOTS to the user's home. HOME is the sole
80// root from GetEditorRoots, so the tree doesn't offer `/`, `/Volumes`, or
81// external mounts as starting points (no nudging the user toward volumes they
82// didn't choose). NOTE: this is root scoping, not a sandbox — listeditordir /
83// readeditorfile still serve any absolute path the frontend sends, and a
84// symlink under HOME can resolve outside it; macOS TCC remains the actual gate
85// for protected locations.
86#[cfg(target_os = "macos")]
87pub(super) fn list_drives() -> Vec<EditorDrive> {
88    Vec::new()
89}
90
91// Linux (and any other non-Windows, non-macOS unix): filesystem root + mounts.
92#[cfg(all(not(target_os = "windows"), not(target_os = "macos")))]
93pub(super) fn list_drives() -> Vec<EditorDrive> {
94    let mut drives = vec![EditorDrive { name: "/".to_string(), path: "/".to_string() }];
95    for mount_dir in ["/mnt", "/media", "/Volumes"] {
96        if let Ok(entries) = std::fs::read_dir(mount_dir) {
97            for entry in entries.flatten() {
98                let path = entry.path();
99                if path.is_dir() {
100                    drives.push(EditorDrive {
101                        name: entry.file_name().to_string_lossy().to_string(),
102                        path: path.to_string_lossy().to_string(),
103                    });
104                }
105            }
106        }
107    }
108    drives
109}
110
111/// Prepend Global Memory, then `agent_block` (the agent's picked bundles,
112/// `format_agent_bundle_block`), into the `BUNDLE_SECTION_HEADING` section of
113/// a CLAUDE.md string, mirroring the injection done by
114/// `write_agent_config_files` in the `agent.open` RPC path.  If the file has
115/// no such section, a new one is inserted before `# Available Skills` (or at
116/// the end of the file).
117fn inject_global_bundles(claude_md: &str, id_store: &Arc<Store>, agent_mode: &str, agent_block: &str) -> String {
118    let bundles = crate::backend::operator_config_seed::global_bundles_for_agent(
119        id_store.bundle_list_global().unwrap_or_default(),
120        agent_mode,
121    );
122    let bundle_block = crate::backend::storage::join_startup_blocks(
123        &crate::backend::storage::format_global_bundle_block(&bundles),
124        agent_block,
125    );
126    if bundle_block.is_empty() {
127        return claude_md.to_string();
128    }
129
130    // Inject after the `BUNDLE_SECTION_HEADING` line if it exists.
131    let heading_line = format!("\n{BUNDLE_SECTION_HEADING}\n");
132    if let Some(pos) = claude_md.find(&heading_line) {
133        let insert_at = pos + heading_line.len();
134        let (before, after) = claude_md.split_at(insert_at);
135        format!("{}{}\n\n---\n\n{}", before, bundle_block, after)
136    } else {
137        // No bundle section — insert one before `# Available Skills` or append.
138        let anchor = "\n# Available Skills\n";
139        if let Some(pos) = claude_md.find(anchor) {
140            let (before, after) = claude_md.split_at(pos);
141            format!("{before}{heading_line}{bundle_block}{after}")
142        } else {
143            format!("{claude_md}{heading_line}{bundle_block}\n")
144        }
145    }
146}
147
148use crate::backend::fs_ops::remote as remote_fs;
149
150pub fn register_editor_handlers(engine: &Arc<WshRpcEngine>, state: &AppState) {
151    let id_store = state.id_store.clone();
152    let mstore = state.mstore.clone();
153    let identity_store = state.identity_store.clone();
154    let editor_file_watcher = state.editor_file_watcher.clone();
155    let media_file_watcher = state.media_file_watcher.clone();
156
157    // watcheditorfile → start live-reload watching for a (path, block_id)
158    // pair. Called by the frontend once a tab's content finishes loading.
159    // Since the migration onto the shared FsWatchPool
160    // (SPEC_SHARED_FS_WATCHER_FRAMEWORK_2026_08_07.md), the watcher itself
161    // always exists — a construction-time failure now degrades into
162    // FsWatchPool::health()'s degraded_paths instead of a missing watcher,
163    // so there's nothing to check here.
164    // Spec: docs/specs/SPEC_EDITOR_LIVE_FILE_RELOAD_2026_07_18.md
165    {
166        let watcher = editor_file_watcher.clone();
167        engine.register_typed(
168            "watcheditorfile",
169            move |cmd: WatchEditorFileReq, _ctx| {
170                let watcher = watcher.clone();
171                async move {
172                    let expanded = expand_home_dir_safe(&cmd.path);
173                    watcher.watch_path(expanded.as_path(), &cmd.block_id);
174                    Ok(())
175                }
176            },
177        );
178    }
179
180    // unwatcheditorfile → stop live-reload watching for a (path, block_id)
181    // pair. Called on tab close / pane dispose.
182    {
183        let watcher = editor_file_watcher.clone();
184        engine.register_typed(
185            "unwatcheditorfile",
186            // Same Req type as `watcheditorfile`, on purpose -- see
187            // `WatchEditorFileReq`: an unwatch that cannot name exactly what
188            // the watch named leaks a watcher.
189            move |cmd: WatchEditorFileReq, _ctx| {
190                let watcher = watcher.clone();
191                async move {
192                    let expanded = expand_home_dir_safe(&cmd.path);
193                    watcher.unwatch_path(expanded.as_path(), &cmd.block_id);
194                    Ok(())
195                }
196            },
197        );
198    }
199
200    // watchmediadir → start live-update watching for a Media pane pointed
201    // at a directory. Called by the frontend whenever the pane's target
202    // path resolves to a directory (not a single file — a fixed single
203    // file has nothing new to "watch for," it's just re-fetched on demand).
204    // Spec: docs/specs/SPEC_MEDIA_PANE_2026_07_26.md
205    {
206        let watcher = media_file_watcher.clone();
207        engine.register_typed(
208            "watchmediadir",
209            move |cmd: WatchMediaDirReq, _ctx| {
210                let watcher = watcher.clone();
211                async move {
212                    let expanded = expand_home_dir_safe(&cmd.path);
213                    watcher.watch_directory(expanded.as_path(), &cmd.block_id, &cmd.extensions);
214                    Ok(())
215                }
216            },
217        );
218    }
219
220    // unwatchmediadir → stop live-update watching. Called when the Media
221    // pane's target path changes away from that directory, or on dispose.
222    {
223        let watcher = media_file_watcher.clone();
224        engine.register_typed(
225            "unwatchmediadir",
226            move |cmd: UnwatchMediaDirReq, _ctx| {
227                let watcher = watcher.clone();
228                async move {
229                    let expanded = expand_home_dir_safe(&cmd.path);
230                    watcher.unwatch_directory(expanded.as_path(), &cmd.block_id);
231                    Ok(())
232                }
233            },
234        );
235    }
236
237    // writeagentconfig → write config files atomically to agent working directory
238    engine.register_typed(
239        COMMAND_WRITE_AGENT_CONFIG,
240        move |mut cmd: CommandWriteAgentConfigData, _ctx| {
241            let id_store = id_store.clone();
242            let mstore = mstore.clone();
243            let identity_store = identity_store.clone();
244            async move {
245                tracing::info!(
246                    working_dir = %cmd.working_dir,
247                    file_count = cmd.files.len(),
248                    auto_allocate = cmd.auto_allocate,
249                    agent_type = cmd.agent_type.as_deref().unwrap_or("host"),
250                    "WriteAgentConfig"
251                );
252                let agent_mode = cmd.agent_type.clone().unwrap_or_else(|| "host".to_string());
253                // The bundles picked for the agent go into its startup file
254                // after Global Memory. An older frontend sends no agent id:
255                // then there are none.
256                let agent_block = cmd
257                    .agent_id
258                    .as_deref()
259                    .filter(|id| !id.is_empty())
260                    .map(|id| {
261                        crate::backend::storage::format_agent_bundle_block(
262                            &crate::backend::storage::agent_picked_bundles(&mstore, &id_store, id),
263                        )
264                    })
265                    .unwrap_or_default();
266
267                // The agent's catalog servers, as `agent.open` gives them.
268                let container = agent_mode == "container";
269                crate::backend::mcp_launch::add_to_files(&mut cmd.files, &mstore, &identity_store, cmd.agent_id.as_deref(), container);
270
271                // Host-tier + LAN-tier jekt sender signing keys — see
272                // `agent_config::inject_jekt_signing_keys_into_mcp_json`'s doc
273                // comment. This is the actual "click Launch" path (per this
274                // handler's own doc below); `agent.open`'s equivalent
275                // injection alone left every normally-launched agent's
276                // jekts rendering TRUST=self-declared, since the frontend's
277                // `agent-config-builder.ts` only ever sets AGENTMUX_AGENT_ID.
278                // Best-effort — a missing/unresolvable agent slug or a
279                // key-ensure failure just means this materialization goes
280                // out unsigned, same as any other spawn.
281                if let Some(pos) = cmd.files.iter().position(|f| f.path == ".mcp.json") {
282                    let agent_slug = serde_json::from_str::<serde_json::Value>(&cmd.files[pos].content)
283                        .ok()
284                        .and_then(|v| {
285                            v.pointer("/mcpServers/agentmux/env/AGENTMUX_AGENT_ID")
286                                .and_then(|s| s.as_str())
287                                .map(str::to_string)
288                        });
289                    if let Some(agent_slug) = agent_slug {
290                        crate::server::agent_self_keys::record_config_id_drift(
291                            "m4d.config_id_drift.write_agent_config",
292                            &agent_slug,
293                            crate::server::agent_self_keys::single_row_slug_folded(&mstore, &agent_slug).as_deref(),
294                        );
295                        if let Some(rewritten) = crate::backend::agent_config::inject_jekt_signing_keys_into_mcp_json(
296                            &cmd.files[pos].content,
297                            &mstore,
298                            &agent_slug,
299                        ) {
300                            cmd.files[pos].content = rewritten;
301                        } else {
302                            tracing::warn!(
303                                agent_slug = %agent_slug,
304                                "WriteAgentConfig: jekt/LAN signing keys not injected into .mcp.json — \
305                                 this agent's jekts will render TRUST=self-declared / TRUST=network-claimed \
306                                 instead of verified"
307                            );
308                        }
309                    }
310                }
311
312                // Resolve to a final on-disk path. For auto-generated
313                // instance paths (`auto_allocate: true`), use the
314                // atomic `<base>-N` allocator so concurrent same-hour
315                // launches don't share a workdir. For user-specified
316                // paths, mkdir-p as before — never rewrite.
317                let expanded_working_dir = expand_home_dir_safe(&cmd.working_dir);
318                let final_working_dir = if cmd.auto_allocate {
319                    let desired = expanded_working_dir.to_string_lossy().to_string();
320                    crate::server::app_api::allocate_agent_workdir(&desired)?
321                } else {
322                    let p = expanded_working_dir.as_path();
323                    if !p.exists() {
324                        std::fs::create_dir_all(p) // workdir-fs: creates the workdir itself
325                            .map_err(|e| format!("failed to create working dir: {e}"))?;
326                    }
327                    expanded_working_dir.to_string_lossy().to_string()
328                };
329                let base_path = std::path::Path::new(&final_working_dir);
330                // Canonicalize base ONCE (it exists — allocate_agent_workdir
331                // or the explicit-path mkdir-p created it just above). Used
332                // by the per-file symlink-escape verifier so we catch a
333                // symlinked ancestor like `<base>/.claude -> /tmp/outside`
334                // before fs::write follows it.
335                let canonical_base = base_path.canonicalize().map_err(|e| {
336                    format!("failed to canonicalize working dir {}: {e}", base_path.display())
337                })?;
338
339                // Remove skill-derived files (.claude/commands/*.md,
340                // .claude/skills/*/SKILL.md) that WE wrote on a previous
341                // materialization but aren't part of this run's output --
342                // e.g. a skill's format switched between "prompt" and
343                // "agent-skill", or a skill was renamed/removed. This is the
344                // actual "click Launch" path used on every normal agent
345                // launch (`launchAgentDefinition`/`WriteAgentConfigCommand`)
346                // -- shared with `agent.open`'s `write_agent_config_files`
347                // (agent_open.rs) via `agent_config.rs` so the two paths
348                // that materialize config files can't drift out of sync on
349                // this (reagent P1, PR #2322 — this handler initially had
350                // no cleanup at all).
351                let wd = crate::backend::workdir_fs::Workdir::open(base_path)
352                    .map_err(|e| format!("failed to open working dir {}: {e}", base_path.display()))?;
353                let new_managed_skill_paths = crate::backend::agent_config::managed_skill_file_paths(
354                    cmd.files.iter().map(|f| f.path.as_str()),
355                );
356                crate::backend::agent_config::cleanup_stale_managed_skill_files(
357                    base_path,
358                    &new_managed_skill_paths,
359                );
360
361                for file in &cmd.files {
362                    // Lexical join + traversal check — works on Windows where
363                    // canonicalize() adds the `\\?\` UNC prefix and breaks
364                    // starts_with against not-yet-created files. Catches `..`,
365                    // absolute paths, drive-letter prefixes (root and inner).
366                    let file_path = crate::backend::base::safe_join_within_base(
367                        base_path,
368                        &file.path,
369                    )
370                    .map_err(|e| format!("path traversal denied: {} ({e})", file.path))?;
371                    // Symlink-escape guard: if any EXISTING ancestor is a
372                    // symlink that resolves outside the workdir, reject.
373                    // No-op for fully-fresh agent dirs (the common case
374                    // where every component is new).
375                    // Like a refused write below, this skips that one file
376                    // rather than failing the launch, as agent.open does
377                    // (Codex on #4141).
378                    if let Err(e) = crate::backend::base::verify_no_symlink_escape(&file_path, &canonical_base) {
379                        tracing::warn!(path = %file.path, error = %e, "writeagentconfig: not writing a config file under a folder linking outside the workdir");
380                        continue;
381                    }
382                    // Inject Global Memory into CLAUDE.md so agents
383                    // launched from the picker receive the same workspace rules
384                    // as agents launched via the agent.open RPC.
385                    if file.path == "CLAUDE.md" {
386                        // Ownership-aware materialization instead of the
387                        // generic write below — never overwrites a
388                        // pre-existing, non-AgentMux-authored file's
389                        // content. See
390                        // docs/specs/SPEC_CLAUDE_MD_OWNERSHIP_PROTECTION_2026_08_22.md.
391                        let content = inject_global_bundles(&file.content, &id_store, &agent_mode, &agent_block);
392                        crate::backend::agent_config::write_claude_md_respecting_ownership(
393                            base_path, &content,
394                        )
395                        .map_err(|e| format!("failed to write CLAUDE.md: {e}"))?;
396                        tracing::debug!(path = %file_path.display(), "wrote config file (ownership-aware)");
397                        continue;
398                    }
399                    // This RPC command carries no provider ID (`CommandWriteAgentConfigData`
400                    // is just `{working_dir, files}` — the frontend already
401                    // resolved everything), so a non-Claude provider's
402                    // startup-instructions file (AGENTS.md, GEMINI.md,
403                    // QWEN.md, .pi/APPEND_SYSTEM.md, ...) is recognized by
404                    // filename membership against the provider registry,
405                    // not a literal string match.
406                    if crate::backend::providers::is_known_startup_instructions_filename(&file.path) {
407                        // Still gets the same Global Memory injection
408                        // CLAUDE.md gets above — otherwise a Codex/Gemini/etc.
409                        // agent launched from the picker would silently
410                        // receive no workspace-wide Global Bundle content at
411                        // all.
412                        let content = inject_global_bundles(&file.content, &id_store, &agent_mode, &agent_block);
413                        // Never overwrites a pre-existing file (codex P1, PR
414                        // #2788) — a simpler exists-guard than CLAUDE.md's
415                        // full ownership-aware materialization above; see
416                        // write_startup_instructions_respecting_existing's
417                        // own doc comment for why a fuller mechanism (the
418                        // @import-based side-file fallback) isn't attempted
419                        // here.
420                        crate::backend::agent_config::write_startup_instructions_respecting_existing(
421                            base_path, &file.path, &content,
422                        )
423                        .map_err(|e| format!("failed to write {}: {e}", file.path))?;
424                        tracing::debug!(path = %file_path.display(), "wrote config file (exists-guarded)");
425                        continue;
426                    }
427                    // `.mcp.json` merges into a user's own file instead of
428                    // replacing it, and is written owner-only (#3680) — see
429                    // write_mcp_json_respecting_user_servers's doc comment.
430                    if file.path == ".mcp.json" {
431                        crate::backend::agent_config::write_mcp_json_respecting_user_servers(
432                            base_path,
433                            &file.content,
434                        )
435                        .map_err(|e| format!("failed to write .mcp.json: {e}"))?;
436                        tracing::debug!(path = %file_path.display(), "wrote config file (merged, owner-only)");
437                        continue;
438                    }
439                    // Atomic, parents created inside the workdir, never
440                    // through a symlinked file (SPEC_WORKDIR_SAFE_WRITES_2026_10_01).
441                    // A refusal skips that one file, as agent.open does
442                    // (ReAgent on #4141); any other failure fails the launch.
443                    match wd.write(&file.path, file.content.as_bytes(), false) {
444                        Err(e) if e.kind() == std::io::ErrorKind::PermissionDenied => {
445                            tracing::warn!(path = %file.path, error = %e, "writeagentconfig: not writing a config file");
446                        }
447                        other => {
448                            other.map_err(|e| format!("failed to write {}: {e}", file.path))?;
449                            tracing::debug!(path = %file_path.display(), "wrote config file");
450                        }
451                    }
452                }
453
454                crate::backend::agent_config::write_managed_skill_file_manifest(
455                    base_path,
456                    &new_managed_skill_paths,
457                );
458
459                // Return the final path so the caller can patch
460                // `cmd:cwd` if collision resolution changed it.
461                Ok(CommandWriteAgentConfigResult { working_dir: final_working_dir })
462            }
463        },
464    );
465
466    // readeditorfile → read file from disk for the editor pane
467    let auth_key = state.auth_key.clone();
468    engine.register_typed(
469        "readeditorfile",
470        move |cmd: CommandReadEditorFileData, _ctx| {
471            let auth_key = auth_key.clone();
472            async move {
473                // A file on an SSH host: read there by its helper, decoded
474                // here the same way (remote terminals spec §6.3).
475                if let Some(conn) = remote_fs::ssh_connection(cmd.connection.as_deref()) {
476                    let r = remote_fs::Remote {
477                        connection: conn,
478                        block_id: cmd.block_id.as_deref(),
479                        auth_key: &auth_key,
480                    };
481                    let (bytes, read_only) = remote_fs::read_file(r, &cmd.path)
482                        .await
483                        .map_err(|e| format!("readeditorfile: {e}"))?;
484                    let decoded = crate::backend::text_encoding::decode_file(&bytes);
485                    return Ok(CommandReadEditorFileResult {
486                        content: decoded.content,
487                        encoding: decoded.encoding,
488                        bom: decoded.bom.to_string(),
489                        line_ending: decoded.line_ending.to_string(),
490                        had_decode_errors: decoded.had_decode_errors,
491                        read_only,
492                    });
493                }
494                let expanded = expand_home_dir_safe(&cmd.path);
495                let path = expanded.as_path();
496
497                // Size guard: reject files > 10MB
498                let metadata = std::fs::metadata(path)
499                    .map_err(|e| format!("readeditorfile: {e}"))?;
500                if metadata.len() > 10_000_000 {
501                    return Err("File too large (>10MB)".to_string());
502                }
503
504                // Read bytes and detect the encoding (BOM → UTF-8 → heuristic),
505                // decoding to UTF-8 for the editor. This lets non-UTF-8 files
506                // (Windows-1252 .ini, UTF-16, Shift_JIS, …) open instead of
507                // erroring on read_to_string. The detected encoding/bom/line
508                // ending ride back so save round-trips the original format.
509                // See docs/specs/SPEC_EDITOR_FILE_ENCODINGS_2026_06_17.md.
510                let bytes = std::fs::read(path)
511                    .map_err(|e| format!("readeditorfile: {e}"))?;
512                let decoded = crate::backend::text_encoding::decode_file(&bytes);
513                let read_only = metadata.permissions().readonly();
514
515                Ok(CommandReadEditorFileResult {
516                    content: decoded.content,
517                    encoding: decoded.encoding,
518                    bom: decoded.bom.to_string(),
519                    line_ending: decoded.line_ending.to_string(),
520                    had_decode_errors: decoded.had_decode_errors,
521                    read_only,
522                })
523            }
524        },
525    );
526
527    // writeeditorfile → write file to disk from the editor pane
528    let auth_key = state.auth_key.clone();
529    engine.register_typed(
530        "writeeditorfile",
531        move |cmd: CommandWriteEditorFileData, _ctx| {
532            let auth_key = auth_key.clone();
533            async move {
534                // Size guard: match readeditorfile's 10MB limit
535                if cmd.content.len() > 10_000_000 {
536                    return Err("Content too large (>10MB)".to_string());
537                }
538
539                // A file on an SSH host: encoded here as for a local file,
540                // then written there atomically by its helper.
541                if let Some(conn) = remote_fs::ssh_connection(cmd.connection.as_deref()) {
542                    let (out_bytes, had_unmappable) = crate::backend::text_encoding::encode_file(
543                        &cmd.content,
544                        cmd.encoding.as_deref().unwrap_or(""),
545                        cmd.bom.as_deref().unwrap_or("none"),
546                        cmd.line_ending.as_deref().unwrap_or("lf"),
547                    );
548                    if had_unmappable {
549                        return Err("writeeditorfile: file contains characters that can't be represented in its encoding — save it as UTF-8 instead".to_string());
550                    }
551                    let r = remote_fs::Remote {
552                        connection: conn,
553                        block_id: cmd.block_id.as_deref(),
554                        auth_key: &auth_key,
555                    };
556                    return remote_fs::write_file(r, &cmd.path, out_bytes).await;
557                }
558
559                let expanded = expand_home_dir_safe(&cmd.path);
560                let path = expanded.as_path();
561
562                // Path safety: restrict writes to under the user's home directory.
563                // Allowlist approach — safer than an incomplete denylist.
564                let home = dirs::home_dir()
565                    .ok_or("writeeditorfile: cannot determine home directory")?;
566                let canonical_home = home.canonicalize()
567                    .map_err(|e| format!("writeeditorfile: home dir: {e}"))?;
568
569                // Resolve the target path (canonicalize existing, or parent + filename for new files)
570                let canonical = path.canonicalize().or_else(|_| {
571                    path.parent()
572                        .and_then(|p| p.canonicalize().ok())
573                        .map(|p| p.join(path.file_name().unwrap_or_default()))
574                        .ok_or_else(|| std::io::Error::new(std::io::ErrorKind::NotFound, "invalid path"))
575                }).map_err(|e| format!("writeeditorfile: {e}"))?;
576
577                if !editor_may_change(&canonical, &canonical_home) {
578                    return Err(format!(
579                        "writeeditorfile: path {} is outside home directory",
580                        canonical.display()
581                    ));
582                }
583
584                // Re-encode to the file's original encoding (+ BOM + line
585                // endings) so a non-UTF-8 file round-trips instead of being
586                // silently rewritten as UTF-8. Absent fields default to UTF-8.
587                let (out_bytes, had_unmappable) = crate::backend::text_encoding::encode_file(
588                    &cmd.content,
589                    cmd.encoding.as_deref().unwrap_or(""),
590                    cmd.bom.as_deref().unwrap_or("none"),
591                    cmd.line_ending.as_deref().unwrap_or("lf"),
592                );
593                // Refuse a lossy write: rather than silently emit
594                // numeric-character-reference replacements (corrupting the
595                // file), fail loudly so the user can save as UTF-8. The UI for
596                // an in-place "Save with Encoding → UTF-8" is Phase 3 of
597                // SPEC_EDITOR_FILE_ENCODINGS.
598                if had_unmappable {
599                    let enc = cmd
600                        .encoding
601                        .as_deref()
602                        .filter(|s| !s.is_empty())
603                        .unwrap_or("its current encoding");
604                    return Err(format!(
605                        "writeeditorfile: file contains characters that can't be represented in {enc} — save it as UTF-8 instead"
606                    ));
607                }
608                std::fs::write(&canonical, &out_bytes)
609                    .map_err(|e| format!("writeeditorfile: {e}"))?;
610                tracing::info!(path = %canonical.display(), bytes = out_bytes.len(), "editor file saved");
611
612                Ok(())
613            }
614        },
615    );
616
617    // listeditordir → list directory contents for the editor's file-tree pane.
618    // Symlinks are followed (matches VS Code semantics; the frontend marks
619    // followed symlinks with a ↗ overlay).
620    // Spec: docs/specs/SPEC_EDITOR_FILE_TREE_2026-05-26.md
621    engine.register_typed(
622        "listeditordir",
623        |cmd: ListEditorDirReq, _ctx| {
624            async move {
625                let expanded = expand_home_dir_safe(&cmd.path);
626                let canonical = expanded.canonicalize()
627                    .map_err(|e| format!("listeditordir: {e}"))?;
628                let read_dir = std::fs::read_dir(&canonical)
629                    .map_err(|e| format!("listeditordir: {e}"))?;
630
631                let mut entries: Vec<DirEntry> = Vec::new();
632                for entry in read_dir.flatten() {
633                    let name = entry.file_name().to_string_lossy().to_string();
634                    // `file_type()` returns the entry's own type — symlinks
635                    // appear as symlinks (no follow). `metadata()` follows
636                    // symlinks, so a symlinked dir reports is_dir=true.
637                    // We need both: is_symlink from file_type, is_dir/size
638                    // from metadata (so a symlink-to-dir reads as a directory,
639                    // matching VS Code).
640                    let is_symlink = entry
641                        .file_type()
642                        .map(|t| t.is_symlink())
643                        .unwrap_or(false);
644                    let metadata = entry.metadata().ok();
645                    let is_dir = metadata.as_ref().map(|m| m.is_dir()).unwrap_or(false);
646                    let size = metadata
647                        .as_ref()
648                        .and_then(|m| if !m.is_dir() { Some(m.len()) } else { None });
649                    let mtime = metadata
650                        .as_ref()
651                        .and_then(|m| m.modified().ok())
652                        .and_then(|t| t.duration_since(std::time::UNIX_EPOCH).ok())
653                        .map(|d| d.as_millis() as u64);
654
655                    entries.push(DirEntry { name, is_dir, is_symlink, size, mtime });
656                }
657
658                // Folders first, then files; alphabetical, case-insensitive.
659                // Reads the fields directly now rather than re-parsing them
660                // back out of a `Value` -- the old `a["is_dir"].as_bool()`
661                // silently sorted everything as a file if that key were ever
662                // renamed.
663                entries.sort_by(|a, b| {
664                    match (a.is_dir, b.is_dir) {
665                        (true, false) => std::cmp::Ordering::Less,
666                        (false, true) => std::cmp::Ordering::Greater,
667                        _ => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
668                    }
669                });
670
671                Ok(ListEditorDirResult {
672                    path: canonical.to_string_lossy().into_owned(),
673                    entries,
674                })
675            }
676        },
677    );
678
679    // geteditorhome → OS home directory, used as the editor file-tree default root.
680    engine.register_typed(
681        "geteditorhome",
682        // `Option<_>` -- see `EditorRootsReq`: it is what makes both encodings
683        // of "no argument" deserialize.
684        |_req: Option<EditorRootsReq>, _ctx| {
685            async move {
686                let home = dirs::home_dir()
687                    .ok_or_else(|| "geteditorhome: cannot determine home directory".to_string())?;
688                Ok(GetEditorHomeResult {
689                    home: home.to_string_lossy().into_owned(),
690                })
691            }
692        },
693    );
694
695    // geteditorroots → home + (Linux/Windows) the filesystem root and mounts,
696    // rendered as sibling top-level roots. On macOS `list_drives` returns none,
697    // so the editor file-tree is scoped to $HOME only.
698    // Spec: docs/specs/SPEC_EDITOR_FILE_TREE_2026-05-26.md (multi-root follow-up)
699    engine.register_typed(
700        "geteditorroots",
701        |_req: Option<EditorRootsReq>, _ctx| {
702            async move {
703                let home = dirs::home_dir()
704                    .ok_or_else(|| "geteditorroots: cannot determine home directory".to_string())?;
705                // WSL distros after the drives, at their roots through the share.
706                let mut drives = list_drives();
707                drives.extend(crate::backend::remote::wsl::list_cached().await.into_iter().map(|d| {
708                    EditorDrive {
709                        path: crate::backend::remote::wsl_fs::share_path(&d, "/"),
710                        name: format!("{d} (WSL)"),
711                    }
712                }));
713                Ok(GetEditorRootsResult {
714                    home: home.to_string_lossy().into_owned(),
715                    drives,
716                })
717            }
718        },
719    );
720
721    // ── Editor file-tree mutations ─────────────────────────────────────
722    // Spec: docs/specs/SPEC_FILE_TREE_CONTEXT_MENU_2026_06_14.md
723    // All handlers validate the target path is under the user's home directory
724    // before performing any mutation — same policy as writeeditorfile.
725
726    // openinshell → reveal a path in the OS file manager
727    engine.register_typed(
728        "openinshell",
729        |cmd: OpenInShellReq, _ctx| {
730            async move {
731                let expanded = expand_home_dir_safe(&cmd.path);
732                let path = expanded.as_path();
733                let home = dirs::home_dir()
734                    .ok_or("openinshell: cannot determine home directory")?;
735                let canonical_home = home.canonicalize()
736                    .map_err(|e| format!("openinshell: home: {e}"))?;
737                let canonical = path.canonicalize()
738                    .map_err(|e| format!("openinshell: {e}"))?;
739                if !editor_may_change(&canonical, &canonical_home) {
740                    return Err(format!("openinshell: path outside home directory"));
741                }
742                // The file manager is third-party and long-lived, and a user can
743                // launch anything from it — including another AgentMux build,
744                // which would then adopt THIS instance's channel and data dir
745                // (invariant I7; retro-env-inheritance-instance-isolation-breach
746                // -2026-09-17 §2.2). So: strict policy, not the pane keep-set.
747                #[cfg(target_os = "windows")]
748                {
749                    let mut cmd = std::process::Command::new("explorer.exe");
750                    crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
751                    let _ = cmd.arg(format!("/select,{}", canonical.display())).spawn();
752                }
753                #[cfg(target_os = "macos")]
754                {
755                    let mut cmd = std::process::Command::new("open");
756                    crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
757                    let _ = cmd.arg("-R").arg(&canonical).spawn();
758                }
759                #[cfg(target_os = "linux")]
760                {
761                    let target = if canonical.is_dir() { canonical.clone() } else { canonical.parent().unwrap_or(&canonical).to_path_buf() };
762                    let mut cmd = std::process::Command::new("xdg-open");
763                    crate::backend::pane_env::sanitize_external_std_command(&mut cmd);
764                    let _ = cmd.arg(&target).spawn();
765                }
766                Ok(())
767            }
768        },
769    );
770
771    // renameeditorfile → rename a file or folder (name only, same parent directory)
772    engine.register_typed(
773        "renameeditorfile",
774        |cmd: RenameEditorFileReq, _ctx| {
775            async move {
776                let expanded = expand_home_dir_safe(&cmd.old_path);
777                let old_path = expanded.as_path();
778                let home = dirs::home_dir().ok_or("renameeditorfile: cannot determine home")?;
779                let canonical_home = home.canonicalize().map_err(|e| format!("renameeditorfile: home: {e}"))?;
780                let canonical_old = old_path.canonicalize().map_err(|e| format!("renameeditorfile: {e}"))?;
781                if !editor_may_change(&canonical_old, &canonical_home) || is_a_home(&canonical_old, &canonical_home) {
782                    return Err("renameeditorfile: path outside or is the home directory".to_string());
783                }
784                if cmd.new_name.contains('/') || cmd.new_name.contains('\\') || cmd.new_name.contains('\0') || cmd.new_name == ".." || cmd.new_name == "." || cmd.new_name.is_empty() {
785                    return Err("renameeditorfile: new_name must be a plain filename".to_string());
786                }
787                let new_path = canonical_old.parent()
788                    .ok_or("renameeditorfile: no parent directory")?
789                    .join(&cmd.new_name);
790                if new_path.exists() {
791                    return Err(format!("renameeditorfile: destination already exists"));
792                }
793                std::fs::rename(&canonical_old, &new_path)
794                    .map_err(|e| format!("renameeditorfile: {e}"))?;
795                Ok(RenameEditorFileResult { new_path: new_path.to_string_lossy().into_owned() })
796            }
797        },
798    );
799
800    // createeditorfile → create an empty file inside an existing directory
801    engine.register_typed(
802        "createeditorfile",
803        |cmd: CreateEditorFileReq, _ctx| {
804            async move {
805                let expanded = expand_home_dir_safe(&cmd.parent_path);
806                let parent = expanded.as_path();
807                let home = dirs::home_dir().ok_or("createeditorfile: cannot determine home")?;
808                let canonical_home = home.canonicalize().map_err(|e| format!("createeditorfile: home: {e}"))?;
809                let canonical_parent = parent.canonicalize().map_err(|e| format!("createeditorfile: {e}"))?;
810                if !editor_may_change(&canonical_parent, &canonical_home) {
811                    return Err("createeditorfile: path outside home directory".to_string());
812                }
813                if cmd.name.contains('/') || cmd.name.contains('\\') || cmd.name.contains('\0') || cmd.name == "." || cmd.name == ".." || cmd.name.is_empty() {
814                    return Err("createeditorfile: name must be a plain filename".to_string());
815                }
816                let file_path = canonical_parent.join(&cmd.name);
817                if file_path.exists() {
818                    return Err("createeditorfile: file already exists".to_string());
819                }
820                std::fs::write(&file_path, "").map_err(|e| format!("createeditorfile: {e}"))?;
821                Ok(CreateEditorFileResult { file_path: file_path.to_string_lossy().into_owned() })
822            }
823        },
824    );
825
826    // createeditordir → create a directory inside an existing directory
827    engine.register_typed(
828        "createeditordir",
829        |cmd: CreateEditorDirReq, _ctx| {
830            async move {
831                let expanded = expand_home_dir_safe(&cmd.parent_path);
832                let parent = expanded.as_path();
833                let home = dirs::home_dir().ok_or("createeditordir: cannot determine home")?;
834                let canonical_home = home.canonicalize().map_err(|e| format!("createeditordir: home: {e}"))?;
835                let canonical_parent = parent.canonicalize().map_err(|e| format!("createeditordir: {e}"))?;
836                if !editor_may_change(&canonical_parent, &canonical_home) {
837                    return Err("createeditordir: path outside home directory".to_string());
838                }
839                if cmd.name.contains('/') || cmd.name.contains('\\') || cmd.name.contains('\0') || cmd.name == "." || cmd.name == ".." || cmd.name.is_empty() {
840                    return Err("createeditordir: name must be a plain name".to_string());
841                }
842                let dir_path = canonical_parent.join(&cmd.name);
843                if dir_path.exists() {
844                    return Err("createeditordir: already exists".to_string());
845                }
846                std::fs::create_dir(&dir_path).map_err(|e| format!("createeditordir: {e}"))?;
847                Ok(CreateEditorDirResult { dir_path: dir_path.to_string_lossy().into_owned() })
848            }
849        },
850    );
851
852    // deleteeditorfile → delete a file or directory
853    engine.register_typed(
854        "deleteeditorfile",
855        |cmd: DeleteEditorFileReq, _ctx| {
856            async move {
857                let expanded = expand_home_dir_safe(&cmd.path);
858                let path = expanded.as_path();
859                let home = dirs::home_dir().ok_or("deleteeditorfile: cannot determine home")?;
860                let canonical_home = home.canonicalize().map_err(|e| format!("deleteeditorfile: home: {e}"))?;
861                let canonical = path.canonicalize().map_err(|e| format!("deleteeditorfile: {e}"))?;
862                if !editor_may_change(&canonical, &canonical_home) || is_a_home(&canonical, &canonical_home) {
863                    return Err("deleteeditorfile: path outside or is the home directory".to_string());
864                }
865                // Use symlink_metadata (not metadata) to detect symlinks without following them.
866                // Deleting via `canonical` would delete the symlink TARGET; we always remove the
867                // entry at the original path so the symlink itself is unlinked.
868                let meta = std::fs::symlink_metadata(path)
869                    .map_err(|e| format!("deleteeditorfile: {e}"))?;
870                if meta.file_type().is_symlink() {
871                    // On Windows, directory junctions/symlinks must use remove_dir;
872                    // remove_file fails for directory symlinks on that platform.
873                    #[cfg(windows)]
874                    if path.is_dir() {
875                        std::fs::remove_dir(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
876                    } else {
877                        std::fs::remove_file(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
878                    }
879                    #[cfg(not(windows))]
880                    std::fs::remove_file(path).map_err(|e| format!("deleteeditorfile: {e}"))?;
881                } else if canonical.is_dir() {
882                    if cmd.recursive {
883                        std::fs::remove_dir_all(&canonical).map_err(|e| format!("deleteeditorfile: {e}"))?;
884                    } else {
885                        std::fs::remove_dir(&canonical).map_err(|e| format!("deleteeditorfile: directory not empty: {e}"))?;
886                    }
887                } else {
888                    std::fs::remove_file(&canonical).map_err(|e| format!("deleteeditorfile: {e}"))?;
889                }
890                Ok(())
891            }
892        },
893    );
894
895    // ── Scratch file service ────────────────────────────────────────────
896    // Spec: docs/specs/SPEC_EDITOR_WIDGET_DEFAULT_UX_2026_06_14.md
897
898    // createscratchfile → create a scratch buffer file in ~/.agentmux/cache/scratch/
899    engine.register_typed(
900        "createscratchfile",
901        |cmd: CreateScratchFileReq, _ctx| {
902            async move {
903                let home = dirs::home_dir()
904                    .ok_or("createscratchfile: cannot determine home directory")?;
905                let scratch_dir = home.join(".agentmux").join("cache").join("scratch");
906                std::fs::create_dir_all(&scratch_dir)
907                    .map_err(|e| format!("createscratchfile: create dir: {e}"))?;
908                let now_ms = agentmux_common::time::now_ms_u64();
909                const THIRTY_DAYS_MS: u64 = 30 * 24 * 60 * 60 * 1000;
910                let session_token = scratch_session_token();
911
912                // Phase 1: scan .md.meta files, collect candidates and prune expired pairs.
913                // A candidate is: not saved, not expired, not in exclude_scratch_ids.
914                let mut candidates: Vec<(u64, String, String)> = Vec::new(); // (created_at, sid, display_name)
915                if let Ok(entries) = std::fs::read_dir(&scratch_dir) {
916                    for entry in entries.flatten() {
917                        let meta_path = entry.path();
918                        let fname = meta_path
919                            .file_name()
920                            .and_then(|f| f.to_str())
921                            .unwrap_or("")
922                            .to_string();
923                        if !fname.ends_with(".md.meta") {
924                            continue;
925                        }
926                        let Ok(content) = std::fs::read_to_string(&meta_path) else { continue };
927                        let Ok(meta_json) = serde_json::from_str::<serde_json::Value>(&content) else { continue };
928                        if meta_json.get("saved_to").map_or(false, |v| !v.is_null()) {
929                            continue;
930                        }
931                        let created_at = meta_json.get("created_at").and_then(|v| v.as_u64()).unwrap_or(0);
932                        let sid = meta_json.get("scratch_id").and_then(|v| v.as_str()).unwrap_or("").to_string();
933                        if created_at == 0 || sid.is_empty() {
934                            continue;
935                        }
936                        if cmd.exclude_scratch_ids.as_deref().map_or(false, |ex| ex.iter().any(|e| e == &sid)) {
937                            continue;
938                        }
939                        if now_ms.saturating_sub(created_at) > THIRTY_DAYS_MS {
940                            // Prune expired pair (all three files: .md, .md.meta, .md.claim).
941                            let _ = std::fs::remove_file(scratch_dir.join(format!("{}.md", sid)));
942                            let _ = std::fs::remove_file(&meta_path);
943                            let _ = std::fs::remove_file(scratch_dir.join(format!("{}.md.claim", sid)));
944                            continue;
945                        }
946                        let dname = meta_json
947                            .get("display_name")
948                            .and_then(|v| v.as_str())
949                            .unwrap_or("Untitled")
950                            .to_string();
951                        candidates.push((created_at, sid, dname));
952                    }
953                }
954
955                // Phase 2: try to atomically claim the most-recent candidate.
956                // Claim files prevent two concurrent createscratchfile calls from
957                // returning the same backing file to different panes. Each claim file
958                // stores the per-process session token; a claim from a previous process
959                // (crash/restart) has a different token and is cleared so recovery works.
960                candidates.sort_by(|a, b| b.0.cmp(&a.0)); // most recent first
961                let mut chosen: Option<(String, String)> = None;
962                for (_, sid, dname) in &candidates {
963                    let claim_path = scratch_dir.join(format!("{}.md.claim", sid));
964                    // Evict stale claims from a previous process session.
965                    if claim_path.exists() {
966                        if let Ok(tok) = std::fs::read_to_string(&claim_path) {
967                            if tok.trim() == session_token {
968                                // Valid live claim — another concurrent call already owns this.
969                                continue;
970                            }
971                        }
972                        // Stale claim (different session) — remove so we can reclaim.
973                        let _ = std::fs::remove_file(&claim_path);
974                    }
975                    // Try to exclusively create the claim file. If another concurrent
976                    // call wins the race, it will have already created it → we skip.
977                    match std::fs::OpenOptions::new().write(true).create_new(true).open(&claim_path) {
978                        Ok(mut f) => {
979                            use std::io::Write as _;
980                            let _ = f.write_all(session_token.as_bytes());
981                            let file_path = scratch_dir.join(format!("{}.md", sid));
982                            if file_path.exists() {
983                                chosen = Some((sid.clone(), dname.clone()));
984                            } else {
985                                // Backing file gone (deleted externally) — release claim.
986                                let _ = std::fs::remove_file(&claim_path);
987                            }
988                        }
989                        Err(_) => continue, // Concurrent call claimed this one first.
990                    }
991                    if chosen.is_some() {
992                        break;
993                    }
994                }
995
996                if let Some((scratch_id, display_name)) = chosen {
997                    let file_path = scratch_dir.join(format!("{}.md", scratch_id));
998                    return Ok(CreateScratchFileResult {
999                        scratch_id,
1000                        file_path: file_path.to_string_lossy().into_owned(),
1001                        display_name,
1002                    });
1003                }
1004
1005                // No reusable candidate — mint a fresh UUID pair.
1006                let scratch_id = uuid::Uuid::new_v4().to_string();
1007                let display_name = cmd.display_name.unwrap_or_else(|| "Untitled".to_string());
1008                let file_path = scratch_dir.join(format!("{}.md", scratch_id));
1009                std::fs::write(&file_path, "")
1010                    .map_err(|e| format!("createscratchfile: {e}"))?;
1011                let meta = serde_json::json!({
1012                    "display_name": display_name,
1013                    "scratch_id": scratch_id,
1014                    "created_at": now_ms,
1015                });
1016                let meta_path = scratch_dir.join(format!("{}.md.meta", scratch_id));
1017                std::fs::write(&meta_path, meta.to_string())
1018                    .map_err(|e| format!("createscratchfile: meta: {e}"))?;
1019                // Claim the fresh scratch so subsequent calls don't immediately reuse it.
1020                let claim_path = scratch_dir.join(format!("{}.md.claim", scratch_id));
1021                let _ = std::fs::write(&claim_path, session_token);
1022                Ok(CreateScratchFileResult {
1023                    scratch_id,
1024                    file_path: file_path.to_string_lossy().into_owned(),
1025                    display_name,
1026                })
1027            }
1028        },
1029    );
1030
1031    // movescratchfile → promote a scratch buffer to a real user-chosen path (Save As)
1032    engine.register_typed(
1033        "movescratchfile",
1034        |cmd: MoveScratchFileReq, _ctx| {
1035            async move {
1036                let home = dirs::home_dir()
1037                    .ok_or("movescratchfile: cannot determine home")?;
1038                let canonical_home = home.canonicalize()
1039                    .map_err(|e| format!("movescratchfile: home: {e}"))?;
1040                // Validate scratch_id has no path components.
1041                if cmd.scratch_id.contains('/') || cmd.scratch_id.contains('\\') || cmd.scratch_id.contains("..") {
1042                    return Err("movescratchfile: invalid scratch_id".to_string());
1043                }
1044                let scratch_dir = home.join(".agentmux").join("cache").join("scratch");
1045                let scratch_path = scratch_dir.join(format!("{}.md", cmd.scratch_id));
1046                if !scratch_path.exists() {
1047                    return Err(format!("movescratchfile: scratch file not found"));
1048                }
1049                // Validate + resolve destination.
1050                let expanded_dest = expand_home_dir_safe(&cmd.destination_path);
1051                let dest_path = expanded_dest.as_path();
1052
1053                // Coarse home-boundary check before any filesystem mutation.
1054                // We use the unexpanded path here because the dest may not exist yet
1055                // (canonicalize fails on non-existent paths).
1056                if !dest_path.starts_with(&home) && !in_a_distro_home(dest_path) {
1057                    return Err("movescratchfile: destination outside home directory".to_string());
1058                }
1059                // Reject ".." components before create_dir_all — the coarse starts_with
1060                // check is purely lexical and passes paths like ~/foo/../../../tmp/evil
1061                // because the prefix matches, but create_dir_all would then materialize
1062                // directories outside the home boundary before the canonical check fires.
1063                if dest_path.components().any(|c| c == std::path::Component::ParentDir) {
1064                    return Err("movescratchfile: destination path must not contain '..' components".to_string());
1065                }
1066                // Reject existing destination to avoid silent data loss.
1067                if dest_path.exists() {
1068                    return Err("movescratchfile: destination already exists".to_string());
1069                }
1070                // Create parent directories if needed.
1071                if let Some(parent) = dest_path.parent() {
1072                    std::fs::create_dir_all(parent)
1073                        .map_err(|e| format!("movescratchfile: create dirs: {e}"))?;
1074                }
1075                // Canonicalize parent (now guaranteed to exist) + append filename.
1076                let canonical_dest = dest_path.parent()
1077                    .and_then(|p| p.canonicalize().ok())
1078                    .map(|p| p.join(dest_path.file_name().unwrap_or_default()))
1079                    .ok_or_else(|| "movescratchfile: invalid destination path".to_string())?;
1080                // Fine-grained home-boundary check with canonical paths.
1081                if !editor_may_change(&canonical_dest, &canonical_home) {
1082                    return Err("movescratchfile: destination outside home directory".to_string());
1083                }
1084                // Copy then remove scratch source (cross-device-safe move).
1085                std::fs::copy(&scratch_path, &canonical_dest)
1086                    .map_err(|e| format!("movescratchfile: copy: {e}"))?;
1087                std::fs::remove_file(&scratch_path)
1088                    .map_err(|e| format!("movescratchfile: remove scratch: {e}"))?;
1089                // Clean up the .meta sidecar and the claim file.
1090                let meta_path = scratch_dir.join(format!("{}.md.meta", cmd.scratch_id));
1091                let _ = std::fs::remove_file(&meta_path);
1092                let claim_path = scratch_dir.join(format!("{}.md.claim", cmd.scratch_id));
1093                let _ = std::fs::remove_file(&claim_path);
1094                Ok(MoveScratchFileResult { file_path: canonical_dest.to_string_lossy().into_owned() })
1095            }
1096        },
1097    );
1098}
1099
1100#[cfg(test)]
1101mod tests {
1102    use crate::backend::rpc_types::AgentConfigFile;
1103    use crate::backend::rpc_types::{
1104        CommandReadEditorFileResult, CreateScratchFileReq, DeleteEditorFileReq, DirEntry,
1105    };
1106
1107    /// The picker launch path puts the agent's picked bundles after Global
1108    /// Memory under `# Memory`, as `agent.open` does, and still adds them
1109    /// when there is no Global Memory.
1110    #[test]
1111    fn the_startup_file_gets_global_memory_then_the_picked_bundles() {
1112        use crate::backend::storage::store::{Bundle, Store};
1113        let id_store = std::sync::Arc::new(Store::open_in_memory().unwrap());
1114        let file = "# Agent\n\n# Memory\nown notes\n";
1115        let picks = "# [Bundle] Writer\n\nwrite plainly";
1116
1117        let only_picks = super::inject_global_bundles(file, &id_store, "host", picks);
1118        assert_eq!(only_picks, "# Agent\n\n# Memory\n# [Bundle] Writer\n\nwrite plainly\n\n---\n\nown notes\n");
1119
1120        id_store
1121            .bundle_upsert(&Bundle {
1122                id: "g1".into(),
1123                name: "Rules".into(),
1124                description: String::new(),
1125                is_blank: false,
1126                is_global: true,
1127                provider: String::new(),
1128                model: String::new(),
1129                instructions: "global rule".into(),
1130                instructions_by_provider: "{}".into(),
1131                context_files: "[]".into(),
1132                mcp_servers: "[]".into(),
1133                skills: "[]".into(),
1134                sort_order: 0,
1135                created_at: 0,
1136                updated_at: 0,
1137                is_system: false,
1138            })
1139            .unwrap();
1140        let both = super::inject_global_bundles(file, &id_store, "host", picks);
1141        let global = both.find("# [Workspace] Rules").unwrap();
1142        let writer = both.find("# [Bundle] Writer").unwrap();
1143        let own = both.find("own notes").unwrap();
1144        assert!(global < writer && writer < own, "{both}");
1145        assert_eq!(super::inject_global_bundles(file, &id_store, "host", ""), both.replace("\n\n---\n\n# [Bundle] Writer\n\nwrite plainly", ""));
1146    }
1147
1148    /// The editor's "only under your home folder" rule, in a WSL distro: under
1149    /// a distro user's home, never its system folders or the Windows drives
1150    /// mounted in it, and never a home folder itself. Windows only: elsewhere
1151    /// such a path is an ordinary one (`wsl_fs::share_of`).
1152    #[cfg(windows)]
1153    #[test]
1154    fn the_editor_may_change_files_under_a_distro_home_and_nowhere_else_in_it() {
1155        use std::path::Path;
1156        let home = Path::new(r"C:\Users\me");
1157        let may = |p: &str| super::editor_may_change(Path::new(p), home);
1158        assert!(may(r"\\?\UNC\wsl.localhost\Ubuntu\home\u\proj\main.rs"));
1159        assert!(may(r"\\wsl.localhost\Ubuntu\root\notes.md"));
1160        assert!(may(r"\\wsl.localhost\Ubuntu\home\u"), "a parent for a new file");
1161        assert!(!may(r"\\wsl.localhost\Ubuntu\etc\passwd"));
1162        assert!(!may(r"\\wsl.localhost\Ubuntu\srv\app\x"));
1163        assert!(
1164            !may(r"\\wsl.localhost\Ubuntu\mnt\c\Windows\x"),
1165            "a Windows drive through the distro is not a way around the home rule"
1166        );
1167        assert!(super::is_a_home(Path::new(r"\\wsl.localhost\Ubuntu\home\u"), home));
1168        assert!(super::is_a_home(Path::new(r"\\wsl.localhost\Ubuntu\root"), home));
1169        assert!(!super::is_a_home(Path::new(r"\\wsl.localhost\Ubuntu\home\u\x"), home));
1170    }
1171
1172    /// The four watchers record their types too, and `writeagentconfig` migrates
1173    /// in the same slice as its own agent.ts stub -- so this file is finished:
1174    /// it has no untyped registrations left at all.
1175    ///
1176    /// `watcheditorfile` and `unwatcheditorfile` share one request type on
1177    /// purpose. They are not merely same-shaped: an unwatch that cannot name
1178    /// exactly what the watch named leaks a watcher, so sharing makes a future
1179    /// divergence a compile error instead. `watchmediadir` and
1180    /// `unwatchmediadir` do NOT share, because the extension filter is
1181    /// genuinely not part of the registration key.
1182    #[tokio::test]
1183    async fn register_typed_records_the_four_watchers() {
1184        let state = crate::server::tests::test_state();
1185        let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1186        super::register_editor_handlers(&engine, &state);
1187        let schema = engine.schema_json();
1188        let rows = schema.as_array().unwrap();
1189        let find = |cmd: &str| {
1190            rows.iter()
1191                .find(|r| r["command"] == cmd)
1192                .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
1193        };
1194
1195        for (cmd, req) in [
1196            ("watcheditorfile", "WatchEditorFileReq"),
1197            ("unwatcheditorfile", "WatchEditorFileReq"),
1198            ("watchmediadir", "WatchMediaDirReq"),
1199            ("unwatchmediadir", "UnwatchMediaDirReq"),
1200        ] {
1201            let row = find(cmd);
1202            assert_eq!(row["requestName"], req, "{cmd} request");
1203            assert_eq!(row["responseName"], "()", "{cmd} answers nothing");
1204        }
1205
1206        // `writeagentconfig` migrated too, in the same slice as its agent.ts
1207        // stub -- so editor_handlers.rs now has no untyped registrations at
1208        // all, and this asserts the file is finished rather than that one
1209        // command is still pending.
1210        let wac = find(crate::backend::rpc_types::COMMAND_WRITE_AGENT_CONFIG);
1211        assert_eq!(wac["requestName"], "CommandWriteAgentConfigData");
1212        assert_eq!(wac["responseName"], "CommandWriteAgentConfigResult");
1213    }
1214
1215    /// The five read commands record their request and response types too. With
1216    /// the four watchers and `writeagentconfig` covered by the test above,
1217    /// nothing in this file is left untyped.
1218    #[tokio::test]
1219    async fn register_typed_records_the_editor_read_commands() {
1220        let state = crate::server::tests::test_state();
1221        let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1222        super::register_editor_handlers(&engine, &state);
1223        let schema = engine.schema_json();
1224        let rows = schema.as_array().unwrap();
1225        let find = |cmd: &str| {
1226            rows.iter()
1227                .find(|r| r["command"] == cmd)
1228                .unwrap_or_else(|| panic!("{cmd} missing from the schema"))
1229        };
1230
1231        for (cmd, resp) in [
1232            ("readeditorfile", "CommandReadEditorFileResult"),
1233            ("writeeditorfile", "()"),
1234            ("listeditordir", "ListEditorDirResult"),
1235            ("geteditorhome", "GetEditorHomeResult"),
1236            ("geteditorroots", "GetEditorRootsResult"),
1237        ] {
1238            assert_eq!(find(cmd)["responseName"], resp, "{cmd} response");
1239        }
1240        assert_eq!(find("readeditorfile")["requestName"], "CommandReadEditorFileData");
1241        assert_eq!(find("writeeditorfile")["requestName"], "CommandWriteEditorFileData");
1242        assert_eq!(find("listeditordir")["requestName"], "ListEditorDirReq");
1243    }
1244
1245    /// `geteditorhome` and `geteditorroots` take no argument, and the two
1246    /// encodings of that both reach the server -- the stub sends `{}`, a client
1247    /// that omits `data` sends `null`. `Option<EditorRootsReq>` takes both;
1248    /// a bare struct rejects the second and `()` rejects the first.
1249    #[tokio::test]
1250    async fn the_no_argument_read_commands_accept_both_encodings() {
1251        let state = crate::server::tests::test_state();
1252        let (engine, mut rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1253        super::register_editor_handlers(&engine, &state);
1254
1255        for cmd in ["geteditorhome", "geteditorroots"] {
1256            for (i, payload) in [serde_json::json!({}), serde_json::Value::Null]
1257                .into_iter()
1258                .enumerate()
1259            {
1260                engine.handle_message(crate::backend::rpc_types::RpcMessage {
1261                    command: cmd.to_string(),
1262                    reqid: format!("{cmd}-{i}"),
1263                    data: Some(payload.clone()),
1264                    ..Default::default()
1265                });
1266                let resp = tokio::time::timeout(std::time::Duration::from_secs(2), rx.recv())
1267                    .await
1268                    .unwrap()
1269                    .unwrap();
1270                assert!(
1271                    resp.error.is_empty(),
1272                    "{cmd} should accept {payload}, got error: {}",
1273                    resp.error,
1274                );
1275            }
1276        }
1277    }
1278
1279    /// `readeditorfile`'s four encoding fields were typed `?:` by hand "for
1280    /// back-compat; absent ⇒ treat as UTF-8", but the server has never omitted
1281    /// them: `decode_file` returns all four unconditionally. Pin that, because
1282    /// the generated type now says they are required and this is the only
1283    /// thing that would notice if the handler started omitting one.
1284    #[test]
1285    fn readeditorfile_always_reports_the_encoding_fields() {
1286        let decoded = crate::backend::text_encoding::decode_file(b"hello");
1287        let v = serde_json::to_value(CommandReadEditorFileResult {
1288            content: decoded.content,
1289            encoding: decoded.encoding,
1290            bom: decoded.bom.to_string(),
1291            line_ending: decoded.line_ending.to_string(),
1292            had_decode_errors: decoded.had_decode_errors,
1293            read_only: false,
1294        })
1295        .unwrap();
1296        for key in ["content", "encoding", "bom", "line_ending", "had_decode_errors", "read_only"] {
1297            assert!(v.get(key).is_some(), "{key} must always be present, got {v}");
1298        }
1299        assert_eq!(v["encoding"], "UTF-8");
1300        assert_eq!(v["bom"], "none");
1301        assert_eq!(v["line_ending"], "lf");
1302    }
1303
1304    /// `DirEntry.size` / `.mtime` really are absent rather than zero: a
1305    /// directory has no meaningful size, and `0` would be indistinguishable
1306    /// from a real empty file. The generated TS says `size?: number`, and this
1307    /// is the half of that claim the TS cannot check.
1308    #[test]
1309    fn a_directory_entry_omits_size_rather_than_sending_zero() {
1310        let dir = DirEntry {
1311            name: "src".to_string(),
1312            is_dir: true,
1313            is_symlink: false,
1314            size: None,
1315            mtime: None,
1316        };
1317        let v = serde_json::to_value(&dir).unwrap();
1318        assert!(v.get("size").is_none(), "expected size omitted, got {v}");
1319        assert!(v.get("mtime").is_none(), "expected mtime omitted, got {v}");
1320
1321        let file = DirEntry {
1322            name: "main.rs".to_string(),
1323            is_dir: false,
1324            is_symlink: false,
1325            size: Some(0),
1326            mtime: Some(1_700_000_000_000),
1327        };
1328        let v = serde_json::to_value(&file).unwrap();
1329        assert_eq!(v["size"], 0, "a real empty file still reports its size");
1330        assert_eq!(v["mtime"], 1_700_000_000_000u64);
1331    }
1332
1333    /// The seven file-tree mutation commands record their request and response
1334    /// types.
1335    ///
1336    /// Each of them used to deserialize into a `struct Cmd` declared inside its
1337    /// own closure, with the response built by an inline `json!({..})`. Neither
1338    /// can be named, so neither could be generated from — which is exactly why
1339    /// the frontend restated all fourteen shapes by hand. A command that falls
1340    /// back to `register_handler` disappears from the registry rather than
1341    /// being recorded wrong, and that absence is what this catches.
1342    #[tokio::test]
1343    async fn register_typed_records_the_editor_mutation_commands() {
1344        let state = crate::server::tests::test_state();
1345        let (engine, _rx) = crate::backend::rpc::engine::WshRpcEngine::new();
1346        super::register_editor_handlers(&engine, &state);
1347        let schema = engine.schema_json();
1348        let rows = schema.as_array().unwrap();
1349        let find = |cmd: &str| {
1350            rows.iter()
1351                .find(|r| r["command"] == cmd)
1352                .unwrap_or_else(|| panic!("{cmd} missing from the schema — did it fall back to register_handler?"))
1353        };
1354
1355        for (cmd, req, resp) in [
1356            ("openinshell", "OpenInShellReq", "()"),
1357            ("renameeditorfile", "RenameEditorFileReq", "RenameEditorFileResult"),
1358            ("createeditorfile", "CreateEditorFileReq", "CreateEditorFileResult"),
1359            ("createeditordir", "CreateEditorDirReq", "CreateEditorDirResult"),
1360            ("deleteeditorfile", "DeleteEditorFileReq", "()"),
1361            ("createscratchfile", "CreateScratchFileReq", "CreateScratchFileResult"),
1362            ("movescratchfile", "MoveScratchFileReq", "MoveScratchFileResult"),
1363        ] {
1364            let row = find(cmd);
1365            assert_eq!(row["requestName"], req, "{cmd} request");
1366            assert_eq!(row["responseName"], resp, "{cmd} response");
1367        }
1368    }
1369
1370    /// `recursive` is required on purpose, and this is the assertion that keeps
1371    /// it that way.
1372    ///
1373    /// Adding `#[serde(default)]` would compile, pass every gate, and quietly
1374    /// turn "a caller that forgot the field" into "delete a directory tree it
1375    /// never asked to delete" — the generated TS would go from `recursive:
1376    /// boolean` to still-required (ts-rs cannot express the default anyway), so
1377    /// nothing downstream would flag it either. The destructive choice has to
1378    /// stay explicit on the wire.
1379    #[test]
1380    fn deleteeditorfile_will_not_deserialize_without_recursive() {
1381        let err = serde_json::from_value::<DeleteEditorFileReq>(
1382            serde_json::json!({ "path": "/home/u/x" }),
1383        )
1384        .expect_err("recursive must not be defaultable");
1385        assert!(
1386            err.to_string().contains("recursive"),
1387            "expected the error to name the field, got {err}",
1388        );
1389
1390        // Both explicit values still work, so this is about absence only.
1391        for recursive in [true, false] {
1392            let req: DeleteEditorFileReq = serde_json::from_value(
1393                serde_json::json!({ "path": "/home/u/x", "recursive": recursive }),
1394            )
1395            .unwrap();
1396            assert_eq!(req.recursive, recursive);
1397        }
1398    }
1399
1400    /// `createscratchfile`'s two fields are genuinely optional — the stub
1401    /// defaults its argument to `{}` — and they are `Option<T>` in Rust, so
1402    /// ts-rs can say `display_name?: string` without the derive-a-narrower-type
1403    /// dance the `serde(default)`-on-`String` fields elsewhere need.
1404    #[test]
1405    fn createscratchfile_accepts_an_empty_request() {
1406        let req: CreateScratchFileReq = serde_json::from_value(serde_json::json!({}))
1407            .expect("both fields are optional");
1408        assert!(req.display_name.is_none());
1409        assert!(req.exclude_scratch_ids.is_none());
1410
1411        let full: CreateScratchFileReq = serde_json::from_value(serde_json::json!({
1412            "display_name": "Notes",
1413            "exclude_scratch_ids": ["a", "b"],
1414        }))
1415        .unwrap();
1416        assert_eq!(full.display_name.as_deref(), Some("Notes"));
1417        assert_eq!(full.exclude_scratch_ids.unwrap().len(), 2);
1418    }
1419
1420    /// reagent P1, PR #2322: `writeagentconfig` (this file) is the actual
1421    /// "click Launch" path, distinct from `agent.open`'s
1422    /// `write_agent_config_files` (agent_open.rs) which already had
1423    /// stale-skill-file cleanup. This exercises the exact integration point
1424    /// the handler uses -- `crate::backend::rpc_types::block::AgentConfigFile`
1425    /// has a `path` field (not `filename`, unlike `agent_config::AgentConfigFile`)
1426    /// -- to catch a field-name mismatch silently no-op'ing the cleanup, which
1427    /// a test only calling `managed_skill_file_paths` with hand-built strings
1428    /// would not catch.
1429    #[test]
1430    fn writeagentconfig_files_produce_the_expected_managed_skill_paths() {
1431        let files = vec![
1432            AgentConfigFile { path: "CLAUDE.md".to_string(), content: "soul".to_string() },
1433            AgentConfigFile {
1434                path: ".claude/commands/deploy.md".to_string(),
1435                content: "deploy".to_string(),
1436            },
1437            AgentConfigFile {
1438                path: ".claude/skills/review/SKILL.md".to_string(),
1439                content: "review".to_string(),
1440            },
1441            AgentConfigFile { path: ".mcp.json".to_string(), content: "{}".to_string() },
1442        ];
1443        let managed = crate::backend::agent_config::managed_skill_file_paths(
1444            files.iter().map(|f| f.path.as_str()),
1445        );
1446        assert_eq!(managed.len(), 2, "expected exactly the two skill-derived paths: {managed:?}");
1447        assert!(managed.contains(".claude/commands/deploy.md"));
1448        assert!(managed.contains(".claude/skills/review/SKILL.md"));
1449        assert!(!managed.contains("CLAUDE.md"));
1450        assert!(!managed.contains(".mcp.json"));
1451    }
1452
1453    /// End-to-end proof (real filesystem, no RPC engine needed) that the
1454    /// three shared calls this handler makes -- compute managed paths,
1455    /// clean up stale ones from a prior manifest, write the new manifest --
1456    /// actually remove a stale skill artifact between two materializations,
1457    /// using this handler's own `AgentConfigFile{path, content}` shape.
1458    #[test]
1459    fn stale_skill_file_is_removed_across_two_materializations() {
1460        let work_dir = tempfile::tempdir().unwrap();
1461        let base_path = work_dir.path();
1462
1463        // "Launch 1": agent-skill format.
1464        let launch1 = vec![AgentConfigFile {
1465            path: ".claude/skills/deploy/SKILL.md".to_string(),
1466            content: "body".to_string(),
1467        }];
1468        let managed1 = crate::backend::agent_config::managed_skill_file_paths(
1469            launch1.iter().map(|f| f.path.as_str()),
1470        );
1471        crate::backend::agent_config::cleanup_stale_managed_skill_files(base_path, &managed1);
1472        for f in &launch1 {
1473            let p = base_path.join(&f.path);
1474            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1475            std::fs::write(&p, &f.content).unwrap();
1476        }
1477        crate::backend::agent_config::write_managed_skill_file_manifest(base_path, &managed1);
1478        let skill_md = base_path.join(".claude/skills/deploy/SKILL.md");
1479        assert!(skill_md.exists());
1480
1481        // "Launch 2": switched to prompt format -- same skill, different path.
1482        let launch2 = vec![AgentConfigFile {
1483            path: ".claude/commands/deploy.md".to_string(),
1484            content: "body".to_string(),
1485        }];
1486        let managed2 = crate::backend::agent_config::managed_skill_file_paths(
1487            launch2.iter().map(|f| f.path.as_str()),
1488        );
1489        crate::backend::agent_config::cleanup_stale_managed_skill_files(base_path, &managed2);
1490        for f in &launch2 {
1491            let p = base_path.join(&f.path);
1492            std::fs::create_dir_all(p.parent().unwrap()).unwrap();
1493            std::fs::write(&p, &f.content).unwrap();
1494        }
1495        crate::backend::agent_config::write_managed_skill_file_manifest(base_path, &managed2);
1496
1497        assert!(
1498            !skill_md.exists(),
1499            "stale .claude/skills/deploy/SKILL.md must be removed by the writeagentconfig path too"
1500        );
1501        assert!(base_path.join(".claude/commands/deploy.md").exists());
1502    }
1503
1504    /// docs/specs/REPORT_JEKT_SIGNING_KEY_INJECTION_GAP_2026_08_16.md: before
1505    /// this fix, `WriteAgentConfig` (the actual "click Launch" path) never
1506    /// injected jekt/LAN signing keys at all -- only `agent.open` did, a
1507    /// path most normal launches don't go through. This exercises the exact
1508    /// slug-extraction-then-injection sequence the real handler runs,
1509    /// against the real `rpc_types::AgentConfigFile{path, content}` shape
1510    /// the frontend's `agent-config-builder.ts` actually produces (only
1511    /// `AGENTMUX_AGENT_ID` set -- no jekt fields), the same
1512    /// field-name-mismatch class of bug the test above this one guards
1513    /// against for `managed_skill_file_paths`.
1514    #[test]
1515    fn writeagentconfig_extracts_slug_and_injects_signing_keys_into_the_real_mcp_json_shape() {
1516        let store = crate::backend::storage::store::Store::open_in_memory().unwrap();
1517        let (_wan_dir, wan) = crate::backend::storage::wan_identity::attach_temp_wan_identity(&store);
1518        let files = vec![AgentConfigFile {
1519            path: ".mcp.json".to_string(),
1520            content: serde_json::to_string(&serde_json::json!({
1521                "mcpServers": {
1522                    "agentmux": {
1523                        "type": "stdio",
1524                        "command": "agentmux-mcp",
1525                        "args": [],
1526                        "env": { "AGENTMUX_AGENT_ID": "aria" }
1527                    }
1528                }
1529            }))
1530            .unwrap(),
1531        }];
1532
1533        let pos = files.iter().position(|f| f.path == ".mcp.json").unwrap();
1534        let agent_slug = serde_json::from_str::<serde_json::Value>(&files[pos].content)
1535            .ok()
1536            .and_then(|v| {
1537                v.pointer("/mcpServers/agentmux/env/AGENTMUX_AGENT_ID")
1538                    .and_then(|s| s.as_str())
1539                    .map(str::to_string)
1540            })
1541            .expect("must extract the agent slug from the real .mcp.json shape");
1542        assert_eq!(agent_slug, "aria");
1543
1544        let rewritten = crate::backend::agent_config::inject_jekt_signing_keys_into_mcp_json(
1545            &files[pos].content,
1546            &store,
1547            &agent_slug,
1548        )
1549        .expect("keys must ensure successfully against a fresh in-memory store");
1550        let parsed: serde_json::Value = serde_json::from_str(&rewritten).unwrap();
1551        let env = &parsed["mcpServers"]["agentmux"]["env"];
1552        assert_eq!(env["AGENTMUX_AGENT_ID"], "aria");
1553        assert!(env["AGENTMUX_JEKT_KEY"].is_string() && !env["AGENTMUX_JEKT_KEY"].as_str().unwrap().is_empty());
1554        assert!(env["AGENTMUX_LAN_KEY"].is_string() && !env["AGENTMUX_LAN_KEY"].as_str().unwrap().is_empty());
1555        // The WAN key must be
1556        // injected on this path too. This assertion is the whole point of the
1557        // report cited above: the Launch-button path silently skipped key
1558        // injection for months while `agent.open` did it correctly.
1559        assert!(env["AGENTMUX_WAN_KEY"].is_string() && !env["AGENTMUX_WAN_KEY"].as_str().unwrap().is_empty());
1560        assert_ne!(env["AGENTMUX_WAN_KEY"], env["AGENTMUX_LAN_KEY"], "WAN and LAN keys must be independent");
1561        // §2.1.2: the WAN signature binds the sending instance, so the host
1562        // label must travel with the key. A missing one would make the sender
1563        // sign under a different instance identity than srv publishes.
1564        // W3-S §2.2: the id of the instance that certifies the key.
1565        assert_eq!(
1566            env["AGENTMUX_HOST_LABEL"],
1567            serde_json::json!(wan.instance_ensure("unused").unwrap().instance_id),
1568            "the MCP env must carry the instance id that certifies this agent's WAN key"
1569        );
1570
1571    }
1572}