Refuse a trailing argument that names one of the subcommand's own flags

6a3ac28fe8b4 · AtlantisPleb · · parent cd0c05d46578

Refuse a trailing argument that names one of the subcommand's own flags

`oa box exec`, `oa box run`, and `oa memory add` take the rest of the line
as one opaque argument, which is right: `grep --color foo` has to reach the
box with its own flag intact. It also captured `oa`'s own flags. This ran
against production:

    oa box exec bx_8bhkse3n "echo hi" --conversation 3dd6d813-...

and sent `{"command":"echo hi --conversation 3dd6d813-..."}` to the box. The
conversation flag was never read, the id went into a remote shell, and
nothing in the output said either had happened.

`trailing_var_arg` stays. Before anything is dispatched, the trailing
arguments are scanned for a long token that names a flag of that same
subcommand, and such an invocation is refused by name, with the sentence
that says flags go before the command and `--` sends one through on
purpose. Nothing is sent: the check runs before the endpoint is resolved.

The flag set is read out of clap's own `Command` rather than a list kept by
hand, so a flag added to `BoxAction::Exec` tomorrow is covered that day, and
a test walks the tree for every `trailing_var_arg` and fails if one of them
is not registered. Only long tokens are matched, because `-v` is `--verbose`
here and `curl -v` is a different program's flag in every case that matters.

Forge issue #109.

Deploy story

What this commit did to the running system — joined from the forge receipt chain, the part a commit page elsewhere cannot show.

Not deployed through the forge lane

No push, promotion, build, or deploy receipt references this commit (receipts are scanned over a bounded recent window). Changes shipped by full node replacement carry their proof in the release gate receipt instead.

Changed files

  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/lib.rs
  • added crates/openagents-cli/src/trailing_args.rs
  • added crates/openagents-cli/tests/box_trailing_flags_test.rs

Diff

4 files changed, +724 -0

crates/openagents-cli/src/cli.rs modified +10

@@ -1272,6 +1272,16 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1272 1272
        std::process::exit(2);
1273 1273
    };
1274 1274
1275
    // `box exec`, `box run`, and `memory add` capture the rest of the line for
1276
    // something else, which is right, and which also captured `oa`'s own
1277
    // `--conversation` and sent the id to a remote shell without a word about
1278
    // the flag it was written for (#109). A trailing token that names a flag of
1279
    // the same subcommand is refused here, before an endpoint is resolved and
1280
    // before anything is sent anywhere.
1281
    if let Err(reason) = crate::trailing_args::check_command(&command) {
1282
        fail(&reason);
1283
    }
1284
1275 1285
    let endpoint =
1276 1286
        match crate::auth::resolve_endpoint(cli.api_url.as_deref(), cli.profile.as_deref()) {
1277 1287
            Ok(endpoint) => endpoint,
crates/openagents-cli/src/lib.rs modified +1

@@ -38,6 +38,7 @@ pub mod tools;

38 38
pub mod trace;
39 39
pub mod trace_client;
40 40
pub mod tracker;
41
pub mod trailing_args;
41 42
pub mod tui;
42 43
pub mod update;
43 44
pub mod workspace;
crates/openagents-cli/src/trailing_args.rs added +464

@@ -0,0 +1,464 @@

1
//! A trailing command must not swallow one of `oa`'s own flags.
2
//!
3
//! `oa box exec`, `oa box run`, and `oa memory add` take the rest of the line
4
//! as one opaque argument for something else: a command for the box, or the
5
//! text of a memory. Clap calls that `trailing_var_arg`, and it is the right
6
//! shape — `oa box exec bx_1 grep --color foo` has to send `--color` to
7
//! `grep`, not read it as a flag of `oa`.
8
//!
9
//! The cost is that a flag `oa` does define is captured the same way. This ran
10
//! against production:
11
//!
12
//! ```text
13
//! oa box exec bx_8bhkse3n "echo hi" --conversation 3dd6d813-...
14
//! ```
15
//!
16
//! `echo hi --conversation 3dd6d813-...` ran on the box, `--conversation` was
17
//! never read, and nothing in the output said so — the conversation id went to
18
//! a remote shell and the flag it was written for was ignored in silence.
19
//! Forge issue #109.
20
//!
21
//! So the trailing arguments are scanned before anything is dispatched, and a
22
//! token that names a flag of that same subcommand is refused. The flag set is
23
//! read out of clap's own `Command`, so a flag added to `BoxAction::Exec`
24
//! tomorrow is covered the day it is added rather than the day someone
25
//! remembers to edit a list here. `--` still passes anything through
26
//! deliberately, and `every_trailing_var_arg_in_the_tree_is_registered` holds
27
//! the registry below to every `trailing_var_arg` in the tree.
28
29
use clap::CommandFactory;
30
31
use crate::cli::{BoxAction, Cli, Commands, MemoryAction};
32
33
/// Every subcommand whose last positional swallows the rest of the line.
34
///
35
/// The tests walk the whole command tree for `trailing_var_arg` positionals
36
/// and fail if one of them is missing here, so a fourth such subcommand cannot
37
/// be added without either the guard or a deliberate decision to skip it.
38
pub const TRAILING_COMMANDS: &[&[&str]] = &[&["box", "exec"], &["box", "run"], &["memory", "add"]];
39
40
/// Refuse the parsed command when its trailing arguments carry one of its own
41
/// flags, reading the separator out of this process's argv.
42
pub fn check_command(command: &Commands) -> Result<(), String> {
43
    let argv: Vec<String> = std::env::args().collect();
44
    check_argv(command, &argv)
45
}
46
47
/// The same, against an argv the caller supplies.
48
pub fn check_argv(command: &Commands, argv: &[String]) -> Result<(), String> {
49
    let Some((path, trailing)) = trailing_of(command) else {
50
        return Ok(());
51
    };
52
    check(path, trailing, has_separator(argv))
53
}
54
55
/// The subcommand path and the trailing arguments, for the subcommands that
56
/// have any.
57
fn trailing_of(command: &Commands) -> Option<(&'static [&'static str], &Vec<String>)> {
58
    match command {
59
        Commands::Box(args) => match &args.action {
60
            BoxAction::Exec { command, .. } => Some((&["box", "exec"], command)),
61
            BoxAction::Run { command, .. } => Some((&["box", "run"], command)),
62
            _ => None,
63
        },
64
        Commands::Memory(args) => match &args.action {
65
            MemoryAction::Add { body, .. } => Some((&["memory", "add"], body)),
66
            _ => None,
67
        },
68
        _ => None,
69
    }
70
}
71
72
/// Whether the invocation wrote an explicit `--`.
73
///
74
/// Clap consumes the first one, so it is not in the parsed trailing arguments
75
/// and has to be read off argv. A caller who wrote it has said where the
76
/// boundary is, and gets what they asked for without a word from here.
77
pub fn has_separator<S: AsRef<str>>(argv: &[S]) -> bool {
78
    argv.iter().any(|argument| argument.as_ref() == "--")
79
}
80
81
/// The refusal for one subcommand's trailing arguments.
82
pub fn check(path: &[&str], trailing: &[String], separated: bool) -> Result<(), String> {
83
    if separated {
84
        return Ok(());
85
    }
86
    let own = own_long_flags(path);
87
    for token in trailing {
88
        let Some(name) = long_flag_name(token) else {
89
            continue;
90
        };
91
        if own.iter().any(|flag| flag == name) {
92
            return Err(refusal(path, name));
93
        }
94
    }
95
    Ok(())
96
}
97
98
/// The long name a token names, if it names one.
99
///
100
/// Only long tokens are matched. `--conversation` and `--conversation=abc` are
101
/// the shape of the mistake, and no flag on any of these subcommands has a
102
/// short name of its own. Matching short tokens would refuse `curl -v` and
103
/// `rm -rf` over `-v` on the root command, which is a flag of a different
104
/// program in every case that matters.
105
fn long_flag_name(token: &str) -> Option<&str> {
106
    let rest = token.strip_prefix("--").filter(|rest| !rest.is_empty())?;
107
    rest.split('=').next()
108
}
109
110
/// Every long flag the named subcommand accepts, as clap holds it.
111
///
112
/// Globals count. `oa box exec --json bx_1 uptime` reads `--json`, so
113
/// `oa box exec bx_1 uptime --json` is the same mistake as the one #109
114
/// reports, and refusing it names a flag the reader can move rather than
115
/// leaving `--json` in the box's argv.
116
///
117
/// An unknown path yields nothing, which refuses nothing. The tests hold every
118
/// entry in `TRAILING_COMMANDS` to a real subcommand with real flags.
119
pub fn own_long_flags(path: &[&str]) -> Vec<String> {
120
    let mut root = Cli::command();
121
    root.build();
122
    let Some(command) = descend(&root, path) else {
123
        return Vec::new();
124
    };
125
    let mut names = Vec::new();
126
    for argument in command.get_arguments() {
127
        if argument.is_positional() {
128
            continue;
129
        }
130
        for long in argument
131
            .get_long_and_visible_aliases()
132
            .into_iter()
133
            .flatten()
134
            .chain(argument.get_all_aliases().into_iter().flatten())
135
        {
136
            let long = long.to_string();
137
            if !names.contains(&long) {
138
                names.push(long);
139
            }
140
        }
141
    }
142
    names
143
}
144
145
/// Walk a subcommand path from the root command.
146
fn descend<'a>(root: &'a clap::Command, path: &[&str]) -> Option<&'a clap::Command> {
147
    let mut current = root;
148
    for segment in path {
149
        current = current.find_subcommand(segment)?;
150
    }
151
    Some(current)
152
}
153
154
/// What the trailing positional is called in help, such as `<COMMAND>`.
155
fn trailing_value_name(path: &[&str]) -> String {
156
    let mut root = Cli::command();
157
    root.build();
158
    let named = descend(&root, path).and_then(|command| {
159
        command
160
            .get_arguments()
161
            .find(|argument| argument.is_trailing_var_arg_set())
162
            .and_then(|argument| argument.get_value_names().and_then(|names| names.first()))
163
            .map(|name| format!("<{name}>"))
164
    });
165
    named.unwrap_or_else(|| "the trailing argument".to_string())
166
}
167
168
/// The sentence the caller acts on.
169
fn refusal(path: &[&str], flag: &str) -> String {
170
    let value = trailing_value_name(path);
171
    format!(
172
        "`--{flag}` is a flag of `oa {command}`, and a flag written after {value} is read as \
173
         part of {value} rather than as a flag. Write `--{flag}` before {value}, or write `--` \
174
         before {value} to send `--{flag}` through on purpose.",
175
        command = path.join(" "),
176
    )
177
}
178
179
#[cfg(test)]
180
mod tests {
181
    use super::*;
182
    use clap::Parser;
183
184
    /// Parse an argv the way `main` does, then run the guard over it.
185
    fn guard(argv: &[&str]) -> Result<(), String> {
186
        let owned: Vec<String> = argv.iter().map(|a| a.to_string()).collect();
187
        let cli = Cli::try_parse_from(&owned).expect("the invocation must parse");
188
        let command = cli.command.expect("the invocation names a subcommand");
189
        check_argv(&command, &owned)
190
    }
191
192
    /// The invocation from the issue. The conversation id must not reach the
193
    /// box, and the refusal has to name the flag that was dropped.
194
    #[test]
195
    fn box_exec_refuses_a_trailing_conversation_flag() {
196
        let error = guard(&[
197
            "oa",
198
            "box",
199
            "exec",
200
            "bx_8bhkse3n",
201
            "echo hi",
202
            "--conversation",
203
            "3dd6d813-0000-4000-8000-000000000000",
204
        ])
205
        .expect_err("a trailing --conversation must be refused");
206
        assert!(
207
            error.contains("--conversation"),
208
            "the refusal must name the flag; it said: {error}"
209
        );
210
        assert!(
211
            error.contains("oa box exec"),
212
            "the refusal must name the subcommand whose flag it is; it said: {error}"
213
        );
214
        assert!(
215
            error.contains("before"),
216
            "the refusal must say where the flag goes; it said: {error}"
217
        );
218
    }
219
220
    /// `--flag=value` is the same flag.
221
    #[test]
222
    fn box_exec_refuses_the_joined_form() {
223
        let error = guard(&[
224
            "oa",
225
            "box",
226
            "exec",
227
            "bx_1",
228
            "echo hi",
229
            "--conversation=3dd6d813",
230
        ])
231
        .expect_err("--conversation=... must be refused too");
232
        assert!(error.contains("--conversation"), "it said: {error}");
233
    }
234
235
    /// Every flag of the subcommand, not one named in a list.
236
    #[test]
237
    fn box_exec_refuses_a_trailing_timeout_flag() {
238
        let error = guard(&["oa", "box", "exec", "bx_1", "sleep 30", "--timeout", "5"])
239
            .expect_err("a trailing --timeout must be refused");
240
        assert!(error.contains("--timeout"), "it said: {error}");
241
    }
242
243
    /// A global is a flag of the subcommand too: `oa box exec --json bx_1 ls`
244
    /// reads it, so writing it after the command is the same mistake.
245
    #[test]
246
    fn box_exec_refuses_a_trailing_global_flag() {
247
        let error = guard(&["oa", "box", "exec", "bx_1", "uptime", "--json"])
248
            .expect_err("a trailing --json must be refused");
249
        assert!(error.contains("--json"), "it said: {error}");
250
    }
251
252
    /// The remote program's own flags are none of `oa`'s business.
253
    #[test]
254
    fn a_flag_that_belongs_to_the_remote_command_passes() {
255
        guard(&["oa", "box", "exec", "bx_1", "ls", "--color=auto"])
256
            .expect("--color is not a flag of oa box exec");
257
        guard(&["oa", "box", "exec", "bx_1", "grep", "--color", "foo"])
258
            .expect("--color is not a flag of oa box exec");
259
        guard(&["oa", "box", "exec", "bx_1", "npm", "--registry", "http://x"])
260
            .expect("--registry is not a flag of oa box exec");
261
    }
262
263
    /// Short tokens are the remote program's. `-v` is `--verbose` on `oa`, and
264
    /// refusing `curl -v` over that would break more than it saves.
265
    #[test]
266
    fn short_tokens_pass() {
267
        guard(&["oa", "box", "exec", "bx_1", "rm", "-rf", "/tmp/x"])
268
            .expect("rm -rf must still run");
269
        guard(&[
270
            "oa",
271
            "box",
272
            "exec",
273
            "bx_1",
274
            "curl",
275
            "-v",
276
            "https://example.com",
277
        ])
278
        .expect("curl -v must still run");
279
    }
280
281
    /// `--` says where the boundary is, so the guard has nothing to say.
282
    #[test]
283
    fn an_explicit_separator_passes_anything_through() {
284
        guard(&[
285
            "oa",
286
            "box",
287
            "exec",
288
            "bx_1",
289
            "--",
290
            "mytool",
291
            "--conversation",
292
            "abc",
293
        ])
294
        .expect("`--` is the caller saying they meant it");
295
    }
296
297
    /// The correct invocation is not refused, and the flag is read.
298
    #[test]
299
    fn a_flag_before_the_command_is_read_and_passes() {
300
        let argv: Vec<String> = [
301
            "oa",
302
            "box",
303
            "exec",
304
            "--conversation",
305
            "abc",
306
            "bx_1",
307
            "echo hi",
308
        ]
309
        .iter()
310
        .map(|a| a.to_string())
311
        .collect();
312
        let cli = Cli::try_parse_from(&argv).expect("the invocation must parse");
313
        let command = cli.command.expect("a subcommand");
314
        match &command {
315
            Commands::Box(args) => match &args.action {
316
                BoxAction::Exec {
317
                    conversation,
318
                    command: trailing,
319
                    ..
320
                } => {
321
                    assert_eq!(conversation.as_deref(), Some("abc"));
322
                    assert_eq!(trailing, &vec!["echo hi".to_string()]);
323
                }
324
                other => panic!("expected box exec, got {other:?}"),
325
            },
326
            other => panic!("expected box, got {other:?}"),
327
        }
328
        check_argv(&command, &argv).expect("the correct invocation must pass");
329
    }
330
331
    /// `box run` has the same shape and the same bug.
332
    #[test]
333
    fn box_run_refuses_a_trailing_conversation_flag() {
334
        let error = guard(&[
335
            "oa",
336
            "box",
337
            "run",
338
            "bx_1",
339
            "cargo test",
340
            "--conversation",
341
            "abc",
342
        ])
343
        .expect_err("a trailing --conversation must be refused");
344
        assert!(error.contains("--conversation"), "it said: {error}");
345
        assert!(
346
            error.contains("oa box run"),
347
            "the refusal must name `oa box run`; it said: {error}"
348
        );
349
    }
350
351
    /// So does `memory add`, whose trailing argument is the memory itself.
352
    #[test]
353
    fn memory_add_refuses_a_trailing_supersedes_flag() {
354
        let error = guard(&[
355
            "oa",
356
            "memory",
357
            "add",
358
            "the box ids are short lived",
359
            "--supersedes",
360
            "mem_1",
361
        ])
362
        .expect_err("a trailing --supersedes must be refused");
363
        assert!(error.contains("--supersedes"), "it said: {error}");
364
        assert!(
365
            error.contains("oa memory add"),
366
            "the refusal must name `oa memory add`; it said: {error}"
367
        );
368
    }
369
370
    /// A memory that talks about some other program's flags is still a memory.
371
    #[test]
372
    fn memory_add_keeps_text_that_is_not_one_of_its_flags() {
373
        guard(&[
374
            "oa",
375
            "memory",
376
            "add",
377
            "run",
378
            "cargo",
379
            "--release",
380
            "for bench work",
381
        ])
382
        .expect("--release is not a flag of oa memory add");
383
    }
384
385
    /// A subcommand without a trailing positional is not touched.
386
    #[test]
387
    fn a_subcommand_without_trailing_arguments_is_untouched() {
388
        guard(&["oa", "box", "list", "--conversation", "abc"])
389
            .expect("box list parses --conversation as a flag, so there is nothing to guard");
390
    }
391
392
    /// The flag set comes out of clap, not out of a list written by hand.
393
    #[test]
394
    fn the_flag_set_is_read_from_the_command_tree() {
395
        let flags = own_long_flags(&["box", "exec"]);
396
        for expected in ["conversation", "timeout", "json", "verbose"] {
397
            assert!(
398
                flags.iter().any(|flag| flag == expected),
399
                "`--{expected}` is a flag of `oa box exec`, and the derived set is {flags:?}"
400
            );
401
        }
402
        assert!(
403
            !flags.iter().any(|flag| flag == "color"),
404
            "`--color` is not a flag of `oa box exec`, and the derived set is {flags:?}"
405
        );
406
    }
407
408
    /// Every `trailing_var_arg` in the tree is guarded.
409
    ///
410
    /// This is the part that does not rot: a fourth subcommand that captures
411
    /// the rest of the line fails here until it is listed.
412
    #[test]
413
    fn every_trailing_var_arg_in_the_tree_is_registered() {
414
        let mut root = Cli::command();
415
        root.build();
416
        let mut found: Vec<Vec<String>> = Vec::new();
417
        collect_trailing(&root, &mut Vec::new(), &mut found);
418
        assert!(
419
            !found.is_empty(),
420
            "the walk found no trailing_var_arg at all, so it is not testing anything"
421
        );
422
        for path in &found {
423
            let names: Vec<&str> = path.iter().map(|s| s.as_str()).collect();
424
            assert!(
425
                TRAILING_COMMANDS.contains(&names.as_slice()),
426
                "`oa {}` captures the rest of the line and is not in TRAILING_COMMANDS, \
427
                 so a flag written after its command is swallowed in silence",
428
                path.join(" ")
429
            );
430
        }
431
        for known in TRAILING_COMMANDS {
432
            let owned: Vec<String> = known.iter().map(|s| s.to_string()).collect();
433
            assert!(
434
                found.contains(&owned),
435
                "`oa {}` is registered but has no trailing_var_arg positional",
436
                known.join(" ")
437
            );
438
            assert!(
439
                !own_long_flags(known).is_empty(),
440
                "`oa {}` resolved to no flags, so the guard over it refuses nothing",
441
                known.join(" ")
442
            );
443
        }
444
    }
445
446
    fn collect_trailing(
447
        command: &clap::Command,
448
        path: &mut Vec<String>,
449
        found: &mut Vec<Vec<String>>,
450
    ) {
451
        if !path.is_empty()
452
            && command
453
                .get_arguments()
454
                .any(|argument| argument.is_trailing_var_arg_set())
455
        {
456
            found.push(path.clone());
457
        }
458
        for child in command.get_subcommands() {
459
            path.push(child.get_name().to_string());
460
            collect_trailing(child, path, found);
461
            path.pop();
462
        }
463
    }
464
}
crates/openagents-cli/tests/box_trailing_flags_test.rs added +249

@@ -0,0 +1,249 @@

1
//! A flag written after the command must not reach the box.
2
//!
3
//! `oa box exec` and `oa box run` capture the rest of the line for the box,
4
//! and that captured `oa`'s own `--conversation` too: the id went into the
5
//! remote shell's argv, the flag was never read, and nothing said so
6
//! (forge issue #109).
7
//!
8
//! These run the real binary against a stub server, so they assert the two
9
//! things a unit test cannot: that the refusal happens before any request is
10
//! sent, and that a trailing argument the guard leaves alone still arrives at
11
//! the box unchanged.
12
13
use std::io::{BufRead, BufReader, Read, Write};
14
use std::net::{TcpListener, TcpStream};
15
use std::process::Command;
16
use std::sync::mpsc;
17
use std::thread;
18
19
/// One canned body for every route: the conversation lookup reads
20
/// `conversation_id`, the command run reads `result`.
21
const BODY: &str = r#"{"conversation_id":"cnv_1","result":{"box_id":"bx_1","exit_code":0,"stdout":"ok","stderr":"","timed_out":false},"run":{"id":"run_1","box_id":"bx_1","state":"running"}}"#;
22
23
/// What the server was asked for.
24
struct Hit {
25
    path: String,
26
    body: String,
27
}
28
29
struct StubServer {
30
    port: u16,
31
    hits: mpsc::Receiver<Hit>,
32
}
33
34
impl StubServer {
35
    fn start() -> Self {
36
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind a port");
37
        let port = listener.local_addr().expect("read the port").port();
38
        let (tx, hits) = mpsc::channel();
39
        thread::spawn(move || {
40
            for stream in listener.incoming() {
41
                let Ok(stream) = stream else { break };
42
                let tx = tx.clone();
43
                thread::spawn(move || serve_one(stream, tx));
44
            }
45
        });
46
        Self { port, hits }
47
    }
48
49
    fn origin(&self) -> String {
50
        format!("http://127.0.0.1:{}", self.port)
51
    }
52
53
    fn hits(&self) -> Vec<Hit> {
54
        self.hits.try_iter().collect()
55
    }
56
}
57
58
fn serve_one(mut stream: TcpStream, hits: mpsc::Sender<Hit>) {
59
    let mut reader = BufReader::new(stream.try_clone().expect("clone the stream"));
60
    let mut request_line = String::new();
61
    if reader.read_line(&mut request_line).is_err() {
62
        return;
63
    }
64
    let path = request_line
65
        .split_whitespace()
66
        .nth(1)
67
        .unwrap_or("")
68
        .to_string();
69
    let mut length = 0usize;
70
    loop {
71
        let mut header = String::new();
72
        if reader.read_line(&mut header).unwrap_or(0) == 0 {
73
            break;
74
        }
75
        if header.trim().is_empty() {
76
            break;
77
        }
78
        if let Some(value) = header.to_lowercase().strip_prefix("content-length:") {
79
            length = value.trim().parse().unwrap_or(0);
80
        }
81
    }
82
    let mut body = vec![0u8; length];
83
    if length > 0 {
84
        let _ = reader.read_exact(&mut body);
85
    }
86
    let _ = hits.send(Hit {
87
        path,
88
        body: String::from_utf8_lossy(&body).into_owned(),
89
    });
90
    let response = format!(
91
        "HTTP/1.1 200 OK\r\nContent-Type: application/json\r\nContent-Length: {}\r\nConnection: close\r\n\r\n{}",
92
        BODY.len(),
93
        BODY
94
    );
95
    let _ = stream.write_all(response.as_bytes());
96
    let _ = stream.flush();
97
}
98
99
struct Output {
100
    stderr: String,
101
    status: Option<i32>,
102
}
103
104
fn oa(args: &[&str]) -> Output {
105
    let result = Command::new(env!("CARGO_BIN_EXE_oa"))
106
        .args(args)
107
        .env("NO_COLOR", "")
108
        .output()
109
        .expect("run oa");
110
    Output {
111
        stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
112
        status: result.status.code(),
113
    }
114
}
115
116
/// The invocation from the issue: refused, named, and nothing sent.
117
#[test]
118
fn a_trailing_conversation_flag_is_refused_before_anything_is_sent() {
119
    let server = StubServer::start();
120
    let origin = server.origin();
121
    let output = oa(&[
122
        "--api-url",
123
        &origin,
124
        "box",
125
        "exec",
126
        "bx_8bhkse3n",
127
        "echo hi",
128
        "--conversation",
129
        "3dd6d813-0000-4000-8000-000000000000",
130
    ]);
131
132
    assert_ne!(
133
        output.status,
134
        Some(0),
135
        "a swallowed flag must not be a success; stderr was: {}",
136
        output.stderr
137
    );
138
    assert!(
139
        output.stderr.contains("--conversation"),
140
        "the refusal must name the flag that was about to be swallowed; it said: {}",
141
        output.stderr
142
    );
143
    assert!(
144
        output.stderr.contains("oa box exec"),
145
        "the refusal must name the subcommand whose flag it is; it said: {}",
146
        output.stderr
147
    );
148
149
    let hits = server.hits();
150
    assert!(
151
        hits.is_empty(),
152
        "nothing may be sent for a refused invocation, and the conversation id \
153
         must never reach a remote shell; the server was asked for {:?}",
154
        hits.iter().map(|hit| &hit.path).collect::<Vec<_>>()
155
    );
156
}
157
158
/// `oa box run` has the same shape and the same refusal.
159
#[test]
160
fn box_run_refuses_a_trailing_conversation_flag_too() {
161
    let server = StubServer::start();
162
    let origin = server.origin();
163
    let output = oa(&[
164
        "--api-url",
165
        &origin,
166
        "box",
167
        "run",
168
        "bx_1",
169
        "cargo test",
170
        "--conversation",
171
        "abc",
172
    ]);
173
174
    assert_ne!(output.status, Some(0), "stderr was: {}", output.stderr);
175
    assert!(
176
        output.stderr.contains("--conversation") && output.stderr.contains("oa box run"),
177
        "the refusal must name the flag and the subcommand; it said: {}",
178
        output.stderr
179
    );
180
    assert!(server.hits().is_empty(), "nothing may be sent");
181
}
182
183
/// A flag that belongs to the remote program still reaches the box, verbatim.
184
#[test]
185
fn a_remote_programs_own_flag_still_reaches_the_box() {
186
    let server = StubServer::start();
187
    let origin = server.origin();
188
    let output = oa(&[
189
        "--api-url",
190
        &origin,
191
        "box",
192
        "exec",
193
        "bx_1",
194
        "ls",
195
        "--color=auto",
196
    ]);
197
198
    assert_eq!(
199
        output.status,
200
        Some(0),
201
        "`ls --color=auto` is not one of this subcommand's flags; stderr was: {}",
202
        output.stderr
203
    );
204
    let sent = server
205
        .hits()
206
        .into_iter()
207
        .find(|hit| hit.path.ends_with("/commands"))
208
        .expect("the command must have been sent to the box");
209
    assert!(
210
        sent.body.contains("ls --color=auto"),
211
        "the command must arrive unchanged; the body was: {}",
212
        sent.body
213
    );
214
}
215
216
/// `--` is the caller saying where the boundary is, and it is honoured.
217
#[test]
218
fn an_explicit_separator_sends_the_flag_through() {
219
    let server = StubServer::start();
220
    let origin = server.origin();
221
    let output = oa(&[
222
        "--api-url",
223
        &origin,
224
        "box",
225
        "exec",
226
        "bx_1",
227
        "--",
228
        "mytool",
229
        "--conversation",
230
        "abc",
231
    ]);
232
233
    assert_eq!(
234
        output.status,
235
        Some(0),
236
        "`--` says the flag is the command's; stderr was: {}",
237
        output.stderr
238
    );
239
    let sent = server
240
        .hits()
241
        .into_iter()
242
        .find(|hit| hit.path.ends_with("/commands"))
243
        .expect("the command must have been sent to the box");
244
    assert!(
245
        sent.body.contains("mytool --conversation abc"),
246
        "the command must arrive unchanged; the body was: {}",
247
        sent.body
248
    );
249
}

This page updates live while a promote is in flight · changelog