Stop four bounded cuts panicking on a server's own bytes, and pin the parity

28704f72ff57 · AtlantisPleb · · parent c182a7ccf849

Stop four bounded cuts panicking on a server's own bytes, and pin the parity

Audit of #88. Eight commands aborted with exit 101 on any non-2xx whose
body was not JSON, longer than 400 bytes, and had a multi-byte character
straddling byte 400 — an HTML 502 from a proxy is exactly that shape.
`error_sentence` sliced `trimmed[..trimmed.len().min(400)]`, a byte index
into the server's own string, on the one code path whose whole job is to
report that the request was refused. Measured against a stub serving such
a body: `issue list`, `project list`, `milestone list`, `issue view`,
`box list`, `memory list`, `deploy list`, and `forum boards` all died with
a stack dump where the TypeScript CLI answered with one sentence and
exit 6.

`forum.rs` carried its own copy of the same slice, so `forum boards`
panicked at a different line and would have survived a fix to
`error_sentence` alone. `short_id` sliced server-supplied topic ids at
eight bytes, and `run_quietly` called `String::truncate(512)` on the
stdout of whatever binary the probe found — twenty lines above a version
cut that already floors its index.

The crate had this walk written out privately in eight places and used
none of them at these four sites. `tracker::floor_char_boundary` is now
public and the four call it.

`tests/parity_test.rs` holds the parity contract. Nothing in it asserts
`is_err()`: the predecessor suite did, against the live origin, where an
unplugged cable satisfies the assertion as well as a 404 does. Every
refusal test names the status it expects, so an offline machine turns
these red rather than green. The route tests read the request the stub
received, because a client that parses its arguments and sends nothing
looks identical at the process boundary to one that works.

Three tests are `#[ignore]`d with the divergence they document, and each
fails when run with `--ignored`: `oa repo list` appends a branch column
the TypeScript CLI does not, `oa memory add "text" --json` stores the
flag as part of the memory body, and `oa trace list` accepts `--json` and
prints the human table anyway.

cargo test -p openagents-cli: 530 passed, 0 failed, 3 ignored.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01SoZMfWRSGnf6FZX2Ar9rQ2
Co-Authored-By
Claude Opus 5 (1M context) <noreply@anthropic.com>

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/computer.rs
  • modified crates/openagents-cli/src/forum.rs
  • modified crates/openagents-cli/src/tracker.rs
  • added crates/openagents-cli/tests/parity_test.rs

Diff

5 files changed, +871 -4

crates/openagents-cli/src/cli.rs modified +5 -1

@@ -2077,8 +2077,12 @@ async fn run_repo(action: RepoAction, endpoint: &Endpoint, store: &CredentialSto

2077 2077
2078 2078
/// The first eight characters of a UUID, which is how the TypeScript CLI renders
2079 2079
/// topic ids in a listing.
2080
///
2081
/// The id comes from the server, so the eight-byte bound is floored to a
2082
/// character boundary. An id that is not a UUID would otherwise panic the
2083
/// listing rather than render short.
2080 2084
fn short_id(id: &str) -> &str {
2081
    &id[..id.len().min(8)]
2085
    &id[..crate::tracker::floor_char_boundary(id, 8)]
2082 2086
}
2083 2087
2084 2088
fn home_directory() -> std::path::PathBuf {
crates/openagents-cli/src/computer.rs modified +5 -1

@@ -1328,7 +1328,11 @@ fn run_quietly(argv: &[&str], cwd: &Path) -> String {

1328 1328
        Ok(output) if output.status.success() => {
1329 1329
            let mut text = String::from_utf8_lossy(&output.stdout).to_string();
1330 1330
            if text.len() > 512 {
1331
                text.truncate(512);
1331
                // `String::truncate` panics off a character boundary, and this
1332
                // is the stdout of whatever binary the probe found. The version
1333
                // cut twenty lines down already floors the index; this one did
1334
                // not.
1335
                text.truncate(bounded_index(&text, 512));
1332 1336
            }
1333 1337
            text.trim().to_string()
1334 1338
        }
crates/openagents-cli/src/forum.rs modified +5 -1

@@ -57,7 +57,11 @@ impl fmt::Display for ForumError {

57 57
                write!(f, "The forum API refused the request (HTTP {})", status)?;
58 58
                let trimmed = body.trim();
59 59
                if !trimmed.is_empty() {
60
                    write!(f, ": {}", &trimmed[..trimmed.len().min(400)])?;
60
                    // The body is the server's, so the 400-byte bound has to
61
                    // land on a character boundary or rendering the refusal
62
                    // panics instead of reporting it.
63
                    let end = crate::tracker::floor_char_boundary(trimmed, 400);
64
                    write!(f, ": {}", &trimmed[..end])?;
61 65
                }
62 66
                Ok(())
63 67
            }
crates/openagents-cli/src/tracker.rs modified +24 -1

@@ -67,6 +67,22 @@ impl fmt::Display for ApiError {

67 67
68 68
impl std::error::Error for ApiError {}
69 69
70
/// The largest index at or below `max` that `text` can be split on.
71
///
72
/// `str::floor_char_boundary` is still unstable, and several modules here need
73
/// it. Bounding a server-supplied string by a byte count without it is a panic
74
/// waiting for a multi-byte character to land on the limit.
75
pub fn floor_char_boundary(text: &str, max: usize) -> usize {
76
    if max >= text.len() {
77
        return text.len();
78
    }
79
    let mut index = max;
80
    while index > 0 && !text.is_char_boundary(index) {
81
        index -= 1;
82
    }
83
    index
84
}
85
70 86
/// Turns the unified error envelope into one sentence.
71 87
///
72 88
/// The deployment answers refusals in more than one shape: `{"message": …,

@@ -78,12 +94,19 @@ pub fn error_sentence(body: &str, status: u16) -> String {

78 94
    let parsed: Value = match serde_json::from_str(body) {
79 95
        Ok(value) => value,
80 96
        // A non-JSON body is still the server's answer. Print it, bounded.
97
        //
98
        // The bound is a byte count and the body is the server's, so the cut
99
        // has to land on a character boundary. Slicing at a fixed byte index
100
        // panicked the process on any non-JSON body over 400 bytes whose 400th
101
        // byte fell inside a multi-byte character — an HTML 502 from a proxy,
102
        // say — and it panicked on the one path whose whole job is to report
103
        // that the request was refused.
81 104
        Err(_) => {
82 105
            let trimmed = body.trim();
83 106
            return if trimmed.is_empty() {
84 107
                format!("The OpenAgents API returned HTTP {}.", status)
85 108
            } else {
86
                trimmed[..trimmed.len().min(400)].to_string()
109
                trimmed[..floor_char_boundary(trimmed, 400)].to_string()
87 110
            };
88 111
        }
89 112
    };
crates/openagents-cli/tests/parity_test.rs added +832

@@ -0,0 +1,832 @@

1
//! Parity between `oa` and the TypeScript `openagents` CLI, and the failure
2
//! contract both of them owe a caller.
3
//!
4
//! Written for issue #88, which was reopened because its predecessor suite was
5
//! 14 green assertions over a CLI that could not reach a model, could not list
6
//! a project, and invented forum boards. Two rules follow from that, and every
7
//! test here obeys both.
8
//!
9
//! **A refusal must be distinguishable from a network failure.** The old suite
10
//! asserted `result.is_err()` against the live origin, which an unplugged
11
//! cable satisfies exactly as well as a 404 does. Nothing here asserts
12
//! `is_err()`. Every refusal test names the status it expects and fails on any
13
//! other error, so an offline machine turns these red rather than green.
14
//!
15
//! **A test must fail if the feature regresses.** These run the real binary
16
//! against a server the test owns, and read the request that server actually
17
//! received. Asserting that a subcommand parses would pass against a binary
18
//! that parsed it and sent nothing.
19
//!
20
//! The TypeScript expectations are not read from a live `openagents` process —
21
//! that would make the suite depend on a `dist/` build that a fresh checkout
22
//! does not have, and a test that silently skips when its fixture is missing
23
//! is the defect this issue is about. They are recorded constants, each one
24
//! captured from a run of the TypeScript CLI at `cd0c05d465` and cited in the
25
//! comment above it. When the two CLIs are meant to agree, the recorded value
26
//! is what `oa` is asserted against.
27
28
use std::io::{BufRead, BufReader, Read, Write};
29
use std::net::{TcpListener, TcpStream};
30
use std::process::Command;
31
use std::sync::mpsc;
32
use std::thread;
33
34
use openagents_cli::forum::ForumError;
35
use openagents_cli::tracker::{
36
    error_sentence, ApiError, IssueListOptions, RepoTarget, TrackerClient,
37
};
38
39
// ---------------------------------------------------------------------------
40
// A server the test owns
41
// ---------------------------------------------------------------------------
42
43
/// One request the stub received. The point of recording it is that a client
44
/// which parses its arguments and sends nothing looks identical, at the
45
/// process boundary, to one that works.
46
#[derive(Debug, Clone)]
47
struct Hit {
48
    method: String,
49
    path: String,
50
}
51
52
impl Hit {
53
    fn route(&self) -> String {
54
        format!("{} {}", self.method, self.path)
55
    }
56
}
57
58
/// A server that answers from a script and records every request.
59
///
60
/// The script is a list of `(status, content_type, body)` answered in order,
61
/// with the last entry repeating.
62
struct StubServer {
63
    port: u16,
64
    hits: mpsc::Receiver<Hit>,
65
}
66
67
impl StubServer {
68
    fn start(script: Vec<(u16, &'static str, Vec<u8>)>) -> Self {
69
        let listener = TcpListener::bind("127.0.0.1:0").expect("bind a port");
70
        let port = listener.local_addr().expect("read the port").port();
71
        let (tx, hits) = mpsc::channel();
72
        thread::spawn(move || {
73
            for (answered, stream) in listener.incoming().enumerate() {
74
                let Ok(stream) = stream else { break };
75
                let index = answered.min(script.len().saturating_sub(1));
76
                let (code, content_type, body) = script[index].clone();
77
                serve_one(stream, code, content_type, &body, tx.clone());
78
            }
79
        });
80
        Self { port, hits }
81
    }
82
83
    /// A server that answers everything the same way.
84
    fn always(code: u16, content_type: &'static str, body: Vec<u8>) -> Self {
85
        Self::start(vec![(code, content_type, body)])
86
    }
87
88
    fn origin(&self) -> String {
89
        format!("http://127.0.0.1:{}", self.port)
90
    }
91
92
    fn api_base(&self) -> String {
93
        format!("{}/api/v1", self.origin())
94
    }
95
96
    fn hits(&self) -> Vec<Hit> {
97
        self.hits.try_iter().collect()
98
    }
99
}
100
101
fn serve_one(
102
    mut stream: TcpStream,
103
    code: u16,
104
    content_type: &str,
105
    body: &[u8],
106
    hits: mpsc::Sender<Hit>,
107
) {
108
    let mut reader = BufReader::new(stream.try_clone().expect("clone the stream"));
109
    let mut request_line = String::new();
110
    if reader.read_line(&mut request_line).is_err() {
111
        return;
112
    }
113
    let mut parts = request_line.split_whitespace();
114
    let method = parts.next().unwrap_or("").to_string();
115
    let path = parts.next().unwrap_or("").to_string();
116
    let mut length = 0usize;
117
    loop {
118
        let mut header = String::new();
119
        if reader.read_line(&mut header).unwrap_or(0) == 0 {
120
            break;
121
        }
122
        if header.trim().is_empty() {
123
            break;
124
        }
125
        if let Some(value) = header.to_lowercase().strip_prefix("content-length:") {
126
            length = value.trim().parse().unwrap_or(0);
127
        }
128
    }
129
    let mut payload = vec![0u8; length];
130
    if length > 0 && reader.read_exact(&mut payload).is_err() {
131
        return;
132
    }
133
    let _ = hits.send(Hit { method, path });
134
    let response = format!(
135
        "HTTP/1.1 {code} X\r\nContent-Type: {content_type}\r\nContent-Length: {}\r\nConnection: close\r\n\r\n",
136
        body.len()
137
    );
138
    let _ = stream.write_all(response.as_bytes());
139
    let _ = stream.write_all(body);
140
    let _ = stream.flush();
141
}
142
143
struct Run {
144
    stdout: String,
145
    stderr: String,
146
    status: Option<i32>,
147
}
148
149
impl Run {
150
    /// The exit code, insisting the process exited rather than died on a
151
    /// signal or a panic-abort. `None` here is itself a failure worth naming.
152
    fn code(&self) -> i32 {
153
        self.status
154
            .unwrap_or_else(|| panic!("oa did not exit normally. stderr: {}", self.stderr))
155
    }
156
157
    fn panicked(&self) -> bool {
158
        self.stderr.contains("panicked at")
159
    }
160
}
161
162
fn oa(origin: &str, args: &[&str]) -> Run {
163
    let mut full = vec!["--api-url", origin];
164
    full.extend(args.iter().copied());
165
    let result = Command::new(env!("CARGO_BIN_EXE_oa"))
166
        .args(&full)
167
        .env("NO_COLOR", "")
168
        .env("OPENAGENTS_TOKEN", "oa_pat_stub")
169
        .output()
170
        .expect("run oa");
171
    Run {
172
        stdout: String::from_utf8_lossy(&result.stdout).into_owned(),
173
        stderr: String::from_utf8_lossy(&result.stderr).into_owned(),
174
        status: result.status.code(),
175
    }
176
}
177
178
fn target() -> RepoTarget {
179
    RepoTarget {
180
        owner: "OpenAgentsInc".to_string(),
181
        repo: "openagents".to_string(),
182
    }
183
}
184
185
/// A list request the client will actually send. `IssueListOptions::default()`
186
/// has `limit: 0`, which `list_issues` refuses locally — a test using it would
187
/// assert on an input error and never reach the server at all.
188
fn one_page() -> IssueListOptions {
189
    IssueListOptions {
190
        limit: 25,
191
        ..Default::default()
192
    }
193
}
194
195
fn client(server: &StubServer) -> TrackerClient {
196
    TrackerClient::new(&server.api_base(), Some("oa_pat_stub".to_string()))
197
}
198
199
/// The status an `ApiError` carries, or a failure naming what arrived instead.
200
///
201
/// This is the whole point of the file. `assert!(result.is_err())` passes on a
202
/// DNS failure, a captive portal, and a closed laptop lid; this passes only on
203
/// a server that answered and refused.
204
fn refused_status(error: &ApiError) -> u16 {
205
    match error {
206
        ApiError::Refused { status, .. } => *status,
207
        other => panic!(
208
            "expected the server to answer and refuse, got a different failure: {other}. \
209
             A transport error here means the test proved nothing."
210
        ),
211
    }
212
}
213
214
// ---------------------------------------------------------------------------
215
// 1. A refusal is reported, never rendered as data
216
// ---------------------------------------------------------------------------
217
218
/// An HTML error page from a proxy is reported, not sliced mid-character.
219
///
220
/// This is the regression that reopened the issue in a new place. Every
221
/// non-2xx body reaches `error_sentence`, which bounded it at 400 *bytes*.
222
/// A 502 from a proxy is not JSON and is longer than that, and if its 400th
223
/// byte lands inside a multi-byte character the process aborts — on the one
224
/// code path whose entire job is to report that the request was refused.
225
///
226
/// Measured before the fix: `oa issue list`, `project list`, `milestone list`,
227
/// `issue view`, `box list`, `memory list`, `deploy list`, and `forum boards`
228
/// all died with exit 101 and a stack dump. The TypeScript CLI answered the
229
/// same body with exit 6 and one sentence.
230
///
231
/// The body below is built so byte 400 is the second byte of `é`.
232
#[test]
233
fn a_non_json_refusal_body_is_reported_rather_than_panicked_on() {
234
    let mut body = vec![b'A'; 399];
235
    body.extend("é".as_bytes());
236
    body.extend(vec![b'B'; 200]);
237
    assert!(!body.is_empty());
238
239
    // The cut has to be inside the character for this to test anything.
240
    let text = String::from_utf8(body.clone()).unwrap();
241
    assert!(
242
        !text.is_char_boundary(400),
243
        "the fixture no longer straddles the bound, so it proves nothing"
244
    );
245
246
    let sentence = error_sentence(&text, 502);
247
    assert!(
248
        sentence.starts_with("AAAA"),
249
        "the server's own body is what gets reported: {sentence}"
250
    );
251
    assert!(
252
        sentence.len() <= 400,
253
        "the sentence is bounded: {} bytes",
254
        sentence.len()
255
    );
256
}
257
258
/// The same body, through the real binary, on every client that renders one.
259
///
260
/// `error_sentence` is one function but four clients call it and a fifth
261
/// (`forum`) had its own copy of the same slice. Asserting the function alone
262
/// would have missed `forum boards`, which panicked at a different line.
263
#[test]
264
fn no_command_dies_on_a_proxy_error_page() {
265
    let mut body = vec![b'A'; 399];
266
    body.extend("é".as_bytes());
267
    body.extend(vec![b'B'; 200]);
268
    let server = StubServer::always(502, "text/html", body);
269
    let origin = server.origin();
270
271
    // One per client module: tracker, box, memory, forum, and the fleet
272
    // routes. Each of these exited 101 before the fix.
273
    let commands: &[&[&str]] = &[
274
        &["issue", "list", "-R", "OpenAgentsInc/openagents"],
275
        &["project", "list", "-R", "OpenAgentsInc/openagents"],
276
        &["milestone", "list", "-R", "OpenAgentsInc/openagents"],
277
        &["issue", "view", "1", "-R", "OpenAgentsInc/openagents"],
278
        &["box", "list", "--conversation", "conv_stub"],
279
        &["memory", "list"],
280
        &["forum", "boards"],
281
        &["deploy", "list"],
282
    ];
283
284
    for command in commands {
285
        let run = oa(&origin, command);
286
        assert!(
287
            !run.panicked(),
288
            "oa {} panicked while reporting a refusal:\n{}",
289
            command.join(" "),
290
            run.stderr
291
        );
292
        assert_ne!(
293
            run.code(),
294
            101,
295
            "oa {} aborted rather than reported: {}",
296
            command.join(" "),
297
            run.stderr
298
        );
299
        assert_ne!(
300
            run.code(),
301
            0,
302
            "oa {} reported success on a 502",
303
            command.join(" ")
304
        );
305
        assert!(
306
            run.stdout.trim().is_empty(),
307
            "oa {} wrote data to stdout on a 502: {}",
308
            command.join(" "),
309
            run.stdout
310
        );
311
    }
312
}
313
314
/// A refused list is an error carrying the status, never an empty list.
315
///
316
/// The predecessor of this file asserted `listed.is_err()`. That passes with
317
/// no network at all. This names 404, so a transport failure fails the test.
318
#[tokio::test]
319
async fn a_refused_list_carries_the_status_and_yields_no_rows() {
320
    let server = StubServer::always(
321
        404,
322
        "application/json",
323
        br#"{"message":"Not Found"}"#.to_vec(),
324
    );
325
    let tracker = client(&server);
326
327
    let listed = tracker
328
        .list_issues(&target(), &one_page())
329
        .await
330
        .expect_err("a 404 must not produce rows");
331
    assert_eq!(refused_status(&listed), 404);
332
    assert!(
333
        listed.to_string().contains("Not Found"),
334
        "the server's own message is what gets reported: {listed}"
335
    );
336
337
    let projects = tracker
338
        .list_projects(&target(), false)
339
        .await
340
        .expect_err("a 404 must not produce boards");
341
    assert_eq!(refused_status(&projects), 404);
342
}
343
344
/// The status is carried through distinctly, not flattened to "it failed".
345
///
346
/// `project list` returned `Ok(Vec::new())` on every non-2xx once, so a
347
/// permission problem and a missing repository and a working empty board were
348
/// the same output. Each status has to survive to the caller.
349
#[tokio::test]
350
async fn each_refusal_status_survives_to_the_caller() {
351
    for status in [400u16, 401, 403, 404, 409, 422, 500, 502] {
352
        let server = StubServer::always(
353
            status,
354
            "application/json",
355
            format!(r#"{{"message":"refused with {status}"}}"#).into_bytes(),
356
        );
357
        let error = client(&server)
358
            .list_issues(&target(), &one_page())
359
            .await
360
            .expect_err("a non-2xx must not produce rows");
361
        assert_eq!(
362
            refused_status(&error),
363
            status,
364
            "the client reported a different status than the server sent"
365
        );
366
        assert!(
367
            error
368
                .to_string()
369
                .contains(&format!("refused with {status}")),
370
            "the server's message was dropped: {error}"
371
        );
372
    }
373
}
374
375
/// The forum client refuses rather than substituting a board list.
376
///
377
/// `forum.rs` answered any non-2xx with a hardcoded `general`/`dev` pair. The
378
/// `dev` board does not exist on this forum, so the CLI printed two boards
379
/// that were never served to it. The assertion that caught nothing was
380
/// `assert!(!boards.is_empty())`; this asserts the refusal instead.
381
#[tokio::test]
382
async fn a_refused_board_list_is_an_error_not_a_substitute_list() {
383
    let server = StubServer::always(406, "application/json", br#"{"message":"nope"}"#.to_vec());
384
    let error = openagents_cli::forum::ForumClient::new(&server.api_base(), None)
385
        .list_boards()
386
        .await
387
        .expect_err("a 406 must not produce boards");
388
    match &error {
389
        ForumError::Refused { status, .. } => assert_eq!(*status, 406),
390
        other => panic!("expected a refusal carrying the status, got {other}"),
391
    }
392
    let rendered = error.to_string();
393
    assert!(
394
        !rendered.contains("dev") && !rendered.contains("General"),
395
        "a refusal must not name boards: {rendered}"
396
    );
397
}
398
399
// ---------------------------------------------------------------------------
400
// 2. The route asked for is the route the parity depends on
401
// ---------------------------------------------------------------------------
402
403
/// `project list` asks for `projectsV2`, which is the route that exists.
404
///
405
/// It asked for `/projects` and turned the 404 into `Ok(Vec::new())`, so it
406
/// printed nothing and exited 0 against a repository with four boards. Reading
407
/// the request the stub received is the only assertion that separates "asked
408
/// correctly" from "asked wrongly and hid the answer".
409
///
410
/// The TypeScript client builds the same path at
411
/// `packages/openagents-cli/src/project-client.ts:60`.
412
#[test]
413
fn project_list_asks_for_projects_v2() {
414
    let server = StubServer::always(200, "application/json", br#"{"projects":[]}"#.to_vec());
415
    let run = oa(
416
        &server.origin(),
417
        &["project", "list", "-R", "OpenAgentsInc/openagents"],
418
    );
419
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
420
421
    let routes: Vec<String> = server.hits().iter().map(Hit::route).collect();
422
    assert_eq!(
423
        routes,
424
        vec!["GET /api/v1/repos/OpenAgentsInc/openagents/projectsV2"],
425
        "project list asked for the wrong route"
426
    );
427
}
428
429
/// `forum boards` asks for `/api/v1/forum`.
430
///
431
/// It asked for `/api/v1/forum/boards`, which answers 406, and the fabricated
432
/// fallback hid that. The TypeScript client uses `/forum`
433
/// (`packages/openagents-cli/src/forum-client.ts:151`).
434
#[test]
435
fn forum_boards_asks_for_the_forum_route() {
436
    let server = StubServer::always(200, "application/json", br#"{"boards":[]}"#.to_vec());
437
    let run = oa(&server.origin(), &["forum", "boards"]);
438
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
439
440
    let routes: Vec<String> = server.hits().iter().map(Hit::route).collect();
441
    assert_eq!(routes, vec!["GET /api/v1/forum"]);
442
}
443
444
// ---------------------------------------------------------------------------
445
// 3. Fields, not shapes
446
// ---------------------------------------------------------------------------
447
448
/// The rendered row carries the fields the route returns.
449
///
450
/// Asserting that a listing "returns rows" passed against the two fabricated
451
/// trace sessions and the two fabricated forum boards. These assert the values
452
/// the server sent, so a client that renders its own defaults fails.
453
#[test]
454
fn a_listing_renders_the_fields_the_server_sent() {
455
    let body = br#"{"issues":[
456
        {"number":4242,"title":"a title only this server knows","state":"open",
457
         "user":{"login":"AtlantisPleb","id":14167547},
458
         "openagents":{"blocked":true,"progress":"in_progress"}}
459
    ],"total_count":1}"#;
460
    let server = StubServer::always(200, "application/json", body.to_vec());
461
    let run = oa(
462
        &server.origin(),
463
        &["issue", "list", "-R", "OpenAgentsInc/openagents"],
464
    );
465
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
466
    assert!(
467
        run.stdout.contains("#4242"),
468
        "the number the server sent is missing: {}",
469
        run.stdout
470
    );
471
    assert!(
472
        run.stdout.contains("a title only this server knows"),
473
        "the title the server sent is missing: {}",
474
        run.stdout
475
    );
476
    // `blocked` is rendered as a suffix by both CLIs. Recorded from the
477
    // TypeScript CLI at cd0c05d465:
478
    //   #105   open    Consolidate the coder TUI …  [blocked]
479
    assert!(
480
        run.stdout.contains("[blocked]"),
481
        "the blocked flag the server sent is missing: {}",
482
        run.stdout
483
    );
484
}
485
486
/// `--json` prints the server's body, not a re-rendering of it.
487
///
488
/// The tracker's contract is that `--json` prints exactly what the server
489
/// sent. A client that decoded into its own struct and re-encoded would drop
490
/// every field it did not model, which is how `forum search --json` lost
491
/// `board`, `url`, `pinned`, `tip_count`, and `tip_sats`.
492
#[test]
493
fn json_output_preserves_fields_the_client_does_not_model() {
494
    let body = br#"{"issues":[{"number":1,"title":"t","state":"open",
495
        "a_field_no_client_models":"survives"}],"total_count":1}"#;
496
    let server = StubServer::always(200, "application/json", body.to_vec());
497
    let run = oa(
498
        &server.origin(),
499
        &["--json", "issue", "list", "-R", "OpenAgentsInc/openagents"],
500
    );
501
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
502
    let parsed: serde_json::Value =
503
        serde_json::from_str(&run.stdout).expect("--json must print one JSON document");
504
    assert_eq!(
505
        parsed["issues"][0]["a_field_no_client_models"], "survives",
506
        "an unmodelled field was dropped: {}",
507
        run.stdout
508
    );
509
}
510
511
/// `--json` changes the output. A flag that is parsed and ignored does not.
512
///
513
/// `--json` was declared globally and read nowhere, so every command accepted
514
/// it and printed the same human text. Comparing the two runs is what catches
515
/// that; asserting the JSON run "produces output" does not.
516
///
517
/// Each command gets a body of its own shape. A shared body would leave the
518
/// commands that cannot read it printing nothing either way, and two empty
519
/// strings compare equal — the test would pass for the wrong reason on the
520
/// commands it was least able to check.
521
#[test]
522
fn the_json_flag_is_read_and_not_merely_accepted() {
523
    let cases: &[(&[&str], &[u8])] = &[
524
        (
525
            &["issue", "list", "-R", "OpenAgentsInc/openagents"],
526
            br#"{"issues":[{"number":7,"title":"an issue","state":"open"}],"total_count":1}"#,
527
        ),
528
        (
529
            &["project", "list", "-R", "OpenAgentsInc/openagents"],
530
            br#"{"projects":[{"number":3,"title":"a board","state":"open"}]}"#,
531
        ),
532
        (
533
            &["memory", "list"],
534
            br#"{"memories":[{"id":"m1","bucket":"user","body":"a memory",
535
                "created_at":"2026-08-26T00:00:00Z","source_ref":null,
536
                "superseded_by":null}]}"#,
537
        ),
538
        (
539
            &["forum", "boards"],
540
            br#"{"boards":[{"slug":"general","name":"General","topic_count":0}]}"#,
541
        ),
542
    ];
543
544
    for (command, body) in cases {
545
        let plain_server = StubServer::always(200, "application/json", body.to_vec());
546
        let plain = oa(&plain_server.origin(), command);
547
        assert_eq!(
548
            plain.code(),
549
            0,
550
            "oa {} failed against its own fixture: {}",
551
            command.join(" "),
552
            plain.stderr
553
        );
554
        assert!(
555
            !plain.stdout.trim().is_empty(),
556
            "oa {} printed nothing, so the comparison below would be two \
557
             empty strings and prove nothing",
558
            command.join(" ")
559
        );
560
561
        let json_server = StubServer::always(200, "application/json", body.to_vec());
562
        let mut with_flag = vec!["--json"];
563
        with_flag.extend(command.iter().copied());
564
        let json = oa(&json_server.origin(), &with_flag);
565
566
        assert_ne!(
567
            plain.stdout,
568
            json.stdout,
569
            "oa {} produced identical output with and without --json, \
570
             so the flag is accepted and ignored",
571
            command.join(" ")
572
        );
573
        serde_json::from_str::<serde_json::Value>(&json.stdout).unwrap_or_else(|error| {
574
            panic!(
575
                "oa --json {} did not print JSON ({error}): {}",
576
                command.join(" "),
577
                json.stdout
578
            )
579
        });
580
    }
581
}
582
583
/// `trace list` accepts `--json` and prints human text anyway.
584
///
585
/// Recorded from `openagents trace list --json` at cd0c05d465:
586
///
587
/// ```text
588
/// {"schema":"openagents.trace_list.v1","stores":[{"root":"…","kind":
589
///  "openagents_export","present":true,"matched":68,…}]}
590
/// ```
591
///
592
/// `oa trace list --json` prints the same table it prints without the flag.
593
/// `run_trace` takes a `json` parameter and reads it in one of its four arms.
594
/// The same holds for `trace show`, `trace redact`, `plugin search`,
595
/// `plugin inspect`, `plugin run`, `api`, `coder`, `delegate`, and `update`.
596
#[test]
597
#[ignore = "#88: oa trace list ignores --json and prints the human table. \
598
            Run with --ignored to see it; delete the attribute when the flag \
599
            is read."]
600
fn trace_list_honours_the_json_flag() {
601
    let server = StubServer::always(200, "application/json", b"{}".to_vec());
602
    let run = oa(&server.origin(), &["--json", "trace", "list"]);
603
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
604
    serde_json::from_str::<serde_json::Value>(&run.stdout).unwrap_or_else(|error| {
605
        panic!(
606
            "trace list --json did not print JSON ({error}): {}",
607
            run.stdout
608
        )
609
    });
610
}
611
612
// ---------------------------------------------------------------------------
613
// 4. The exit-code contract
614
// ---------------------------------------------------------------------------
615
616
/// A refusal never exits 0, and never aborts.
617
///
618
/// This is the property both CLIs already hold and the one a caller most needs.
619
/// The *value* they exit with is where they part company, which the next test
620
/// pins.
621
#[test]
622
fn no_refusal_exits_zero() {
623
    for status in [400u16, 401, 403, 404, 409, 422, 500] {
624
        let server = StubServer::always(
625
            status,
626
            "application/json",
627
            format!(r#"{{"message":"refused {status}"}}"#).into_bytes(),
628
        );
629
        let run = oa(
630
            &server.origin(),
631
            &["issue", "list", "-R", "OpenAgentsInc/openagents"],
632
        );
633
        assert_ne!(run.code(), 0, "HTTP {status} exited 0");
634
        assert_ne!(run.code(), 101, "HTTP {status} aborted: {}", run.stderr);
635
        assert!(
636
            run.stdout.trim().is_empty(),
637
            "HTTP {status} wrote data to stdout: {}",
638
            run.stdout
639
        );
640
    }
641
}
642
643
/// `oa` collapses every server refusal to exit 2. The TypeScript CLI does not.
644
///
645
/// This test records the divergence rather than blessing it. The TypeScript
646
/// ladder is deliberate and published in
647
/// `packages/openagents-cli/src/errors.ts:238-305`:
648
///
649
/// | condition          | openagents | oa |
650
/// | ------------------ | ---------: | -: |
651
/// | 400, 422           |          2 |  2 |
652
/// | 401, 403           |          3 |  2 |
653
/// | 404                |          4 |  2 |
654
/// | 409                |          5 |  2 |
655
/// | 5xx                |          6 |  2 |
656
///
657
/// Measured against production at cd0c05d465: `openagents deploy list` exits
658
/// 3 and `oa deploy list` exits 2; `openagents issue view 999999` exits 4 and
659
/// `oa` exits 2.
660
///
661
/// A caller cannot tell an expired token from a typo from a missing repository
662
/// from a server outage. When the ladder lands in `oa`, this test is the file
663
/// that has to change, which is the point: the collapse becomes a deliberate
664
/// edit rather than a silent default.
665
#[test]
666
fn every_server_refusal_currently_exits_two() {
667
    for status in [401u16, 403, 404, 409, 500, 502] {
668
        let server = StubServer::always(
669
            status,
670
            "application/json",
671
            format!(r#"{{"message":"refused {status}"}}"#).into_bytes(),
672
        );
673
        let run = oa(
674
            &server.origin(),
675
            &["issue", "list", "-R", "OpenAgentsInc/openagents"],
676
        );
677
        assert_eq!(
678
            run.code(),
679
            2,
680
            "HTTP {status} exited {}. If the #88 exit ladder has landed, \
681
             update this test to the new expectation rather than deleting it.",
682
            run.code()
683
        );
684
    }
685
}
686
687
/// A refusal writes to stderr and leaves stdout clean.
688
///
689
/// A `--json` consumer piping stdout must not receive prose. `oa` writes
690
/// `oa: …` to stderr on every failure, which is right; what it does not yet do
691
/// is write a JSON error object to stdout under `--json`, the way the
692
/// TypeScript CLI does. Recorded from `openagents box list --json` at
693
/// cd0c05d465:
694
///
695
/// ```text
696
/// {"code":"api_error","message":"This deployment does not report a
697
///  conversation for the account. …","exit_code":3}
698
/// ```
699
///
700
/// Until that lands, the contract this pins is the weaker one: stdout stays
701
/// empty, so a consumer sees a parse failure on empty input rather than prose
702
/// masquerading as data.
703
#[test]
704
fn a_refusal_keeps_prose_off_stdout() {
705
    let server = StubServer::always(
706
        403,
707
        "application/json",
708
        br#"{"message":"forbidden"}"#.to_vec(),
709
    );
710
    let run = oa(
711
        &server.origin(),
712
        &["--json", "issue", "list", "-R", "OpenAgentsInc/openagents"],
713
    );
714
    assert_ne!(run.code(), 0);
715
    assert!(
716
        run.stdout.trim().is_empty(),
717
        "prose reached stdout under --json: {}",
718
        run.stdout
719
    );
720
    assert!(
721
        run.stderr.contains("forbidden"),
722
        "the server's message never reached the user: {}",
723
        run.stderr
724
    );
725
}
726
727
// ---------------------------------------------------------------------------
728
// 5. Recorded parity with the TypeScript CLI
729
// ---------------------------------------------------------------------------
730
731
/// The two CLIs render a repository listing the same way.
732
///
733
/// Recorded from `openagents repo list` against production at cd0c05d465:
734
///
735
/// ```text
736
/// moneya/wardrobe
737
/// OpenAgentsInc/openagents
738
/// ```
739
///
740
/// `oa` appends `\t(branch: main)` to every row. That is a divergence in a
741
/// command whose output is routinely piped, and this test is what makes it
742
/// visible: it asserts the recorded TypeScript shape, so it fails until the
743
/// two agree.
744
#[test]
745
#[ignore = "#88: oa appends a branch column the TypeScript CLI does not. \
746
            Run with --ignored to see the divergence; delete the attribute \
747
            when the two renderings agree."]
748
fn repo_list_renders_the_same_row_as_the_typescript_cli() {
749
    let body = br#"{"repositories":[
750
        {"id":"1","name":"wardrobe","full_name":"moneya/wardrobe",
751
         "owner":{"id":1,"login":"moneya","type":"User"},
752
         "private":false,"visibility":"public","default_branch":"main",
753
         "lifecycle_state":"ready",
754
         "clone_url":"https://openagents.com/moneya/wardrobe.git",
755
         "html_url":"https://openagents.com/moneya/wardrobe",
756
         "permissions":{"admin":false,"push":false,"pull":true}}
757
    ]}"#;
758
    let server = StubServer::always(200, "application/json", body.to_vec());
759
    let run = oa(&server.origin(), &["repo", "list"]);
760
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
761
    assert_eq!(run.stdout, "moneya/wardrobe\n");
762
}
763
764
/// A refused `box list` reports the server's sentence, as the TypeScript does.
765
///
766
/// Recorded from `openagents box list` against production at cd0c05d465:
767
///
768
/// ```text
769
/// openagents: This deployment does not report a conversation for the
770
/// account. Pass --conversation <conversation_id> to name the conversation
771
/// to use.
772
/// ```
773
///
774
/// `oa` prefixes the route and status — `oa: The API refused the request to
775
/// resolve user conversation (HTTP 401): …` — which is more informative and
776
/// not a parity break. What both must do, and what this asserts, is carry the
777
/// server's own sentence rather than an empty list and exit 0. `box_client.rs`
778
/// returned `Ok(Vec::new())` here once.
779
#[test]
780
fn a_refused_box_list_reports_the_servers_sentence() {
781
    const SENTENCE: &str = "This deployment does not report a conversation for the account.";
782
    let server = StubServer::always(
783
        401,
784
        "application/json",
785
        format!(r#"{{"message":"{SENTENCE}"}}"#).into_bytes(),
786
    );
787
    let run = oa(&server.origin(), &["box", "list"]);
788
    assert_ne!(run.code(), 0, "a refused box list exited 0");
789
    assert!(
790
        run.stdout.trim().is_empty(),
791
        "a refused box list printed rows: {}",
792
        run.stdout
793
    );
794
    assert!(
795
        run.stderr.contains(SENTENCE),
796
        "the server's sentence never reached the user: {}",
797
        run.stderr
798
    );
799
}
800
801
/// A global flag after a positional argument is a flag, not data.
802
///
803
/// `oa memory add "text" --json` stored the memory as `text --json`, because
804
/// the positional is `trailing_var_arg`. It is the same defect as issue #109
805
/// (`box exec` swallowing a trailing `--conversation`), and it silently
806
/// corrupts what gets written. The TypeScript CLI reads the flag from either
807
/// position.
808
///
809
/// Measured against production at cd0c05d465: `oa memory add "parity audit
810
/// scratch RS (delete me)" --json` created a memory whose body ended
811
/// `(delete me) --json`.
812
#[test]
813
#[ignore = "#88: oa memory add absorbs a trailing --json into the memory body. \
814
            Run with --ignored to see it; delete the attribute when fixed."]
815
fn a_trailing_global_flag_is_not_stored_as_data() {
816
    let server = StubServer::always(
817
        201,
818
        "application/json",
819
        br#"{"memory":{"id":"m1","bucket":"user","body":"remember this"}}"#.to_vec(),
820
    );
821
    let run = oa(
822
        &server.origin(),
823
        &["memory", "add", "remember this", "--json"],
824
    );
825
    assert_eq!(run.code(), 0, "stderr: {}", run.stderr);
826
    serde_json::from_str::<serde_json::Value>(&run.stdout).unwrap_or_else(|error| {
827
        panic!(
828
            "the trailing --json was swallowed rather than read ({error}): {}",
829
            run.stdout
830
        )
831
    });
832
}

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