railwayapp 5.54.1

Interact with Railway via CLI
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
//! Syncing a project's MCP servers onto a cloud agent.
//!
//! The source is the launch directory's `.mcp.json` — the project-scope file
//! Claude Code already reads, found by walking up from the cwd to the git
//! root. A repo that gives its people MCP servers by default (mono's
//! `.mcp.json` is the motivating case) should give its cloud agents the same
//! ones: launch from inside the repo and they come along.
//!
//! What ships is the `mcpServers` object, minus entries marked
//! `"disabled": true` and minus the names express-agent provisions itself
//! (`railway`, `playwright`, `railway-machine`) — those are the platform's to
//! own and reconcile. Every shipped name is prefixed `user-` (`buildkite`
//! lands as `user-buildkite`), so an import can never collide with a server
//! already on the agent — the platform's, or one the user added by hand —
//! and everything ours is recognizable at a glance. The rest lands on the VM
//! merged into the two JSON dialects the harnesses there read:
//!
//! - `~/.claude.json` `mcpServers` (object form) — claude's user scope, which
//!   asks no per-project approval question the way a repo `.mcp.json` would.
//! - `~/.claude/settings.json` `mcp_servers` (array form) — what the railway
//!   harness reads; entries carry `{name, transport, url|command}`.
//!
//! The prefix is the ownership contract, on both sides of the merge: names
//! under it are ours to keep current (an edited url in the project's
//! `.mcp.json` propagates on the next launch, since the recorded hash claims
//! it did), and every other name is preserved untouched, whether the user put
//! it there by hand or express-agent's boot reconcile did. Nothing is ever
//! removed. Codex and grok keep their MCP config in TOML, which a shell merge
//! cannot edit safely; express-agent renders those from the canonical file.
//!
//! The merge runs under jq, which cloud-agent-base bakes. Every failure path
//! degrades instead of aborting the launch — a missing jq or an unparseable
//! config costs the user their MCP servers, not their session — and a hash
//! recorded on the agent (reported by the main provision script, like
//! `SKILLS-HASH`) keeps an unchanged set from costing an upload.

use std::collections::BTreeMap;
use std::path::{Path, PathBuf};

use anyhow::{Context, Result};
use sha2::{Digest, Sha256};

use super::prefs::AgentPrefs;

/// Server names express-agent registers on every agent itself. Shipping a
/// local copy could only fight the platform's reconcile.
const RESERVED_NAMES: &[&str] = &["railway", "playwright", "railway-machine"];

/// Every imported name leads with this, so what the CLI brought can never
/// collide with a server already on the agent and is recognizable as an
/// import wherever it shows up.
const NAME_PREFIX: &str = "user-";

/// Payload ceiling. An `.mcp.json` is a page of config; anything near this is
/// not one, and it rides ssh stdin where every retry re-sends it.
const MAX_BYTES: usize = 256 * 1024;

#[derive(Debug)]
pub struct PackedMcp {
    /// The filtered `mcpServers` object, serialized deterministically.
    pub payload: Vec<u8>,
    /// Content hash, compared against the copy recorded on the agent so an
    /// unchanged set costs no upload.
    pub hash: String,
    pub names: Vec<String>,
    pub source_path: PathBuf,
}

/// The `.mcp.json` a launch from `dir` should read: the nearest one walking
/// up from `dir`, and only within the repo — the walk requires a git root
/// and never passes it. Outside any repo, only `dir` itself is checked:
/// walking an unbounded ancestor chain from some scratch directory would
/// eventually find a personal `~/.mcp.json` (headers and env values — API
/// tokens — included) and ship it to a VM nobody meant to give it to.
pub fn find_config(dir: &Path) -> Option<PathBuf> {
    let in_repo = |d: &Path| d.ancestors().any(|a| a.join(".git").exists());
    let mut cur = Some(dir);
    while let Some(d) = cur {
        let candidate = d.join(".mcp.json");
        if candidate.is_file() {
            // The launch directory's own file always counts; anything above
            // it only inside the repo that contains the launch directory.
            return (d == dir || in_repo(dir)).then_some(candidate);
        }
        // The git root is the last directory searched: a `.mcp.json` above
        // the repo belongs to some other context.
        if d.join(".git").exists() {
            return None;
        }
        cur = d.parent();
    }
    None
}

/// Pack the MCP servers a launch from `dir` should carry, or `None` when
/// there is nothing to send (pref off, no `.mcp.json`, or every entry
/// filtered out). An unreadable or malformed file is an error — a repo that
/// commits one expects it to work, and silently launching without it would
/// change what the agent can do without telling anyone.
pub fn pack(prefs: &AgentPrefs, dir: &Path) -> Result<Option<PackedMcp>> {
    if !prefs.mcp.enabled {
        return Ok(None);
    }
    let Some(source_path) = find_config(dir) else {
        return Ok(None);
    };
    let raw = std::fs::read_to_string(&source_path)
        .with_context(|| format!("Failed to read {}", source_path.display()))?;
    let parsed: serde_json::Value = serde_json::from_str(&raw)
        .with_context(|| format!("{} is not valid JSON", source_path.display()))?;
    let Some(servers) = parsed.get("mcpServers").and_then(|v| v.as_object()) else {
        return Ok(None);
    };

    // BTreeMap for a deterministic serialization, which is what makes the
    // hash stable across launches.
    let mut shipped: BTreeMap<String, serde_json::Value> = BTreeMap::new();
    for (name, config) in servers {
        if RESERVED_NAMES.contains(&name.as_str())
            || prefs.mcp.exclude.iter().any(|e| e == name)
            || config.get("disabled").and_then(|d| d.as_bool()) == Some(true)
        {
            continue;
        }
        let mut config = config.clone();
        // `disabled: false` is mono's own convention, not the harnesses' —
        // strip it rather than teach every reader about it.
        if let Some(obj) = config.as_object_mut() {
            obj.remove("disabled");
        }
        // A name that already leads with the prefix keeps it single —
        // `user-user-foo` helps nobody.
        let shipped_name = if name.starts_with(NAME_PREFIX) {
            name.clone()
        } else {
            format!("{NAME_PREFIX}{name}")
        };
        shipped.insert(shipped_name, config);
    }
    if shipped.is_empty() {
        return Ok(None);
    }

    let payload = serde_json::to_vec(&shipped)?;
    if payload.len() > MAX_BYTES {
        anyhow::bail!(
            "{} is too large to sync ({} bytes, limit {MAX_BYTES}).",
            source_path.display(),
            payload.len()
        );
    }
    let hash = format!("{:x}", Sha256::digest(&payload));
    Ok(Some(PackedMcp {
        payload,
        hash,
        names: shipped.into_keys().collect(),
        source_path,
    }))
}

/// Marker prefix the launcher greps out of the main provision script's stdout
/// to decide whether the agent already holds this exact server set.
pub const REMOTE_HASH_MARKER: &str = "MCP-HASH:";

/// Where the hash of the last synced set lives on the agent.
pub const REMOTE_HASH_FILE: &str = "$HOME/.railway-mcp-hash";

/// Reads the hash the agent recorded, out of the provision script's stdout.
pub fn parse_remote_hash(provision_output: &str) -> Option<String> {
    provision_output
        .lines()
        .find_map(|line| line.trim().strip_prefix(REMOTE_HASH_MARKER))
        .map(str::to_string)
        .filter(|h| !h.is_empty())
}

/// The VM-side sync. The filtered `mcpServers` object arrives on stdin; both
/// harness configs are merged add-only under jq, each through a temp file so
/// a failed merge cannot leave a half-written config. Every step degrades
/// instead of aborting the launch, and the hash file is written last so a
/// failure anywhere above means the next launch retries rather than believing
/// itself current.
pub fn provision_script(hash: &str) -> String {
    format!(
        r#"umask 077
# Read stdin FIRST, before anything that can bail: exiting with the pipe still
# full breaks the CLI's write instead of the sync.
payload="$HOME/.railway-mcp-payload.json"
cat > "$payload"
command -v jq >/dev/null 2>&1 || {{ rm -f "$payload"; echo MCP-NO-JQ; exit 0; }}
jq -e 'type == "object"' "$payload" >/dev/null 2>&1 || {{ rm -f "$payload"; echo MCP-BAD-JSON; exit 0; }}
# The canonical copy, for the platform: express-agent's boot reconcile reads
# this and renders the servers into every harness dialect it has verified —
# the TOML ones (codex, grok) included, which the shell merges below cannot
# reach. The direct merges stay as the compatibility path for images whose
# express-agent predates the file.
cp "$payload" "$HOME/.railway-mcp.json"
ok=1
# claude's user scope: mcpServers object. The payload's keys all wear the
# import prefix, and prefixed names are OURS to keep current — an edited url
# in the project's .mcp.json must land, or the hash below would record a
# sync that never happened. Every other name (the user's own, or
# express-agent's) is preserved untouched.
cfg="$HOME/.claude.json"
[ -s "$cfg" ] || echo '{{}}' > "$cfg"
if jq --slurpfile new "$payload" '.mcpServers = ((.mcpServers // {{}}) + $new[0])' "$cfg" > "$cfg.railway-mcp-tmp" 2>/dev/null; then
  mv "$cfg.railway-mcp-tmp" "$cfg"
else
  rm -f "$cfg.railway-mcp-tmp"; ok=0
fi
# The railway harness reads settings.json's mcp_servers array. Same
# ownership rule in array form: entries under the payload's names are
# replaced with the payload's current content (appended at the tail), and
# every other entry keeps its place.
mkdir -p "$HOME/.claude"
set="$HOME/.claude/settings.json"
[ -s "$set" ] || echo '{{}}' > "$set"
if jq --slurpfile new "$payload" '
  ($new[0] | keys) as $ours
  | .mcp_servers = ((.mcp_servers // [])
      | map(select(.name as $n | $ours | index($n) | not)))
      + ($new[0]
      | to_entries
      | map({{name: .key}}
          + (if .value.url then {{transport: (.value.type // "http"), url: .value.url}}
             else {{transport: "stdio", command: (.value.command // ""), args: (.value.args // [])}} end)
          + (if .value.headers then {{headers: .value.headers}} else {{}} end)
          + (if .value.env then {{env: .value.env}} else {{}} end)))
' "$set" > "$set.railway-mcp-tmp" 2>/dev/null; then
  mv "$set.railway-mcp-tmp" "$set"
else
  rm -f "$set.railway-mcp-tmp"; ok=0
fi
rm -f "$payload"
[ "$ok" = 1 ] || {{ echo MCP-MERGE-FAILED; exit 0; }}
printf '%s\n' '{hash}' > "{REMOTE_HASH_FILE}"
echo MCP-OK"#
    )
}

#[cfg(test)]
mod tests {
    use super::*;

    fn prefs_on() -> AgentPrefs {
        AgentPrefs::default()
    }

    fn plant(dir: &Path, body: &str) {
        std::fs::create_dir_all(dir).unwrap();
        std::fs::write(dir.join(".mcp.json"), body).unwrap();
    }

    const MONO_LIKE: &str = r#"{
        "mcpServers": {
            "railway-internal": { "type": "http", "url": "https://mcp.internal.example.com/" },
            "notion": { "type": "http", "url": "https://mcp.notion.com/mcp", "disabled": true },
            "buildkite": { "type": "http", "url": "https://mcp.buildkite.com/mcp", "disabled": false },
            "railway": { "type": "http", "url": "https://should-never-ship.example.com" },
            "local-tool": { "command": "my-mcp", "args": ["--serve"] }
        }
    }"#;

    #[test]
    fn packs_enabled_servers_and_filters_the_rest() {
        let dir = tempfile::tempdir().unwrap();
        plant(dir.path(), MONO_LIKE);

        let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
        assert_eq!(
            packed.names,
            vec![
                "user-buildkite".to_string(),
                "user-local-tool".to_string(),
                "user-railway-internal".to_string()
            ],
            "disabled entries and the platform's own names stay home; \
             what ships wears the import prefix"
        );
        // The mono-only `disabled` flag is stripped from what ships.
        let shipped: serde_json::Value = serde_json::from_slice(&packed.payload).unwrap();
        assert!(shipped["user-buildkite"].get("disabled").is_none());
        assert_eq!(
            shipped["user-buildkite"]["url"],
            "https://mcp.buildkite.com/mcp"
        );
    }

    /// A source name that already leads with the prefix keeps it single.
    #[test]
    fn the_prefix_never_doubles() {
        let dir = tempfile::tempdir().unwrap();
        plant(
            dir.path(),
            r#"{"mcpServers": {"user-foo": {"type": "http", "url": "https://x.example"}}}"#,
        );
        let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
        assert_eq!(packed.names, vec!["user-foo".to_string()]);
    }

    #[test]
    fn walks_up_to_the_git_root_and_not_past_it() {
        let root = tempfile::tempdir().unwrap();
        let repo = root.path().join("repo");
        let deep = repo.join("packages").join("thing");
        std::fs::create_dir_all(&deep).unwrap();
        std::fs::create_dir_all(repo.join(".git")).unwrap();
        plant(&repo, MONO_LIKE);

        assert_eq!(
            find_config(&deep).unwrap(),
            repo.join(".mcp.json"),
            "a launch from a subdirectory finds the repo's file"
        );

        // A file ABOVE the repo belongs to some other context.
        let bare = root.path().join("bare");
        std::fs::create_dir_all(bare.join(".git")).unwrap();
        plant(root.path(), MONO_LIKE);
        assert!(find_config(&bare).is_none());
    }

    /// Outside any repo the walk goes nowhere: only the launch directory's
    /// own file counts. An unbounded ancestor walk from a scratch directory
    /// would eventually reach a personal `~/.mcp.json` — headers and env
    /// values included — and ship it to a VM nobody meant to give it to.
    #[test]
    fn outside_a_repo_only_the_launch_directory_is_read() {
        let root = tempfile::tempdir().unwrap();
        let scratch = root.path().join("scratch").join("notes");
        std::fs::create_dir_all(&scratch).unwrap();
        // The ancestor holds a config full of secrets; no .git anywhere.
        plant(root.path(), MONO_LIKE);
        assert!(
            find_config(&scratch).is_none(),
            "an out-of-repo ancestor's file must stay home"
        );

        // The launch directory's own file still counts, repo or not.
        plant(&scratch, MONO_LIKE);
        assert_eq!(find_config(&scratch).unwrap(), scratch.join(".mcp.json"));
    }

    #[test]
    fn disabled_pref_missing_file_or_empty_set_pack_nothing() {
        let dir = tempfile::tempdir().unwrap();
        assert!(pack(&prefs_on(), dir.path()).unwrap().is_none(), "no file");

        plant(
            dir.path(),
            r#"{"mcpServers": {"railway": {"type": "http", "url": "x"}}}"#,
        );
        assert!(
            pack(&prefs_on(), dir.path()).unwrap().is_none(),
            "everything filtered"
        );

        plant(dir.path(), MONO_LIKE);
        let mut off = prefs_on();
        off.mcp.enabled = false;
        assert!(pack(&off, dir.path()).unwrap().is_none(), "pref off");
    }

    #[test]
    fn excluded_names_stay_home() {
        let dir = tempfile::tempdir().unwrap();
        plant(dir.path(), MONO_LIKE);
        let mut prefs = prefs_on();
        // Excludes match the `.mcp.json` names, not the prefixed form.
        prefs.mcp.exclude = vec!["railway-internal".into()];
        let packed = pack(&prefs, dir.path()).unwrap().unwrap();
        assert!(!packed.names.iter().any(|n| n.contains("railway-internal")));
    }

    #[test]
    fn malformed_json_is_an_error_not_a_silent_skip() {
        let dir = tempfile::tempdir().unwrap();
        plant(dir.path(), "{ not json");
        assert!(pack(&prefs_on(), dir.path()).is_err());
    }

    /// Content-addressed: an unchanged file hashes the same across launches,
    /// and any change moves it.
    #[test]
    fn hash_tracks_content() {
        let dir = tempfile::tempdir().unwrap();
        plant(dir.path(), MONO_LIKE);
        let first = pack(&prefs_on(), dir.path()).unwrap().unwrap();
        let again = pack(&prefs_on(), dir.path()).unwrap().unwrap();
        assert_eq!(first.hash, again.hash);

        plant(
            dir.path(),
            r#"{"mcpServers": {"other": {"type": "http", "url": "https://x.example"}}}"#,
        );
        let changed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
        assert_ne!(first.hash, changed.hash);
    }

    #[test]
    fn parses_the_remote_hash_marker() {
        assert_eq!(
            parse_remote_hash("AGENT-READY\nMCP-HASH:abc\n").as_deref(),
            Some("abc")
        );
        assert!(parse_remote_hash("AGENT-READY\nMCP-HASH:\n").is_none());
        assert!(parse_remote_hash("AGENT-READY\n").is_none());
    }

    /// Every early exit must come after the `cat`, and the hash is recorded
    /// only after both merges succeeded.
    #[test]
    fn provision_script_drains_stdin_first_and_records_the_hash_last() {
        let script = provision_script("deadbeef");
        let cat_at = script.find("cat > \"$payload\"").unwrap();
        for marker in ["MCP-NO-JQ", "MCP-BAD-JSON", "MCP-MERGE-FAILED"] {
            assert!(
                script.find(marker).unwrap() > cat_at,
                "`{marker}` can be reached before stdin is drained"
            );
        }
        let hash_at = script.find("deadbeef").unwrap();
        let ok_at = script.find("echo MCP-OK").unwrap();
        let merge_at = script.find("mcpServers =").unwrap();
        assert!(merge_at < hash_at && hash_at < ok_at);
    }

    /// The script actually runs: executed against a stand-in `$HOME` with the
    /// real payload on stdin. Asserts the ownership rule — prefixed names are
    /// refreshed to the payload's current content (an edited url propagates;
    /// the hash below records that it did), every other name is preserved —
    /// in both dialects. Skipped where jq isn't installed locally; the agent
    /// image bakes it.
    #[cfg(unix)]
    #[test]
    fn provision_script_upserts_owned_names_and_preserves_the_rest() {
        use std::process::Command;

        if Command::new("jq").arg("--version").output().is_err() {
            eprintln!("skipping: jq not installed");
            return;
        }

        let dir = tempfile::tempdir().unwrap();
        plant(dir.path(), MONO_LIKE);
        let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();

        // Stand-in agent: express-agent has written its own entries, the
        // user has one of their own, and a PREVIOUS import left a stale copy
        // of a prefixed name — the shape of "I edited the url and
        // relaunched".
        let vm = tempfile::tempdir().unwrap();
        std::fs::write(
            vm.path().join(".claude.json"),
            r#"{"hasCompletedOnboarding": true, "mcpServers": {"user-buildkite": {"type": "http", "url": "https://stale.example"}, "mine": {"command": "hands-off"}}}"#,
        )
        .unwrap();
        std::fs::create_dir_all(vm.path().join(".claude")).unwrap();
        std::fs::write(
            vm.path().join(".claude").join("settings.json"),
            r#"{"mcp_servers": [{"name": "railway", "transport": "stdio", "command": "express-agent"}, {"name": "user-buildkite", "transport": "http", "url": "https://stale.example"}]}"#,
        )
        .unwrap();

        let stdout = run_script(vm.path(), &packed.payload, &packed.hash);
        assert!(stdout.contains("MCP-OK"), "{stdout}");

        // ~/.claude.json: the stale import refreshed, the user's own name and
        // unrelated fields untouched, the rest of the payload added.
        let cfg: serde_json::Value =
            serde_json::from_str(&std::fs::read_to_string(vm.path().join(".claude.json")).unwrap())
                .unwrap();
        assert_eq!(cfg["hasCompletedOnboarding"], true);
        assert_eq!(
            cfg["mcpServers"]["user-buildkite"]["url"], "https://mcp.buildkite.com/mcp",
            "an edited server propagates on the next sync"
        );
        assert_eq!(
            cfg["mcpServers"]["mine"]["command"], "hands-off",
            "names outside the prefix are never touched"
        );
        assert_eq!(
            cfg["mcpServers"]["user-railway-internal"]["url"],
            "https://mcp.internal.example.com/"
        );

        // ~/.claude/settings.json: array dialect — express-agent's entry
        // keeps its place, the stale prefixed entry is replaced (exactly one
        // survives), the rest appended.
        let set: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap(),
        )
        .unwrap();
        let servers = set["mcp_servers"].as_array().unwrap();
        assert_eq!(servers[0]["name"], "railway", "existing entries lead");
        let buildkites: Vec<_> = servers
            .iter()
            .filter(|s| s["name"] == "user-buildkite")
            .collect();
        assert_eq!(
            buildkites.len(),
            1,
            "an owned name is replaced, not duplicated"
        );
        assert_eq!(buildkites[0]["url"], "https://mcp.buildkite.com/mcp");
        let internal = servers
            .iter()
            .find(|s| s["name"] == "user-railway-internal")
            .expect("http server appended");
        assert_eq!(internal["transport"], "http");
        let local = servers
            .iter()
            .find(|s| s["name"] == "user-local-tool")
            .expect("stdio server appended");
        assert_eq!(local["transport"], "stdio");
        assert_eq!(local["command"], "my-mcp");

        // The canonical copy for the platform's own reconcile landed, and it
        // is exactly the payload.
        let canonical = std::fs::read(vm.path().join(".railway-mcp.json")).unwrap();
        assert_eq!(canonical, packed.payload);

        // The hash was recorded, so the next launch skips the upload; no
        // scratch files left behind.
        let recorded = std::fs::read_to_string(vm.path().join(".railway-mcp-hash")).unwrap();
        assert_eq!(recorded.trim(), packed.hash);
        assert!(!vm.path().join(".railway-mcp-payload.json").exists());
    }

    /// Run the provision script against a stand-in `$HOME`, returning stdout.
    #[cfg(unix)]
    fn run_script(vm: &Path, payload: &[u8], hash: &str) -> String {
        use std::io::Write;
        use std::process::{Command, Stdio};
        let mut child = Command::new("sh")
            .arg("-c")
            .arg(provision_script(hash))
            .env("HOME", vm)
            .stdin(Stdio::piped())
            .stdout(Stdio::piped())
            .stderr(Stdio::piped())
            .spawn()
            .unwrap();
        child.stdin.take().unwrap().write_all(payload).unwrap();
        let out = child.wait_with_output().unwrap();
        String::from_utf8_lossy(&out.stdout).into_owned()
    }

    /// A config the merge cannot parse or cannot type-match must degrade —
    /// MCP-MERGE-FAILED, no hash recorded (so the next launch retries), the
    /// broken file left byte-for-byte as it was — never a clobber, and never
    /// a failed launch.
    #[cfg(unix)]
    #[test]
    fn provision_script_leaves_corrupt_configs_untouched() {
        use std::process::Command;
        if Command::new("jq").arg("--version").output().is_err() {
            eprintln!("skipping: jq not installed");
            return;
        }
        let dir = tempfile::tempdir().unwrap();
        plant(dir.path(), MONO_LIKE);
        let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();

        // Invalid JSON in ~/.claude.json, and a shape clash in settings.json
        // (mcpServers as an array would break `object + object` the same way).
        let vm = tempfile::tempdir().unwrap();
        std::fs::write(vm.path().join(".claude.json"), "{ definitely not json").unwrap();
        std::fs::create_dir_all(vm.path().join(".claude")).unwrap();
        std::fs::write(
            vm.path().join(".claude").join("settings.json"),
            r#"{"mcp_servers": {"wrong": "shape"}}"#,
        )
        .unwrap();

        let stdout = run_script(vm.path(), &packed.payload, &packed.hash);
        assert!(stdout.contains("MCP-MERGE-FAILED"), "{stdout}");
        assert!(!stdout.contains("MCP-OK"), "{stdout}");

        // Both files exactly as they were: degrade means hands off.
        assert_eq!(
            std::fs::read_to_string(vm.path().join(".claude.json")).unwrap(),
            "{ definitely not json"
        );
        assert_eq!(
            std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap(),
            r#"{"mcp_servers": {"wrong": "shape"}}"#
        );
        // No hash: the next launch retries instead of believing itself
        // current. No temp or scratch litter. The canonical file still lands
        // — it validated, and express-agent reads it independently.
        assert!(!vm.path().join(".railway-mcp-hash").exists());
        assert!(!vm.path().join(".railway-mcp-payload.json").exists());
        assert!(!vm.path().join(".claude.json.railway-mcp-tmp").exists());
        assert!(
            !vm.path()
                .join(".claude")
                .join("settings.json.railway-mcp-tmp")
                .exists()
        );
        assert!(vm.path().join(".railway-mcp.json").exists());
    }

    /// A shape clash in only ONE file must not take the other down with it:
    /// the good file still gets its merge, and the run still reports failure
    /// so nothing records the hash.
    #[cfg(unix)]
    #[test]
    fn provision_script_merges_the_good_file_despite_the_bad_one() {
        use std::process::Command;
        if Command::new("jq").arg("--version").output().is_err() {
            eprintln!("skipping: jq not installed");
            return;
        }
        let dir = tempfile::tempdir().unwrap();
        plant(dir.path(), MONO_LIKE);
        let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();

        let vm = tempfile::tempdir().unwrap();
        // mcpServers as an ARRAY: `$new + array` is a jq type error.
        std::fs::write(
            vm.path().join(".claude.json"),
            r#"{"mcpServers": ["not", "an", "object"]}"#,
        )
        .unwrap();

        let stdout = run_script(vm.path(), &packed.payload, &packed.hash);
        assert!(stdout.contains("MCP-MERGE-FAILED"), "{stdout}");
        // The clashing file was left alone…
        assert_eq!(
            std::fs::read_to_string(vm.path().join(".claude.json")).unwrap(),
            r#"{"mcpServers": ["not", "an", "object"]}"#
        );
        // …while settings.json (absent → seeded) still got the servers.
        let set: serde_json::Value = serde_json::from_str(
            &std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap(),
        )
        .unwrap();
        assert!(
            set["mcp_servers"]
                .as_array()
                .unwrap()
                .iter()
                .any(|s| s["name"] == "user-buildkite"),
            "{set}"
        );
        assert!(!vm.path().join(".railway-mcp-hash").exists());
    }

    /// Running the sync twice must converge, not accumulate: the object merge
    /// is keyed and the array merge is name-deduped, so the second pass finds
    /// every name present and changes nothing.
    #[cfg(unix)]
    #[test]
    fn provision_script_is_idempotent() {
        use std::process::Command;
        if Command::new("jq").arg("--version").output().is_err() {
            eprintln!("skipping: jq not installed");
            return;
        }
        let dir = tempfile::tempdir().unwrap();
        plant(dir.path(), MONO_LIKE);
        let packed = pack(&prefs_on(), dir.path()).unwrap().unwrap();
        let vm = tempfile::tempdir().unwrap();

        assert!(run_script(vm.path(), &packed.payload, &packed.hash).contains("MCP-OK"));
        let first_cfg = std::fs::read_to_string(vm.path().join(".claude.json")).unwrap();
        let first_set =
            std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap();

        assert!(run_script(vm.path(), &packed.payload, &packed.hash).contains("MCP-OK"));
        assert_eq!(
            std::fs::read_to_string(vm.path().join(".claude.json")).unwrap(),
            first_cfg,
            "second run left .claude.json byte-identical"
        );
        assert_eq!(
            std::fs::read_to_string(vm.path().join(".claude").join("settings.json")).unwrap(),
            first_set,
            "second run left settings.json byte-identical"
        );
        let set: serde_json::Value = serde_json::from_str(&first_set).unwrap();
        assert_eq!(
            set["mcp_servers"]
                .as_array()
                .unwrap()
                .iter()
                .filter(|s| s["name"] == "user-buildkite")
                .count(),
            1,
            "no duplicate array entries across runs"
        );
    }
}