warble-cli 0.19.0

Warble CLI — compile profiles, dispatch to the Claude Code or Vercel target, render dashboards, emit manifests, run eval.
Documentation
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
//! End-to-end proof that a component's declared assets reach the directory an agent runs in.
//!
//! IR 0.7 let a component declare `assets:` and compile recorded each file's identity, but nothing
//! consumed the manifest: a component declared its files and the agent then ran in a directory that
//! did not contain them, with no error at all. These tests cover the whole path — compile writes the
//! content beside the IR, dispatch lands it and verifies every hash — and the two refusals, since a
//! silent partial landing is what made the original defect hard to see.
//!
//! See decision-101 for why the content travels beside the IR rather than being re-read from a
//! component directory (there is none at dispatch) or carried inside the IR (deliberately not).

use std::fs;
use std::path::Path;

use sha2::Digest;

use warble_cli::{
    asset_dir_for_ir, compile_project_to_ir_with_assets, land_assets, write_assets,
    BuiltinContextResolver, ComponentSource,
};

const PREPARED_WIDGETS: &str = r#"{
  "context_version": 2,
  "parseable": true,
  "metrics": [
    {
      "name": "id",
      "owner": "widgets",
      "declared": false,
      "additivity": null
    }
  ],
  "dimensions": [],
  "models": [
    {
      "name": "widgets",
      "has_timestamp": false,
      "columns": [
        "id"
      ]
    }
  ],
  "lineage": {
    "nodes": [
      {
        "id": "model:widgets",
        "kind": "model"
      }
    ],
    "edges": []
  },
  "lineage_diagnostics": [],
  "analysis": {
    "impact": {
      "model:widgets": {
        "downstream": [],
        "severity": {
          "rank": 0,
          "name": "none"
        }
      }
    },
    "consumers": {
      "queries": 0,
      "dashboards": 0
    }
  }
}"#;

/// A one-component project. `assets` is spliced into `component.yml` verbatim so a test can declare
/// none at all and prove the no-asset path is untouched.
fn write_project(dir: &Path, assets: &str) {
    fs::create_dir_all(dir.join("context")).unwrap();
    // Nothing reads this directory any more; it exists only so the identity path below can be
    // canonicalized into the binding.
    fs::create_dir_all(dir.join("wren")).unwrap();
    fs::create_dir_all(dir.join("components/asker/steps")).unwrap();
    fs::create_dir_all(dir.join("components/asker/themes")).unwrap();
    fs::write(
        dir.join("profile.yml"),
        "profile: fixture\ncontext:\n  project: ./context/binding.yml\ncomponents:\n  - use: asker\n",
    )
    .unwrap();
    let wren_abs = dir.join("wren").canonicalize().unwrap();
    fs::write(
        dir.join("context/binding.yml"),
        format!(
            "kind: prepared\nproject: {}\ndocument: context/context.json\n",
            wren_abs.to_string_lossy()
        ),
    )
    .unwrap();
    fs::write(dir.join("context/context.json"), PREPARED_WIDGETS).unwrap();
    fs::write(
        dir.join("components/asker/component.yml"),
        format!(
            r#"
id: asker
verb: asker
type: analytical
realization_kind: skill
binding_mode: runtime_selected
context_precondition: []
params: []
llm_steps:
  - {{ name: ask, tier: cheap, prompt_ref: steps/ask.md }}
trigger: {{ kind: one_shot }}
guardrails:
  - {{ name: read_only_execution, locked: true }}
required_capabilities: [llm:cheap]
borrowed_actions: []
effect:
  render_blocks: []
  outcome:
    kind: none
{assets}
"#
        ),
    )
    .unwrap();
    fs::write(
        dir.join("components/asker/steps/ask.md"),
        "Ask something.\n",
    )
    .unwrap();
    fs::write(
        dir.join("components/asker/themes/dark.css"),
        "body { color: black }\n",
    )
    .unwrap();
}

const DECLARES_ONE_ASSET: &str = "assets:\n  - path: themes/dark.css";

fn compile(project: &Path) -> (serde_json::Value, warble_cli::CompiledAssets) {
    let sources = vec![ComponentSource::local(project.join("components"))];
    compile_project_to_ir_with_assets(project, &sources, &BuiltinContextResolver, None)
        .expect("the fixture project must compile")
}

#[test]
fn a_declared_asset_reaches_the_directory_the_agent_runs_in() {
    let project = tempfile::tempdir().unwrap();
    write_project(project.path(), DECLARES_ONE_ASSET);
    let (ir, assets) = compile(project.path());

    // Compile hands back the content, and writes it beside the IR.
    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, serde_json::to_string_pretty(&ir).unwrap()).unwrap();
    write_assets(&asset_dir_for_ir(&ir_path), &assets).unwrap();
    assert!(
        asset_dir_for_ir(&ir_path)
            .join("asker/themes/dark.css")
            .exists(),
        "compile writes the content beside the IR, since nothing downstream can re-read it"
    );

    // Dispatch lands it into the working directory, at the authored relative path.
    let out = tempfile::tempdir().unwrap();
    let landed = land_assets(&ir, &ir_path, out.path()).expect("landing must succeed");
    assert_eq!(landed.len(), 1);
    assert_eq!(
        fs::read_to_string(out.path().join("themes/dark.css")).unwrap(),
        "body { color: black }\n",
        "the agent's working directory holds the file the component declared"
    );
}

#[test]
fn an_asset_missing_from_the_travelling_directory_is_refused() {
    // The case a copied IR produces: the manifest names a file that did not come with it. Loud,
    // because silently running without it is precisely the defect this closes.
    let project = tempfile::tempdir().unwrap();
    write_project(project.path(), DECLARES_ONE_ASSET);
    let (ir, _assets) = compile(project.path());

    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, serde_json::to_string_pretty(&ir).unwrap()).unwrap();
    // Deliberately no `write_assets`.

    let out = tempfile::tempdir().unwrap();
    let err = land_assets(&ir, &ir_path, out.path()).expect_err("a missing asset must refuse");
    assert!(
        err.contains("declared in the IR but missing from"),
        "unexpected message: {err}"
    );
    assert!(
        !out.path().join("themes/dark.css").exists(),
        "and nothing is left half-landed"
    );
}

#[test]
fn an_asset_whose_content_changed_after_compile_is_refused() {
    let project = tempfile::tempdir().unwrap();
    write_project(project.path(), DECLARES_ONE_ASSET);
    let (ir, assets) = compile(project.path());

    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, serde_json::to_string_pretty(&ir).unwrap()).unwrap();
    write_assets(&asset_dir_for_ir(&ir_path), &assets).unwrap();
    // Same path, different bytes: the manifest now names content that is not there.
    fs::write(
        asset_dir_for_ir(&ir_path).join("asker/themes/dark.css"),
        "body { color: red }\n",
    )
    .unwrap();

    let out = tempfile::tempdir().unwrap();
    let err = land_assets(&ir, &ir_path, out.path()).expect_err("a hash mismatch must refuse");
    assert!(
        err.contains("does not match its manifest"),
        "unexpected message: {err}"
    );
}

#[test]
fn a_component_declaring_no_assets_is_untouched() {
    let project = tempfile::tempdir().unwrap();
    write_project(project.path(), "");
    let (ir, assets) = compile(project.path());

    assert!(assets.is_empty(), "nothing is collected");
    assert!(
        ir["components"][0].get("assets").is_none(),
        "and the IR carries no assets key, exactly as before this existed"
    );

    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, serde_json::to_string_pretty(&ir).unwrap()).unwrap();
    write_assets(&asset_dir_for_ir(&ir_path), &assets).unwrap();
    assert!(
        !asset_dir_for_ir(&ir_path).exists(),
        "no directory is created for a project without assets"
    );

    let out = tempfile::tempdir().unwrap();
    assert!(land_assets(&ir, &ir_path, out.path()).unwrap().is_empty());
}

/// A manifest that names a path outside the directory it lands in. Not authorable — compile refuses
/// such a reference — but an IR is a document that can arrive from anywhere, and until review found
/// this, nothing looked at the path again on the way in.
fn ir_declaring_asset_path(path: &str, content: &str) -> serde_json::Value {
    serde_json::json!({
        "warble_ir_version": "0.7",
        "profile": "fixture",
        "components": [{
            "id": "asker",
            "assets": [{
                "path": path,
                "hash": format!("sha256:{:x}", sha2::Sha256::digest(content.as_bytes())),
                "bytes": content.len(),
            }],
        }],
    })
}

#[test]
fn a_manifest_path_that_climbs_out_of_the_output_directory_is_refused() {
    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, "{}").unwrap();
    let out = tempfile::tempdir().unwrap();
    let escape_target = out.path().parent().unwrap().join("escaped.txt");

    let err = land_assets(
        &ir_declaring_asset_path("../escaped.txt", "owned"),
        &ir_path,
        out.path(),
    )
    .expect_err("a '..' segment must be refused, not resolved");

    // Deliberately a substring that cannot straddle a line wrap: the message is one `format!`
    // whose rendering `cargo fmt` may re-break, and an assertion spanning the break is brittle.
    assert!(err.contains("must be a relative path"), "unexpected: {err}");
    assert!(
        !escape_target.exists(),
        "and nothing is written outside the output directory"
    );
}

#[test]
fn an_absolute_manifest_path_is_refused() {
    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, "{}").unwrap();
    let out = tempfile::tempdir().unwrap();

    let err = land_assets(
        &ir_declaring_asset_path("/tmp/warble-absolute-escape.txt", "owned"),
        &ir_path,
        out.path(),
    )
    .expect_err("an absolute path must be refused: join would replace the base entirely");
    assert!(err.contains("must be a relative path"), "unexpected: {err}");
}

#[test]
fn a_multi_asset_component_whose_second_asset_fails_lands_neither() {
    // The property the docs promised and the Rust side did not keep: it verified and wrote each
    // asset in the same pass, so an earlier file was already on disk when a later one failed. Every
    // fixture above declares a single asset, which cannot tell the two implementations apart.
    let project = tempfile::tempdir().unwrap();
    write_project(
        project.path(),
        "assets:\n  - path: themes/dark.css\n  - path: themes/light.css",
    );
    fs::write(
        project.path().join("components/asker/themes/light.css"),
        "body { color: white }\n",
    )
    .unwrap();
    let (ir, assets) = compile(project.path());

    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, serde_json::to_string_pretty(&ir).unwrap()).unwrap();
    write_assets(&asset_dir_for_ir(&ir_path), &assets).unwrap();
    // Break only the SECOND asset, so a write-as-you-go implementation has already landed the first.
    fs::write(
        asset_dir_for_ir(&ir_path).join("asker/themes/light.css"),
        "tampered\n",
    )
    .unwrap();

    let out = tempfile::tempdir().unwrap();
    land_assets(&ir, &ir_path, out.path()).expect_err("the tampered second asset must refuse");
    assert!(
        !out.path().join("themes/dark.css").exists(),
        "the first asset must not have landed either — the set is all or nothing"
    );
}

#[test]
fn write_assets_refuses_a_path_that_escapes_the_directory_it_writes_into() {
    // The other side of the same trust boundary, and the one that was asserted in code and by
    // nothing else: a mutation removing this check left every other test green, because they all
    // exercise `land_assets` instead. Compile validates what an author wrote, but `write_assets` is
    // a public function taking a caller-supplied map.
    let root = tempfile::tempdir().unwrap();
    let escape_target = root.path().parent().unwrap().join("written-outside.txt");
    let mut assets = warble_cli::CompiledAssets::new();
    assets.insert(
        "asker".to_string(),
        vec![("../written-outside.txt".to_string(), b"owned".to_vec())],
    );

    let err = write_assets(root.path(), &assets).expect_err("a '..' segment must be refused");
    assert!(err.contains("must be a relative path"), "unexpected: {err}");
    assert!(
        !escape_target.exists(),
        "and nothing is written outside the root"
    );
}

#[test]
fn a_symlinked_parent_inside_the_output_directory_does_not_let_an_asset_escape() {
    // Review constructed this after the string check landed: a manifest path with no `..` and not
    // absolute still escapes when a directory component of it is a symlink. Reachable because an
    // output directory may hold whatever a previous step left in it.
    let project = tempfile::tempdir().unwrap();
    write_project(project.path(), DECLARES_ONE_ASSET);
    let (ir, assets) = compile(project.path());

    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, serde_json::to_string_pretty(&ir).unwrap()).unwrap();
    write_assets(&asset_dir_for_ir(&ir_path), &assets).unwrap();

    let out = tempfile::tempdir().unwrap();
    let elsewhere = tempfile::tempdir().unwrap();
    #[cfg(unix)]
    std::os::unix::fs::symlink(elsewhere.path(), out.path().join("themes")).unwrap();

    let err = land_assets(&ir, &ir_path, out.path())
        .expect_err("a symlinked parent must be refused, not followed");
    assert!(err.contains("resolves outside"), "unexpected: {err}");
    assert!(
        !elsewhere.path().join("dark.css").exists(),
        "and nothing is written through the symlink"
    );
}

#[test]
fn a_symlink_inside_the_travelling_directory_is_not_read_through() {
    // The read side of the same gap. The content hashes correctly, so only a resolved-location
    // check can refuse it.
    let project = tempfile::tempdir().unwrap();
    write_project(project.path(), DECLARES_ONE_ASSET);
    let (ir, _assets) = compile(project.path());

    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, serde_json::to_string_pretty(&ir).unwrap()).unwrap();
    let travelling = asset_dir_for_ir(&ir_path).join("asker/themes");
    fs::create_dir_all(&travelling).unwrap();
    let outside = tempfile::tempdir().unwrap();
    let secret = outside.path().join("secret.css");
    fs::write(&secret, "body { color: black }\n").unwrap();
    #[cfg(unix)]
    std::os::unix::fs::symlink(&secret, travelling.join("dark.css")).unwrap();

    let out = tempfile::tempdir().unwrap();
    let err = land_assets(&ir, &ir_path, out.path())
        .expect_err("a symlinked source must be refused even when its content hashes correctly");
    assert!(err.contains("resolves outside"), "unexpected: {err}");
}

#[test]
#[cfg(unix)]
fn a_pipe_planted_at_a_declared_asset_path_is_refused_rather_than_read() {
    // Review constructed this after the symlink fix: reading before deciding meant a FIFO with no
    // writer hung the dispatch forever, and the containment check never ran. It needs no symlink and
    // no path trickery — the path here is exactly the one the component declared — so the previous
    // round's check could not help. Run on a worker thread with a deadline: a regression hangs the
    // read forever, and this must fail rather than wedge the suite.
    use std::sync::mpsc;
    use std::time::Duration;

    let project = tempfile::tempdir().unwrap();
    write_project(project.path(), DECLARES_ONE_ASSET);
    let (ir, _assets) = compile(project.path());

    let staging = tempfile::tempdir().unwrap();
    let ir_path = staging.path().join("ir.json");
    fs::write(&ir_path, serde_json::to_string_pretty(&ir).unwrap()).unwrap();
    let travelling = asset_dir_for_ir(&ir_path).join("asker/themes");
    fs::create_dir_all(&travelling).unwrap();
    let fifo = travelling.join("dark.css");
    let status = std::process::Command::new("mkfifo")
        .arg(&fifo)
        .status()
        .expect("mkfifo must be available on a unix test host");
    assert!(status.success(), "mkfifo failed");

    let out = tempfile::tempdir().unwrap();
    let (tx, rx) = mpsc::channel();
    let ir_for_thread = ir.clone();
    let ir_path_for_thread = ir_path.clone();
    let out_for_thread = out.path().to_path_buf();
    std::thread::spawn(move || {
        let result = land_assets(&ir_for_thread, &ir_path_for_thread, &out_for_thread);
        let _ = tx.send(result.map(|_| ()));
    });

    match rx.recv_timeout(Duration::from_secs(10)) {
        Ok(Err(err)) => assert!(
            err.contains("is not a regular file"),
            "unexpected message: {err}"
        ),
        Ok(Ok(())) => panic!("a pipe must not be accepted as an asset"),
        Err(_) => panic!("land_assets blocked on a pipe instead of refusing it"),
    }
}

#[test]
#[cfg(unix)]
fn write_assets_creates_nothing_through_a_symlinked_component_directory() {
    // Review found that `write_assets` created the target's parent BEFORE checking containment, so
    // a symlinked component directory got a directory made through it even though the file write was
    // then correctly refused. "Refused rather than followed" has to include creating nothing — and
    // the fix for it had no test until this one: a mutation restoring the premature create passed
    // every other case, because they all use a root with no symlink in it.
    let root = tempfile::tempdir().unwrap();
    let elsewhere = tempfile::tempdir().unwrap();
    std::os::unix::fs::symlink(elsewhere.path(), root.path().join("asker")).unwrap();

    let mut assets = warble_cli::CompiledAssets::new();
    assets.insert(
        "asker".to_string(),
        vec![("themes/dark.css".to_string(), b"body {}\n".to_vec())],
    );

    let err = write_assets(root.path(), &assets)
        .expect_err("a symlinked component directory must be refused");
    assert!(err.contains("resolves outside"), "unexpected: {err}");
    assert!(
        !elsewhere.path().join("themes").exists(),
        "and no directory is created through the symlink"
    );
}