Port the Computer subsystem and the API passthrough to the Rust CLI

a578a4a36e1f · AtlantisPleb · · parent 50dfd9b0e6a9

Port the Computer subsystem and the API passthrough to the Rust CLI

Issue 79. `computer.rs` was 40 lines: a policy of three unconditional
`true`s with no roots, no allowlist, and no path semantics, and `status`
and `up` were printlns that claimed a daemon that did not exist. It now
carries the real subsystem — tiers, declared roots, the curated
per-binary allowlist, denied commands and protected paths, the local
journal, the bounded executor, the machine credential, the controller
client, and the outbound Phoenix channel — plus `pair`, `logout`, and
`journal`, which were absent.

The policy starts closed. The default tier reaches nothing and no root
is declared, so no working directory is reachable until the owner
declares one; `oa computer policy` now prints the same tier, roots, and
allowlist as the TypeScript CLI, line for line. `up` serves bounded
requests over an outbound connection, journals every decision including
refusals, and retries transport loss with bounded backoff before saying
the retries ran out. The journal never leaves the machine, and command
output and journal entries are both scrubbed of credentials.

Issue 81. `oa api` concatenated its `/api/v1` base with the path, so
`/api/v1/user` — the one example its own help text gave — became
`/api/v1/api/v1/user` and 404ed. Paths now resolve the way the
TypeScript CLI resolves them: without a leading slash under `/api/v1/`,
with one only under `/api/`, and a complete URL only on the configured
origin. A non-2xx prints the server's own error body and request id and
exits 2 instead of being replaced by a `{"status": N}` stub. The
command gains `-X`, `-f`, `-H`, and `--input` (file or `-`), so POST,
PATCH, and PUT can carry a body, and an unrecognised method is refused
rather than silently performed as a GET. Authentication comes from
`resolve_endpoint` and the origin-keyed `CredentialStore`, and the
older `oa api <METHOD> <PATH>` spelling keeps working.

Tests. The assertion that closed issue 81 was `res.is_object()`, which
the error stub also satisfied; it now names the field the route returns
and asserts that a refused route is an error. The new suite stands up a
real Phoenix-shaped socket and a real HTTP peer, and drives the built
binary against them, so the channel framing, policy, journal, executor,
backoff, header injection, and body input are all asserted against a
peer rather than against the client's own intent.

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 Cargo.lock
  • modified crates/openagents-cli/Cargo.toml
  • modified crates/openagents-cli/src/api_passthrough.rs
  • modified crates/openagents-cli/src/cli.rs
  • modified crates/openagents-cli/src/computer.rs
  • modified crates/openagents-cli/tests/cli_test.rs
  • added crates/openagents-cli/tests/computer_api_test.rs

Diff

7 files changed, +5198 -94

Cargo.lock modified +2

@@ -1728,6 +1728,7 @@ dependencies = [

1728 1728
 "regex",
1729 1729
 "reqwest 0.12.28",
1730 1730
 "ripemd",
1731
 "rustls",
1731 1732
 "serde",
1732 1733
 "serde_json",
1733 1734
 "sha2",

@@ -1736,6 +1737,7 @@ dependencies = [

1736 1737
 "tokio",
1737 1738
 "tracing",
1738 1739
 "tracing-subscriber",
1740
 "tungstenite",
1739 1741
 "unicode-segmentation",
1740 1742
 "unicode-width 0.2.0",
1741 1743
 "zeroize",
crates/openagents-cli/Cargo.toml modified +7

@@ -37,7 +37,14 @@ ripemd = "0.1"

37 37
bs58 = { version = "0.5", features = ["check"] }
38 38
regex = "1"
39 39
zeroize = "1"
40
# `oa computer up` speaks the Phoenix controller socket. The same pair the
41
# desktop audio transport already uses, so no new TLS stack enters the tree.
42
tungstenite = { version = "0.28", default-features = false, features = ["handshake", "rustls-tls-native-roots"] }
43
rustls = { version = "0.23", default-features = false, features = ["ring", "std", "tls12"] }
40 44
41 45
[dev-dependencies]
42 46
tempfile = "3"
43 47
bech32 = "0.11"
48
# The Computer channel tests stand up a real Phoenix-shaped socket server, so
49
# the client's framing, policy, journal, and backoff run against a live peer.
50
tungstenite = { version = "0.28", default-features = false, features = ["handshake"] }
crates/openagents-cli/src/api_passthrough.rs modified +583 -32

@@ -1,71 +1,622 @@

1
//! Generic authenticated API passthrough command (`oa api`)
2
//! Talking to real `/api/v1` routes with dynamic methods and arbitrary JSON payloads
1
//! Generic authenticated API passthrough command (`oa api`).
2
//!
3
//! The command exists to show the reader exactly what the server said. Anything
4
//! it prints that the server did not send is a bug, so there is no fallback
5
//! body, no placeholder status, and no "empty result" for a refused request: a
6
//! non-2xx prints the server's own error body on stderr and exits non-zero.
7
//!
8
//! Ported from `packages/openagents-cli/src/api-passthrough.ts`,
9
//! `api-contract.ts`, and `request-body-input.ts`.
3 10
4
use reqwest::header::{HeaderMap, HeaderValue, AUTHORIZATION, CONTENT_TYPE};
11
use clap::Args;
12
use reqwest::header::{HeaderMap, HeaderName, HeaderValue, ACCEPT, AUTHORIZATION, CONTENT_TYPE};
5 13
use serde::{Deserialize, Serialize};
14
use std::io::Read;
15
16
/// Every OpenAgents API route lives under this prefix. A passthrough path
17
/// without a leading slash resolves under it.
18
pub const API_BASE_PATH: &str = "/api/v1/";
19
20
/// The methods `oa api` accepts. The transport supports more, but a passthrough
21
/// that offers `HEAD`, `OPTIONS`, or `TRACE` promises behavior the API does not
22
/// have.
23
pub const PASSTHROUGH_METHODS: [&str; 5] = ["GET", "POST", "PATCH", "PUT", "DELETE"];
24
25
/// A request body larger than this is a mistake rather than an API call.
26
pub const MAXIMUM_REQUEST_BODY_BYTES: usize = 1_048_576;
27
28
/// The reference `--input` accepts for standard input.
29
pub const STANDARD_INPUT_REFERENCE: &str = "-";
30
31
#[derive(Args, Debug)]
32
pub struct ApiArgs {
33
    /// The API path, and — for the older `oa api GET <path>` spelling — an
34
    /// optional method ahead of it. A bare `oa api user` is a GET.
35
    #[arg(
36
        value_name = "PATH",
37
        help = "API path. A path without a leading slash resolves under /api/v1/, so \
38
                repos/OWNER/REPO/issues and /api/v1/repos/OWNER/REPO/issues name the same route"
39
    )]
40
    pub path: String,
41
42
    #[arg(
43
        value_name = "TRAILING_PATH",
44
        hide = true,
45
        help = "Compatibility with `oa api <METHOD> <PATH>`; the first argument must then be a method"
46
    )]
47
    pub trailing_path: Option<String>,
48
49
    #[arg(
50
        short = 'X',
51
        long,
52
        help = "Set the HTTP method (defaults to GET, or POST when a body is supplied)"
53
    )]
54
    pub method: Option<String>,
55
56
    #[arg(
57
        short = 'f',
58
        long,
59
        help = "Add a body field as key=value, repeatable; values are sent as JSON strings"
60
    )]
61
    pub field: Vec<String>,
62
63
    #[arg(
64
        short = 'H',
65
        long,
66
        help = "Add a request header as 'Name: value', repeatable"
67
    )]
68
    pub header: Vec<String>,
69
70
    #[arg(
71
        long,
72
        help = "Read the whole JSON body from a file, or from - for standard input"
73
    )]
74
    pub input: Option<String>,
75
}
6 76
7 77
#[derive(Debug, Clone, Serialize, Deserialize)]
8 78
pub struct ApiResponseEnvelope {
9 79
    pub status: u16,
10
    pub body: serde_json::Value,
80
    /// The parsed body, when the server sent JSON.
81
    pub body: Option<serde_json::Value>,
82
    /// The body exactly as it arrived. Kept so a non-JSON answer can still be
83
    /// shown verbatim instead of being replaced by a plausible stand-in.
84
    pub text: String,
85
    pub request_id: Option<String>,
86
}
87
88
impl ApiResponseEnvelope {
89
    pub fn successful(&self) -> bool {
90
        (200..300).contains(&self.status)
91
    }
92
}
93
94
/// The optional error envelope the API returns with a failed request.
95
#[derive(Debug, Default, Clone, PartialEq, Eq)]
96
pub struct ApiErrorDetails {
97
    pub message: Option<String>,
98
    pub code: Option<String>,
99
    pub request_id: Option<String>,
100
}
101
102
/// Read the error envelope. The caller supplies its own summary, because a
103
/// passthrough request and a typed request describe a failure differently.
104
pub fn api_error_details(body: Option<&serde_json::Value>) -> ApiErrorDetails {
105
    let Some(object) = body.and_then(|value| value.as_object()) else {
106
        return ApiErrorDetails::default();
107
    };
108
    let text = |key: &str| {
109
        object
110
            .get(key)
111
            .and_then(|value| value.as_str())
112
            .map(str::to_string)
113
    };
114
    ApiErrorDetails {
115
        message: text("message").or_else(|| text("error")),
116
        code: text("code"),
117
        request_id: text("request_id"),
118
    }
119
}
120
121
// ---------------------------------------------------------------------------
122
// path resolution
123
// ---------------------------------------------------------------------------
124
125
fn leaves_origin(candidate: &str, origin: &str) -> String {
126
    format!("the path {candidate} leaves the configured API origin {origin}")
127
}
128
129
fn looks_absolute(value: &str) -> bool {
130
    let Some(index) = value.find("://") else {
131
        return false;
132
    };
133
    let scheme = &value[..index];
134
    !scheme.is_empty()
135
        && scheme.starts_with(|c: char| c.is_ascii_alphabetic())
136
        && scheme
137
            .chars()
138
            .all(|c| c.is_ascii_alphanumeric() || c == '+' || c == '.' || c == '-')
139
}
140
141
fn origin_of(url: &reqwest::Url) -> String {
142
    url.origin().ascii_serialization()
143
}
144
145
fn path_and_query(url: &reqwest::Url) -> String {
146
    match url.query() {
147
        Some(query) => format!("{}?{}", url.path(), query),
148
        None => url.path().to_string(),
149
    }
11 150
}
12 151
152
/// Turn a caller-supplied path into an origin-relative request path.
153
///
154
/// A path without a leading slash resolves under `/api/v1/`, so
155
/// `repos/OWNER/REPO/issues` and `/api/v1/repos/OWNER/REPO/issues` name the same
156
/// route. An absolute path must stay under `/api/`, because this command talks
157
/// to the API rather than to the website. A complete URL is accepted only when
158
/// it matches the configured origin.
159
///
160
/// The version this replaces concatenated the `/api/v1` base with the path, so
161
/// the one form its own help text advertised — `/api/v1/user` — became
162
/// `/api/v1/api/v1/user` and 404ed.
163
pub fn resolve_api_path(origin: &str, path: &str) -> Result<String, String> {
164
    let value = path.trim();
165
    if value.is_empty() {
166
        return Err("the API path cannot be empty".to_string());
167
    }
168
    if value.starts_with("//") {
169
        return Err(leaves_origin(value, origin));
170
    }
171
172
    if looks_absolute(value) {
173
        let lower = value.to_ascii_lowercase();
174
        if !(lower.starts_with("http://") || lower.starts_with("https://")) {
175
            return Err(format!("the path {value} must use http or https"));
176
        }
177
        let url = reqwest::Url::parse(value).map_err(|_| format!("invalid API path: {value}"))?;
178
        if origin_of(&url) != origin {
179
            return Err(leaves_origin(value, origin));
180
        }
181
        return Ok(path_and_query(&url));
182
    }
183
184
    let base_text = if value.starts_with('/') {
185
        format!("{}/", origin.trim_end_matches('/'))
186
    } else {
187
        format!("{}{}", origin.trim_end_matches('/'), API_BASE_PATH)
188
    };
189
    let base =
190
        reqwest::Url::parse(&base_text).map_err(|_| format!("invalid API origin: {origin}"))?;
191
    let url = base
192
        .join(value)
193
        .map_err(|_| format!("invalid API path: {value}"))?;
194
    if origin_of(&url) != origin {
195
        return Err(leaves_origin(value, origin));
196
    }
197
198
    if value.starts_with('/') {
199
        if !url.path().starts_with("/api/") {
200
            return Err(format!(
201
                "an absolute path must start with /api/. Write {} to resolve it under {}",
202
                &value[1..],
203
                API_BASE_PATH
204
            ));
205
        }
206
    } else if !url.path().starts_with(API_BASE_PATH) {
207
        return Err(format!("the path {value} resolves outside {API_BASE_PATH}"));
208
    }
209
210
    Ok(path_and_query(&url))
211
}
212
213
// ---------------------------------------------------------------------------
214
// flag parsing
215
// ---------------------------------------------------------------------------
216
217
/// Collect repeated `--field key=value` flags into a JSON object. Every value is
218
/// sent as a JSON string; the command makes no guess about the type a route
219
/// wants, so `--input` carries numbers, booleans, arrays, and nested objects.
220
pub fn parse_request_fields(fields: &[String]) -> Result<serde_json::Value, String> {
221
    let mut map = serde_json::Map::new();
222
    for field in fields {
223
        let Some(separator) = field.find('=') else {
224
            return Err(format!("use --field key=value. The CLI received {field}"));
225
        };
226
        if separator == 0 {
227
            return Err(format!("use --field key=value. The CLI received {field}"));
228
        }
229
        let key = field[..separator].to_string();
230
        if map.contains_key(&key) {
231
            return Err(format!("--field {key} is set more than once"));
232
        }
233
        map.insert(
234
            key,
235
            serde_json::Value::String(field[separator + 1..].to_string()),
236
        );
237
    }
238
    Ok(serde_json::Value::Object(map))
239
}
240
241
fn valid_header_name(name: &str) -> bool {
242
    !name.is_empty()
243
        && name.chars().all(|c| {
244
            c.is_ascii_alphanumeric()
245
                || matches!(
246
                    c,
247
                    '!' | '#'
248
                        | '$'
249
                        | '%'
250
                        | '&'
251
                        | '\''
252
                        | '*'
253
                        | '+'
254
                        | '.'
255
                        | '^'
256
                        | '_'
257
                        | '`'
258
                        | '|'
259
                        | '~'
260
                        | '-'
261
                )
262
        })
263
}
264
265
/// Collect repeated `--header 'Name: value'` flags. Header names are compared
266
/// without case. The authorization header comes from the OpenAgents session, so
267
/// a caller cannot replace it.
268
pub fn parse_request_headers(headers: &[String]) -> Result<Vec<(String, String)>, String> {
269
    let mut collected: Vec<(String, String)> = Vec::new();
270
    for header in headers {
271
        let Some(separator) = header.find(':') else {
272
            return Err(format!(
273
                "use --header 'Name: value'. The CLI received {header}"
274
            ));
275
        };
276
        if separator == 0 {
277
            return Err(format!(
278
                "use --header 'Name: value'. The CLI received {header}"
279
            ));
280
        }
281
        let name = header[..separator].trim().to_string();
282
        if !valid_header_name(&name) {
283
            return Err(format!("invalid header name: {name}"));
284
        }
285
        let normalized = name.to_ascii_lowercase();
286
        if normalized == "authorization" {
287
            return Err(
288
                "the CLI sets the authorization header from your OpenAgents session. \
289
                 Remove --header authorization"
290
                    .to_string(),
291
            );
292
        }
293
        let value = header[separator + 1..].trim().to_string();
294
        collected.retain(|(existing, _)| existing != &normalized);
295
        collected.push((normalized, value));
296
    }
297
    Ok(collected)
298
}
299
300
/// Select the request method. An explicit `--method` always wins. Without one, a
301
/// request that carries a body is a `POST` and a request without one is a `GET`.
302
pub fn resolve_request_method(method: Option<&str>, has_body: bool) -> String {
303
    match method {
304
        Some(value) => value.to_string(),
305
        None if has_body => "POST".to_string(),
306
        None => "GET".to_string(),
307
    }
308
}
309
310
/// Admit only the five methods the passthrough offers. An unrecognised method is
311
/// refused; the version this replaces performed a GET instead, so `oa api POSTT`
312
/// silently read a route the caller meant to write to.
313
pub fn admitted_method(value: &str) -> Result<String, String> {
314
    let upper = value.trim().to_ascii_uppercase();
315
    if PASSTHROUGH_METHODS.contains(&upper.as_str()) {
316
        return Ok(upper);
317
    }
318
    Err(format!(
319
        "{} is not a supported method. Use {}",
320
        value.trim(),
321
        PASSTHROUGH_METHODS.join(", ")
322
    ))
323
}
324
325
pub fn decode_request_body(text: &str, source: &str) -> Result<serde_json::Value, String> {
326
    let trimmed = text.trim();
327
    if trimmed.is_empty() {
328
        return Err(format!("{source} contained no JSON body"));
329
    }
330
    serde_json::from_str(trimmed).map_err(|_| format!("{source} did not contain valid JSON"))
331
}
332
333
/// Read a whole request body from a file path, or from `-` for standard input.
334
pub fn read_request_body(reference: &str) -> Result<(String, String), String> {
335
    if reference == STANDARD_INPUT_REFERENCE {
336
        let mut buffer = Vec::new();
337
        std::io::stdin()
338
            .take((MAXIMUM_REQUEST_BODY_BYTES + 1) as u64)
339
            .read_to_end(&mut buffer)
340
            .map_err(|_| "the CLI could not read a request body from standard input".to_string())?;
341
        if buffer.len() > MAXIMUM_REQUEST_BODY_BYTES {
342
            return Err(format!(
343
                "the request body on standard input exceeds {MAXIMUM_REQUEST_BODY_BYTES} bytes"
344
            ));
345
        }
346
        let text = String::from_utf8(buffer)
347
            .map_err(|_| "standard input did not contain UTF-8 text".to_string())?;
348
        return Ok((text, "standard input".to_string()));
349
    }
350
    let metadata = std::fs::metadata(reference)
351
        .map_err(|_| format!("the CLI could not read the file {reference}"))?;
352
    if metadata.len() as usize > MAXIMUM_REQUEST_BODY_BYTES {
353
        return Err(format!(
354
            "the file {reference} exceeds {MAXIMUM_REQUEST_BODY_BYTES} bytes"
355
        ));
356
    }
357
    let text = std::fs::read_to_string(reference)
358
        .map_err(|_| format!("the CLI could not read the file {reference}"))?;
359
    Ok((text, format!("the file {reference}")))
360
}
361
362
// ---------------------------------------------------------------------------
363
// client
364
// ---------------------------------------------------------------------------
365
13 366
pub struct ApiPassthroughClient {
14
    pub api_base: String,
367
    /// The bare origin, such as `https://openagents.com`. A base carrying
368
    /// `/api/v1` would double-prefix every absolute path.
369
    pub origin: String,
15 370
    pub token: Option<String>,
16 371
    pub http: reqwest::Client,
17 372
}
18 373
19 374
impl ApiPassthroughClient {
375
    /// Accepts either a bare origin or a legacy `…/api/v1` base, and keeps the
376
    /// origin. Paths resolve against `/api/v1/` on their own.
20 377
    pub fn new(api_base: &str, token: Option<String>) -> Self {
378
        let trimmed = api_base.trim_end_matches('/');
379
        let origin = match reqwest::Url::parse(trimmed) {
380
            Ok(url) => origin_of(&url),
381
            Err(_) => trimmed.to_string(),
382
        };
21 383
        Self {
22
            api_base: api_base.trim_end_matches('/').to_string(),
384
            origin,
23 385
            token,
24 386
            http: reqwest::Client::new(),
25 387
        }
26 388
    }
27 389
28
    fn headers(&self) -> HeaderMap {
390
    fn headers(&self, extra: &[(String, String)]) -> Result<HeaderMap, String> {
29 391
        let mut map = HeaderMap::new();
392
        map.insert(ACCEPT, HeaderValue::from_static("application/json"));
30 393
        map.insert(CONTENT_TYPE, HeaderValue::from_static("application/json"));
31
        if let Some(tok) = &self.token {
32
            if let Ok(val) = HeaderValue::from_str(&format!("Bearer {}", tok)) {
33
                map.insert(AUTHORIZATION, val);
34
            }
394
        for (name, value) in extra {
395
            let header = HeaderName::from_bytes(name.as_bytes())
396
                .map_err(|_| format!("invalid header name: {name}"))?;
397
            let header_value = HeaderValue::from_str(value)
398
                .map_err(|_| format!("invalid value for header {name}"))?;
399
            map.insert(header, header_value);
400
        }
401
        if let Some(token) = &self.token {
402
            let value = HeaderValue::from_str(&format!("Bearer {token}"))
403
                .map_err(|_| "the stored token is not a valid header value".to_string())?;
404
            map.insert(AUTHORIZATION, value);
405
        }
406
        Ok(map)
407
    }
408
409
    /// Send one request and return what the server actually said.
410
    ///
411
    /// A transport failure is an error, not a status stub. A non-2xx is returned
412
    /// with its real body so the caller can print it; deciding what to do with a
413
    /// refusal belongs to the command, not the client.
414
    pub async fn send(
415
        &self,
416
        method: &str,
417
        path: &str,
418
        headers: &[(String, String)],
419
        body: Option<&serde_json::Value>,
420
    ) -> Result<ApiResponseEnvelope, String> {
421
        let method_name = admitted_method(method)?;
422
        let request_path = resolve_api_path(&self.origin, path)?;
423
        let url = format!("{}{}", self.origin.trim_end_matches('/'), request_path);
424
        let verb = reqwest::Method::from_bytes(method_name.as_bytes())
425
            .map_err(|_| format!("{method_name} is not a supported method"))?;
426
427
        let mut builder = self
428
            .http
429
            .request(verb, &url)
430
            .headers(self.headers(headers)?);
431
        if let Some(value) = body {
432
            builder = builder.json(value);
35 433
        }
36
        map
434
435
        let response = builder
436
            .send()
437
            .await
438
            .map_err(|error| format!("could not reach {url}: {}", transport_reason(&error)))?;
439
        let status = response.status().as_u16();
440
        let request_id = response
441
            .headers()
442
            .get("x-request-id")
443
            .and_then(|value| value.to_str().ok())
444
            .map(str::to_string);
445
        let text = response
446
            .text()
447
            .await
448
            .map_err(|error| format!("could not read the response from {url}: {error}"))?;
449
        let parsed = if text.trim().is_empty() {
450
            None
451
        } else {
452
            serde_json::from_str::<serde_json::Value>(&text).ok()
453
        };
454
        Ok(ApiResponseEnvelope {
455
            status,
456
            body: parsed,
457
            text,
458
            request_id,
459
        })
37 460
    }
38 461
462
    /// The older two-argument entry point, kept for callers inside the crate.
463
    /// It refuses an unknown method and a non-2xx rather than returning a stub.
39 464
    pub async fn execute_request(
40 465
        &self,
41 466
        method: &str,
42 467
        path: &str,
43 468
        body: Option<serde_json::Value>,
44 469
    ) -> Result<serde_json::Value, Box<dyn std::error::Error + Send + Sync>> {
45
        let clean_path = if path.starts_with('/') { path.to_string() } else { format!("/{}", path) };
46
        let url = format!("{}{}", self.api_base, clean_path);
47
48
        let method_upper = method.to_uppercase();
49
        let mut req_builder = match method_upper.as_str() {
50
            "POST" => self.http.post(&url),
51
            "PUT" => self.http.put(&url),
52
            "PATCH" => self.http.patch(&url),
53
            "DELETE" => self.http.delete(&url),
54
            _ => self.http.get(&url),
55
        };
470
        let response = self.send(method, path, &[], body.as_ref()).await?;
471
        if !response.successful() {
472
            let details = api_error_details(response.body.as_ref());
473
            let summary = format!(
474
                "the API returned HTTP {} for {} {}",
475
                response.status,
476
                method.to_ascii_uppercase(),
477
                path
478
            );
479
            return Err(match details.message {
480
                Some(message) => format!("{summary}. {message}").into(),
481
                None => summary.into(),
482
            });
483
        }
484
        match response.body {
485
            Some(value) => Ok(value),
486
            None => Err(format!(
487
                "the API answered {} with a body that is not JSON",
488
                response.status
489
            )
490
            .into()),
491
        }
492
    }
493
}
494
495
fn transport_reason(error: &reqwest::Error) -> String {
496
    if error.is_timeout() {
497
        return "the request timed out".to_string();
498
    }
499
    if error.is_connect() {
500
        return "the connection was refused".to_string();
501
    }
502
    error.to_string()
503
}
504
505
// ---------------------------------------------------------------------------
506
// command
507
// ---------------------------------------------------------------------------
508
509
fn pretty(value: &serde_json::Value) -> String {
510
    serde_json::to_string_pretty(value).unwrap_or_else(|_| "null".to_string())
511
}
512
513
pub async fn run(args: ApiArgs, endpoint: &crate::auth::Endpoint, _json: bool) {
514
    let fail = crate::cli::fail;
56 515
57
        req_builder = req_builder.headers(self.headers());
516
    // `oa api <METHOD> <PATH>` stays accepted, so the shape this command shipped
517
    // with keeps working. Two arguments where the first is not a method is a
518
    // typo, not a GET: `oa api POSTT /x` used to perform a GET of `/x`.
519
    let (path, method_from_position) = match args.trailing_path {
520
        Some(trailing) => match admitted_method(&args.path) {
521
            Ok(method) => (trailing, Some(method)),
522
            Err(reason) => fail(&reason),
523
        },
524
        None => (args.path, None),
525
    };
58 526
59
        if let Some(b) = body {
60
            req_builder = req_builder.json(&b);
527
    if !args.field.is_empty() && args.input.is_some() {
528
        fail("use either --field or --input, not both");
529
    }
530
531
    let headers = match parse_request_headers(&args.header) {
532
        Ok(headers) => headers,
533
        Err(reason) => fail(&reason),
534
    };
535
536
    let body = if let Some(reference) = args.input.as_deref() {
537
        let (text, source) = match read_request_body(reference) {
538
            Ok(value) => value,
539
            Err(reason) => fail(&reason),
540
        };
541
        match decode_request_body(&text, &source) {
542
            Ok(value) => Some(value),
543
            Err(reason) => fail(&reason),
544
        }
545
    } else if args.field.is_empty() {
546
        None
547
    } else {
548
        match parse_request_fields(&args.field) {
549
            Ok(value) => Some(value),
550
            Err(reason) => fail(&reason),
551
        }
552
    };
553
554
    let explicit = match (args.method.as_deref(), method_from_position) {
555
        (Some(flag), Some(positional)) => {
556
            let named = match admitted_method(flag) {
557
                Ok(value) => value,
558
                Err(reason) => fail(&reason),
559
            };
560
            if named != positional {
561
                fail(&format!(
562
                    "the method is given twice and does not agree: {positional} and {named}"
563
                ));
564
            }
565
            Some(named)
61 566
        }
567
        (Some(flag), None) => match admitted_method(flag) {
568
            Ok(value) => Some(value),
569
            Err(reason) => fail(&reason),
570
        },
571
        (None, positional) => positional,
572
    };
573
    let method = resolve_request_method(explicit.as_deref(), body.is_some());
574
575
    // The same credential path the rest of the CLI uses: one store, keyed by the
576
    // resolved origin. A store that cannot be read is refused rather than
577
    // reported as an anonymous request that then 401s somewhere else.
578
    let store = crate::auth::CredentialStore::for_origin(&endpoint.origin);
579
    let token = match store.find_token() {
580
        Ok(held) => held.map(|stored| stored.token.expose().to_string()),
581
        Err(error) => fail(&error.to_string()),
582
    };
62 583
63
        let resp = req_builder.send().await?;
64
        let status = resp.status();
65
        let json_body = resp.json::<serde_json::Value>().await.unwrap_or_else(|_| serde_json::json!({
66
            "status": status.as_u16(),
67
        }));
584
    let client = ApiPassthroughClient::new(&endpoint.origin, token);
585
    let request_path = match resolve_api_path(&endpoint.origin, &path) {
586
        Ok(value) => value,
587
        Err(reason) => fail(&reason),
588
    };
589
    let response = match client.send(&method, &path, &headers, body.as_ref()).await {
590
        Ok(response) => response,
591
        Err(reason) => fail(&reason),
592
    };
593
594
    if !response.successful() {
595
        match &response.body {
596
            Some(value) => eprintln!("{}", pretty(value)),
597
            None if !response.text.trim().is_empty() => eprintln!("{}", response.text.trim_end()),
598
            None => {}
599
        }
600
        let details = api_error_details(response.body.as_ref());
601
        let request_id = response.request_id.clone().or(details.request_id);
602
        if let Some(id) = &request_id {
603
            eprintln!("Request id: {id}");
604
        }
605
        let summary = format!(
606
            "the API returned HTTP {} for {method} {request_path}",
607
            response.status
608
        );
609
        match details.message {
610
            Some(message) => fail(&format!("{summary}. {message}")),
611
            None => fail(&summary),
612
        }
613
    }
68 614
69
        Ok(json_body)
615
    match &response.body {
616
        Some(value) => println!("{}", pretty(value)),
617
        // A 2xx that is not JSON is shown exactly as it arrived. Replacing it
618
        // with `{}` or `null` would be the CLI inventing a body.
619
        None if response.text.is_empty() => {}
620
        None => println!("{}", response.text.trim_end()),
70 621
    }
71 622
}
crates/openagents-cli/src/cli.rs modified +4 -38

@@ -71,13 +71,13 @@ pub enum Commands {

71 71
    /// Box sandbox management and fanout execution
72 72
    Box(BoxArgs),
73 73
    /// Computer agent daemon and local policy probe
74
    Computer(ComputerArgs),
74
    Computer(crate::computer::ComputerArgs),
75 75
    /// Forum boards and topics
76 76
    Forum(ForumArgs),
77 77
    /// Account-level system memory and knowledge management
78 78
    Memory(MemoryArgs),
79 79
    /// Generic API route invocation
80
    Api(ApiArgs),
80
    Api(crate::api_passthrough::ApiArgs),
81 81
    /// Trace inspection and session export
82 82
    Trace(TraceArgs),
83 83
    /// Replace this binary with the release the channel names

@@ -867,20 +867,6 @@ pub enum BoxRunAction {

867 867
    },
868 868
}
869 869
870
#[derive(Args, Debug)]
871
pub struct ComputerArgs {
872
    #[command(subcommand)]
873
    pub action: ComputerAction,
874
}
875
876
#[derive(Subcommand, Debug)]
877
pub enum ComputerAction {
878
    Probe,
879
    Policy,
880
    Status,
881
    Up,
882
}
883
884 870
#[derive(Args, Debug)]
885 871
pub struct ForumArgs {
886 872
    #[command(subcommand)]

@@ -941,14 +927,6 @@ pub enum MemoryAction {

941 927
    },
942 928
}
943 929
944
#[derive(Args, Debug)]
945
pub struct ApiArgs {
946
    #[arg(help = "HTTP method (e.g. GET, POST, DELETE)", default_value = "GET")]
947
    pub method: String,
948
    #[arg(help = "API endpoint path (e.g. /api/v1/user)", default_value = "/")]
949
    pub path: String,
950
}
951
952 930
#[derive(Args, Debug)]
953 931
pub struct TraceArgs {
954 932
    #[command(subcommand)]

@@ -1076,15 +1054,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1076 1054
        Commands::Deploy(deploy) => run_deploy(deploy.action, &api_base, token, cli.json).await,
1077 1055
        Commands::Provider(provider) => run_provider(provider.action, cli.json),
1078 1056
        Commands::Box(b) => run_box(b.action, &api_base, token, cli.json).await,
1079
        Commands::Computer(comp) => match comp.action {
1080
            ComputerAction::Probe => {
1081
                let info = crate::computer::probe_host();
1082
                println!("Host OS: {} ({}), CPUs: {}, Memory: {}MB", info.os, info.arch, info.num_cpus, info.total_memory_mb);
1083
            }
1084
            ComputerAction::Policy => println!("Computer Policy: default allowlist active"),
1085
            ComputerAction::Status => println!("Computer agent: idle / online"),
1086
            ComputerAction::Up => println!("Computer agent daemon launched."),
1087
        },
1057
        Commands::Computer(comp) => crate::computer::run(comp, &endpoint, cli.json).await,
1088 1058
        Commands::Forum(forum) => {
1089 1059
            let client = crate::forum::ForumClient::new(&api_base, token);
1090 1060
            match forum.action {

@@ -1162,11 +1132,7 @@ pub async fn run(cli: Cli) -> Result<(), Box<dyn std::error::Error>> {

1162 1132
            }
1163 1133
        }
1164 1134
        Commands::Memory(mem) => run_memory(mem.action, &api_base, token, cli.json).await,
1165
        Commands::Api(api) => {
1166
            let client = crate::api_passthrough::ApiPassthroughClient::new(&api_base, token);
1167
            let res = client.execute_request(&api.method, &api.path, None).await.map_err(|e| e.to_string())?;
1168
            println!("{}", serde_json::to_string_pretty(&res)?);
1169
        }
1135
        Commands::Api(api) => crate::api_passthrough::run(api, &endpoint, cli.json).await,
1170 1136
        Commands::Trace(trace) => run_trace(trace.action),
1171 1137
        Commands::Update(update) => {
1172 1138
            crate::update::run(update.channel, update.version, update.check, update.force).await?;
crates/openagents-cli/src/computer.rs modified +3042 -19

@@ -1,7 +1,1477 @@

1
//! Computer agent daemon, environment probing, security policy engine, and execution journal
1
//! Computer agent daemon, environment probing, security policy engine, and
2
//! execution journal.
3
//!
4
//! Ported from `packages/openagents-cli/src/computer-policy.ts`,
5
//! `computer-config.ts`, `computer-probe.ts`, `computer-journal.ts`,
6
//! `computer-executor.ts`, `computer-client.ts`, `computer-channel.ts`, and
7
//! `computer-up.ts`.
8
//!
9
//! The authority runs one way. This machine decides what may run on it; the
10
//! server can ask, and every answer is a decision this file made against a
11
//! local configuration file the owner controls. The policy therefore starts
12
//! closed — the default tier reaches nothing and the default root set is empty,
13
//! so no working directory is reachable — and widens only where the owner
14
//! declares it. The version this replaces held three unconditional `true`s.
2 15
16
use clap::{Args, Subcommand};
3 17
use serde::{Deserialize, Serialize};
18
use std::collections::BTreeMap;
19
use std::io::{Read, Write};
20
use std::path::{Component, Path, PathBuf};
21
use std::process::{Command, Stdio};
22
use std::sync::mpsc::{Receiver, Sender};
23
use std::sync::{Arc, Mutex};
24
use std::time::{Duration, Instant, SystemTime, UNIX_EPOCH};
25
26
// ---------------------------------------------------------------------------
27
// tiers
28
// ---------------------------------------------------------------------------
29
30
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
31
#[serde(rename_all = "lowercase")]
32
pub enum Tier {
33
    Probe,
34
    Curated,
35
    Shell,
36
}
37
38
impl Tier {
39
    pub fn rank(self) -> u8 {
40
        match self {
41
            Tier::Probe => 0,
42
            Tier::Curated => 1,
43
            Tier::Shell => 2,
44
        }
45
    }
46
47
    pub fn label(self) -> &'static str {
48
        match self {
49
            Tier::Probe => "probe",
50
            Tier::Curated => "curated",
51
            Tier::Shell => "shell",
52
        }
53
    }
54
55
    pub fn parse(value: &str) -> Option<Tier> {
56
        match value {
57
            "probe" => Some(Tier::Probe),
58
            "curated" => Some(Tier::Curated),
59
            "shell" => Some(Tier::Shell),
60
            _ => None,
61
        }
62
    }
63
}
64
65
/// A request at `requested` is inside a ceiling of `ceiling`.
66
pub fn tier_allows(ceiling: Tier, requested: Tier) -> bool {
67
    requested.rank() <= ceiling.rank()
68
}
69
70
// ---------------------------------------------------------------------------
71
// paths and configuration
72
// ---------------------------------------------------------------------------
73
74
#[derive(Debug, Clone, PartialEq, Eq)]
75
pub struct ComputerPaths {
76
    pub config: PathBuf,
77
    pub journal: PathBuf,
78
}
79
80
impl ComputerPaths {
81
    /// The same two files the TypeScript CLI reads, so both binaries see one
82
    /// machine's policy and one machine's audit trail.
83
    pub fn in_directory(directory: &Path) -> Self {
84
        Self {
85
            config: directory.join("computer.json"),
86
            journal: directory.join("journal.ndjson"),
87
        }
88
    }
89
90
    pub fn default_paths() -> Self {
91
        Self::in_directory(&crate::auth::config_directory())
92
    }
93
}
94
95
#[derive(Debug, Clone)]
96
pub struct PolicyConfig {
97
    pub tier: Tier,
98
    pub roots: Vec<PathBuf>,
99
    pub pre_approved: Vec<String>,
100
    pub curated_execute: Vec<String>,
101
    pub paths: ComputerPaths,
102
}
103
104
impl PolicyConfig {
105
    /// Closed. No tier above `probe`, and no root, so nothing is reachable
106
    /// until the owner declares something.
107
    pub fn closed(paths: ComputerPaths) -> Self {
108
        Self {
109
            tier: Tier::Probe,
110
            roots: Vec::new(),
111
            pre_approved: Vec::new(),
112
            curated_execute: default_curated_execute(),
113
            paths,
114
        }
115
    }
116
}
117
118
pub fn default_curated_execute() -> Vec<String> {
119
    [
120
        "git", "gh", "ls", "cat", "head", "tail", "wc", "pwd", "which", "find", "rg", "grep",
121
        "sed", "node", "npm", "npx", "pnpm", "python3", "cargo", "go", "make", "mix",
122
    ]
123
    .iter()
124
    .map(|value| value.to_string())
125
    .collect()
126
}
127
128
#[derive(Debug, Default, Serialize, Deserialize)]
129
struct StoredConfiguration {
130
    #[serde(skip_serializing_if = "Option::is_none")]
131
    tier: Option<String>,
132
    #[serde(skip_serializing_if = "Option::is_none")]
133
    roots: Option<Vec<String>>,
134
    #[serde(skip_serializing_if = "Option::is_none")]
135
    pre_approved: Option<Vec<String>>,
136
    #[serde(skip_serializing_if = "Option::is_none")]
137
    curated_execute: Option<Vec<String>>,
138
    #[serde(skip_serializing_if = "Option::is_none")]
139
    registry_agents: Option<bool>,
140
    #[serde(skip_serializing_if = "Option::is_none")]
141
    agents: Option<serde_json::Value>,
142
}
143
144
const MAXIMUM_CONFIGURATION_BYTES: u64 = 16_384;
145
146
/// Read the local policy.
147
///
148
/// A missing file is the closed default, which is a real answer. A file that
149
/// cannot be read, is too large, or does not decode is an error: continuing
150
/// with the default would silently widen or narrow the owner's policy without
151
/// saying so.
152
pub fn load_config(paths: &ComputerPaths) -> Result<PolicyConfig, String> {
153
    let text = match std::fs::read_to_string(&paths.config) {
154
        Ok(text) => text,
155
        Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
156
            return Ok(PolicyConfig::closed(paths.clone()))
157
        }
158
        Err(error) => {
159
            return Err(format!(
160
                "the local Computer configuration at {} could not be read: {error}",
161
                paths.config.display()
162
            ))
163
        }
164
    };
165
    if text.len() as u64 > MAXIMUM_CONFIGURATION_BYTES {
166
        return Err(format!(
167
            "the local Computer configuration at {} is larger than {MAXIMUM_CONFIGURATION_BYTES} bytes",
168
            paths.config.display()
169
        ));
170
    }
171
    let stored: StoredConfiguration = serde_json::from_str(&text).map_err(|error| {
172
        format!(
173
            "the local Computer configuration at {} is not valid JSON: {error}",
174
            paths.config.display()
175
        )
176
    })?;
177
    let tier = match stored.tier.as_deref() {
178
        None => Tier::Probe,
179
        Some(value) => Tier::parse(value).ok_or_else(|| {
180
            format!(
181
                "the local Computer configuration names an unknown tier {value}. \
182
                 Use probe, curated, or shell"
183
            )
184
        })?,
185
    };
186
    let mut pre_approved: Vec<String> = Vec::new();
187
    for value in stored.pre_approved.unwrap_or_default() {
188
        if !pre_approved.contains(&value) {
189
            pre_approved.push(value);
190
        }
191
        if pre_approved.len() == 64 {
192
            break;
193
        }
194
    }
195
    let mut curated_execute: Vec<String> = Vec::new();
196
    for value in stored
197
        .curated_execute
198
        .unwrap_or_else(default_curated_execute)
199
    {
200
        if !value.is_empty() && !curated_execute.contains(&value) {
201
            curated_execute.push(value);
202
        }
203
        if curated_execute.len() == 64 {
204
            break;
205
        }
206
    }
207
    Ok(PolicyConfig {
208
        tier,
209
        roots: resolve_roots(&stored.roots.unwrap_or_default()),
210
        pre_approved,
211
        curated_execute,
212
        paths: paths.clone(),
213
    })
214
}
215
216
pub fn write_config(config: &PolicyConfig) -> Result<(), String> {
217
    let stored = StoredConfiguration {
218
        tier: Some(config.tier.label().to_string()),
219
        roots: Some(
220
            config
221
                .roots
222
                .iter()
223
                .map(|root| root.display().to_string())
224
                .collect(),
225
        ),
226
        pre_approved: Some(config.pre_approved.clone()),
227
        curated_execute: Some(config.curated_execute.clone()),
228
        registry_agents: Some(false),
229
        agents: Some(serde_json::json!({})),
230
    };
231
    let encoded = serde_json::to_string_pretty(&stored)
232
        .map_err(|error| format!("the Computer configuration could not be encoded: {error}"))?;
233
    write_private_file(&config.paths.config, &format!("{encoded}\n"))
234
}
235
236
fn write_private_file(path: &Path, contents: &str) -> Result<(), String> {
237
    if let Some(directory) = path.parent() {
238
        std::fs::create_dir_all(directory)
239
            .map_err(|error| format!("could not create {}: {error}", directory.display()))?;
240
        #[cfg(unix)]
241
        {
242
            use std::os::unix::fs::PermissionsExt;
243
            let _ = std::fs::set_permissions(directory, std::fs::Permissions::from_mode(0o700));
244
        }
245
    }
246
    std::fs::write(path, contents)
247
        .map_err(|error| format!("could not write {}: {error}", path.display()))?;
248
    #[cfg(unix)]
249
    {
250
        use std::os::unix::fs::PermissionsExt;
251
        std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o600))
252
            .map_err(|error| format!("could not secure {}: {error}", path.display()))?;
253
    }
254
    Ok(())
255
}
256
257
// ---------------------------------------------------------------------------
258
// path semantics
259
// ---------------------------------------------------------------------------
260
261
/// Resolve `.` and `..` without touching the filesystem, the way Node's
262
/// `path.normalize` does. A root that does not exist yet still has a meaning,
263
/// and a path argument must be judged before anything runs.
264
pub fn normalize_path(path: &Path) -> PathBuf {
265
    let mut parts: Vec<Component> = Vec::new();
266
    for component in path.components() {
267
        match component {
268
            Component::CurDir => {}
269
            Component::ParentDir => match parts.last() {
270
                Some(Component::Normal(_)) => {
271
                    parts.pop();
272
                }
273
                Some(Component::RootDir) | Some(Component::Prefix(_)) => {}
274
                _ => parts.push(component),
275
            },
276
            other => parts.push(other),
277
        }
278
    }
279
    parts.iter().collect()
280
}
281
282
fn absolutize(value: &Path, base: &Path) -> PathBuf {
283
    if value.is_absolute() {
284
        normalize_path(value)
285
    } else {
286
        normalize_path(&base.join(value))
287
    }
288
}
289
290
/// Expand a leading `~` and make the root absolute against the process
291
/// directory, then normalize it.
292
pub fn resolve_root(root: &str) -> PathBuf {
293
    let expanded = if root == "~" {
294
        crate::auth::home_directory()
295
    } else if let Some(rest) = root.strip_prefix("~/") {
296
        crate::auth::home_directory().join(rest)
297
    } else {
298
        PathBuf::from(root)
299
    };
300
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
301
    absolutize(&expanded, &cwd)
302
}
303
304
pub fn resolve_roots(roots: &[String]) -> Vec<PathBuf> {
305
    let mut seen: Vec<PathBuf> = Vec::new();
306
    for root in roots {
307
        let resolved = resolve_root(root);
308
        if !seen.contains(&resolved) {
309
            seen.push(resolved);
310
        }
311
    }
312
    seen
313
}
314
315
/// True when `candidate` is the root itself or lives under it.
316
pub fn within_root(candidate: &Path, root: &Path) -> bool {
317
    let cwd = std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."));
318
    let target = absolutize(candidate, &cwd);
319
    let base = absolutize(root, &cwd);
320
    target == base || target.starts_with(&base)
321
}
322
323
// ---------------------------------------------------------------------------
324
// the curated allowlist
325
// ---------------------------------------------------------------------------
326
327
/// The curated allowlist, in the order the policy prints it. Each entry is the
328
/// binary and the options it may carry; an empty option list means "no options,
329
/// and path arguments only inside a declared root".
330
pub fn curated_allowlist() -> Vec<(&'static str, Vec<&'static str>)> {
331
    vec![
332
        (
333
            "git",
334
            vec![
335
                "status",
336
                "log",
337
                "diff",
338
                "branch",
339
                "remote",
340
                "show",
341
                "rev-parse",
342
                "ls-files",
343
                "--version",
344
            ],
345
        ),
346
        ("uname", vec![]),
347
        ("date", vec![]),
348
        ("echo", vec![]),
349
        ("whoami", vec![]),
350
        ("df", vec![]),
351
        ("du", vec![]),
352
        ("ps", vec![]),
353
        ("uptime", vec![]),
354
        ("file", vec![]),
355
        ("stat", vec![]),
356
        ("ls", vec![]),
357
        ("cat", vec![]),
358
        ("head", vec![]),
359
        ("tail", vec![]),
360
        ("wc", vec![]),
361
        ("pwd", vec![]),
362
        ("which", vec![]),
363
        ("rg", vec![]),
364
        ("grep", vec![]),
365
        ("node", vec!["--version"]),
366
        ("npm", vec!["--version", "ls"]),
367
        ("pnpm", vec!["--version", "ls"]),
368
        ("python3", vec!["--version"]),
369
        ("cargo", vec!["--version"]),
370
        ("go", vec!["version"]),
371
        ("docker", vec!["ps", "images", "version"]),
372
    ]
373
}
374
375
/// The allowlist as the policy command prints it, one binary to a line, with
376
/// `gh` last. The TypeScript CLI prints exactly these strings.
377
pub fn format_allowlist() -> Vec<String> {
378
    let mut lines: Vec<String> = curated_allowlist()
379
        .into_iter()
380
        .map(|(name, options)| {
381
            if options.is_empty() {
382
                format!("{name}: no options; path arguments inside declared roots")
383
            } else {
384
                format!("{name}: {}", options.join(", "))
385
            }
386
        })
387
        .collect();
388
    lines.push("gh: read-only queries only".to_string());
389
    lines
390
}
391
392
const DENIED_COMMANDS: [&str; 22] = [
393
    "sudo",
394
    "doas",
395
    "su",
396
    "chmod",
397
    "chown",
398
    "mkfs",
399
    "dd",
400
    "shutdown",
401
    "reboot",
402
    "halt",
403
    "passwd",
404
    "ssh-keygen",
405
    "ssh-add",
406
    "keychain",
407
    "security",
408
    "gpg",
409
    "crontab",
410
    "systemctl",
411
    "launchctl",
412
    "nc",
413
    "ncat",
414
    "telnet",
415
];
416
417
const DENIED_PATH_FRAGMENTS: [&str; 12] = [
418
    ".ssh",
419
    ".aws",
420
    ".gnupg",
421
    ".kube",
422
    ".netrc",
423
    ".npmrc",
424
    ".pypirc",
425
    ".git-credentials",
426
    "id_rsa",
427
    "id_ed25519",
428
    ".env",
429
    "credentials.json",
430
];
431
432
const DENIED_PATH_FRAGMENT_KEYCHAINS: &str = "Keychains";
433
434
fn has_shell_metacharacter(value: &str) -> bool {
435
    value.chars().any(|c| {
436
        matches!(
437
            c,
438
            ';' | '&' | '|' | '`' | '$' | '>' | '<' | '\n' | '\r' | '\\'
439
        )
440
    })
441
}
442
443
fn command_name(value: &str) -> String {
444
    let last = value.rsplit(['/', '\\']).next().unwrap_or(value);
445
    let lowered = last.to_ascii_lowercase();
446
    lowered
447
        .strip_suffix(".exe")
448
        .map(str::to_string)
449
        .unwrap_or(lowered)
450
}
451
452
fn looks_like_path(value: &str) -> bool {
453
    Path::new(value).is_absolute()
454
        || value.starts_with('~')
455
        || value.contains("../")
456
        || value.contains("..\\")
457
        || value.starts_with("./")
458
        || value.starts_with(".\\")
459
}
460
461
// ---------------------------------------------------------------------------
462
// the decision
463
// ---------------------------------------------------------------------------
464
465
#[derive(Debug, Clone, PartialEq, Eq)]
466
pub struct CommandRequest {
467
    pub argv: Vec<String>,
468
    pub cwd: String,
469
}
470
471
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
472
pub enum RefusalReason {
473
    EmptyCommand,
474
    TierInsufficient,
475
    NotAllowlisted,
476
    RootNotDeclared,
477
    DeniedCommand,
478
    DeniedArgument,
479
    ShellMetacharacter,
480
    ConfirmationRequired,
481
}
482
483
impl RefusalReason {
484
    pub fn label(self) -> &'static str {
485
        match self {
486
            RefusalReason::EmptyCommand => "empty_command",
487
            RefusalReason::TierInsufficient => "tier_insufficient",
488
            RefusalReason::NotAllowlisted => "not_allowlisted",
489
            RefusalReason::RootNotDeclared => "root_not_declared",
490
            RefusalReason::DeniedCommand => "denied_command",
491
            RefusalReason::DeniedArgument => "denied_argument",
492
            RefusalReason::ShellMetacharacter => "shell_metacharacter",
493
            RefusalReason::ConfirmationRequired => "confirmation_required",
494
        }
495
    }
496
}
497
498
#[derive(Debug, Clone, PartialEq, Eq)]
499
pub enum Decision {
500
    Allowed {
501
        needs_confirmation: bool,
502
    },
503
    Refused {
504
        reason: RefusalReason,
505
        detail: String,
506
    },
507
}
508
509
impl Decision {
510
    pub fn allowed(&self) -> bool {
511
        matches!(self, Decision::Allowed { .. })
512
    }
513
}
514
515
fn refuse(reason: RefusalReason, detail: &str) -> Decision {
516
    Decision::Refused {
517
        reason,
518
        detail: detail.to_string(),
519
    }
520
}
521
522
const GH_READ_TOP_LEVEL: [&str; 4] = ["search", "status", "--version", "version"];
523
const GH_API_WRITE_FLAGS: [&str; 5] = ["-f", "-F", "--field", "--raw-field", "--input"];
524
525
fn gh_read_actions(resource: &str) -> Option<&'static [&'static str]> {
526
    Some(match resource {
527
        "issue" => &["list", "view", "status"],
528
        "pr" => &["list", "view", "status", "diff", "checks"],
529
        "release" => &["list", "view"],
530
        "run" => &["list", "view"],
531
        "workflow" => &["list", "view"],
532
        "repo" => &["list", "view"],
533
        "gist" => &["list", "view"],
534
        "cache" => &["list"],
535
        "label" => &["list"],
536
        "ruleset" => &["list", "view"],
537
        "auth" => &["status"],
538
        _ => return None,
539
    })
540
}
541
542
pub fn gh_read_only_allowed(args: &[String]) -> bool {
543
    let Some(resource) = args.first() else {
544
        return false;
545
    };
546
    if GH_READ_TOP_LEVEL.contains(&resource.as_str()) {
547
        return args.len() == 1;
548
    }
549
    if resource == "api" {
550
        return false;
551
    }
552
    let Some(action) = args.get(1) else {
553
        return false;
554
    };
555
    args.len() == 2
556
        && !args
557
            .iter()
558
            .any(|arg| GH_API_WRITE_FLAGS.contains(&arg.as_str()))
559
        && gh_read_actions(resource)
560
            .map(|actions| actions.contains(&action.as_str()))
561
            .unwrap_or(false)
562
}
563
564
fn no_arguments(args: &[String]) -> bool {
565
    args.is_empty()
566
}
567
568
fn non_option_arguments(args: &[String]) -> bool {
569
    !args.is_empty() && args.iter().all(|arg| arg == "--" || !arg.starts_with('-'))
570
}
571
572
fn bounded_words(args: &[String]) -> bool {
573
    args.len() <= 16
574
        && args
575
            .iter()
576
            .all(|arg| !arg.starts_with('-') && arg.len() <= 256)
577
}
578
579
fn git_arguments(args: &[String]) -> bool {
580
    let Some(subcommand) = args.first() else {
581
        return false;
582
    };
583
    let rest = &args[1..];
584
    if subcommand == "--version" {
585
        return rest.is_empty();
586
    }
587
    const READ_SUBCOMMANDS: [&str; 8] = [
588
        "status",
589
        "log",
590
        "diff",
591
        "branch",
592
        "remote",
593
        "show",
594
        "rev-parse",
595
        "ls-files",
596
    ];
597
    if !READ_SUBCOMMANDS.contains(&subcommand.as_str()) {
598
        return false;
599
    }
600
    const DENIED: [&str; 14] = [
601
        "--exec-path",
602
        "--ext-diff",
603
        "--no-ext-diff",
604
        "--textconv",
605
        "--no-textconv",
606
        "--delete",
607
        "-D",
608
        "-d",
609
        "--set-upstream",
610
        "--unset-upstream",
611
        "set-url",
612
        "set-head",
613
        "set-branches",
614
        "update-ref",
615
    ];
616
    if subcommand == "branch" {
617
        const READ_OPTIONS: [&str; 10] = [
618
            "-a",
619
            "-r",
620
            "--all",
621
            "--remotes",
622
            "--contains",
623
            "--merged",
624
            "--no-merged",
625
            "--list",
626
            "--verbose",
627
            "-v",
628
        ];
629
        return rest.iter().all(|arg| {
630
            READ_OPTIONS.contains(&arg.as_str()) || (arg.starts_with("--sort=") && arg.len() <= 128)
631
        });
632
    }
633
    if subcommand == "remote" {
634
        return rest.iter().all(|arg| arg == "-v" || arg == "--verbose");
635
    }
636
    rest.iter().all(|arg| {
637
        !DENIED.contains(&arg.as_str())
638
            && !arg.starts_with("--upload-pack=")
639
            && !arg.starts_with("--output")
640
    })
641
}
642
643
fn curated_argument_rule(name: &str, args: &[String]) -> Option<bool> {
644
    Some(match name {
645
        "git" => git_arguments(args),
646
        "uname" | "date" | "whoami" | "df" | "ps" | "uptime" | "pwd" => no_arguments(args),
647
        "echo" | "which" => bounded_words(args),
648
        "du" | "file" | "stat" | "ls" | "cat" | "head" | "tail" | "wc" | "rg" | "grep" => {
649
            non_option_arguments(args)
650
        }
651
        "node" => args.len() == 1 && args[0] == "--version",
652
        "npm" | "pnpm" => args.len() == 1 && (args[0] == "--version" || args[0] == "ls"),
653
        "python3" | "cargo" => args.len() == 1 && args[0] == "--version",
654
        "go" => args.len() == 1 && args[0] == "version",
655
        "docker" => {
656
            args.len() == 1 && (args[0] == "ps" || args[0] == "images" || args[0] == "version")
657
        }
658
        _ => return None,
659
    })
660
}
661
662
/// Decide one command against this machine's policy.
663
///
664
/// Every refusal names why. The order matters: a denied binary or a protected
665
/// path is refused before the tier is consulted, so raising the tier never
666
/// unlocks `sudo` or a read of `~/.ssh`.
667
pub fn decide(request: &CommandRequest, config: &PolicyConfig) -> Decision {
668
    let Some(argv0) = request.argv.first() else {
669
        return refuse(RefusalReason::EmptyCommand, "no command was supplied");
670
    };
671
    if argv0.trim().is_empty() {
672
        return refuse(RefusalReason::EmptyCommand, "no command was supplied");
673
    }
674
    if request
675
        .argv
676
        .iter()
677
        .any(|part| has_shell_metacharacter(part))
678
    {
679
        return refuse(
680
            RefusalReason::ShellMetacharacter,
681
            "command arguments cannot contain shell metacharacters",
682
        );
683
    }
684
    let name = command_name(argv0);
685
    if DENIED_COMMANDS.contains(&name.as_str()) {
686
        return refuse(
687
            RefusalReason::DeniedCommand,
688
            &format!("{name} is denied on this machine"),
689
        );
690
    }
691
    for argument in &request.argv {
692
        let fragment = DENIED_PATH_FRAGMENTS
693
            .iter()
694
            .chain(std::iter::once(&DENIED_PATH_FRAGMENT_KEYCHAINS))
695
            .find(|candidate| argument.contains(**candidate));
696
        if let Some(fragment) = fragment {
697
            return refuse(
698
                RefusalReason::DeniedArgument,
699
                &format!("argument references a protected path: {fragment}"),
700
            );
701
        }
702
    }
703
    let cwd = PathBuf::from(&request.cwd);
704
    if config.roots.is_empty() || !config.roots.iter().any(|root| within_root(&cwd, root)) {
705
        return refuse(
706
            RefusalReason::RootNotDeclared,
707
            "the working directory is outside every declared root",
708
        );
709
    }
710
    let rest = &request.argv[1..];
711
    let escapes = rest.iter().any(|argument| {
712
        looks_like_path(argument)
713
            && !config.roots.iter().any(|root| {
714
                let candidate = if argument.starts_with('~') {
715
                    resolve_root(argument)
716
                } else {
717
                    absolutize(Path::new(argument), &cwd)
718
                };
719
                within_root(&candidate, root)
720
            })
721
    });
722
    if escapes {
723
        return refuse(
724
            RefusalReason::DeniedArgument,
725
            "a path argument is outside every declared root",
726
        );
727
    }
728
    if !tier_allows(config.tier, Tier::Curated) {
729
        return refuse(
730
            RefusalReason::TierInsufficient,
731
            "probe tier permits fixed discovery only",
732
        );
733
    }
734
    if !tier_allows(config.tier, Tier::Shell) {
735
        if name == "gh" {
736
            return if gh_read_only_allowed(rest) {
737
                Decision::Allowed {
738
                    needs_confirmation: false,
739
                }
740
            } else {
741
                refuse(
742
                    RefusalReason::NotAllowlisted,
743
                    "this gh command is not a permitted read-only operation",
744
                )
745
            };
746
        }
747
        let permitted = curated_allowlist()
748
            .into_iter()
749
            .find(|(candidate, _)| *candidate == name);
750
        let Some(rule) = curated_argument_rule(&name, rest) else {
751
            return refuse(
752
                RefusalReason::NotAllowlisted,
753
                &format!("{name} is not in the curated allowlist"),
754
            );
755
        };
756
        if permitted.is_none() {
757
            return refuse(
758
                RefusalReason::NotAllowlisted,
759
                &format!("{name} is not in the curated allowlist"),
760
            );
761
        }
762
        if !rule {
763
            let first = rest.first().map(String::as_str).unwrap_or("");
764
            return refuse(
765
                RefusalReason::NotAllowlisted,
766
                format!("{name} {first} is not in the curated allowlist").trim(),
767
            );
768
        }
769
        return Decision::Allowed {
770
            needs_confirmation: false,
771
        };
772
    }
773
    Decision::Allowed {
774
        needs_confirmation: !config.pre_approved.contains(&name),
775
    }
776
}
777
778
// ---------------------------------------------------------------------------
779
// redaction
780
// ---------------------------------------------------------------------------
781
782
fn redaction_patterns() -> &'static [regex::Regex] {
783
    use std::sync::OnceLock;
784
    static PATTERNS: OnceLock<Vec<regex::Regex>> = OnceLock::new();
785
    PATTERNS.get_or_init(|| {
786
        vec![
787
            regex::Regex::new(r"oa_(?:pat|agent|assignment)_[A-Za-z0-9._-]+").unwrap(),
788
            regex::Regex::new(r"smct_[A-Za-z0-9._-]+").unwrap(),
789
        ]
790
    })
791
}
792
793
fn bearer_pattern() -> &'static regex::Regex {
794
    use std::sync::OnceLock;
795
    static PATTERN: OnceLock<regex::Regex> = OnceLock::new();
796
    PATTERN.get_or_init(|| regex::Regex::new(r"(?i)Bearer\s+\S+").unwrap())
797
}
798
799
/// Remove anything that looks like a credential. The journal and the streamed
800
/// output of an allowed command both pass through here, so a token that lands
801
/// in a command's output never reaches the file or the wire.
802
pub fn redact(value: &str) -> String {
803
    let mut text = value.to_string();
804
    for pattern in redaction_patterns() {
805
        text = pattern.replace_all(&text, "[REDACTED]").into_owned();
806
    }
807
    bearer_pattern()
808
        .replace_all(&text, "Bearer [REDACTED]")
809
        .into_owned()
810
}
811
812
fn bounded(value: &str, limit: usize) -> String {
813
    let redacted = redact(value);
814
    if redacted.len() <= limit {
815
        return redacted;
816
    }
817
    let mut end = limit;
818
    while end > 0 && !redacted.is_char_boundary(end) {
819
        end -= 1;
820
    }
821
    redacted[..end].to_string()
822
}
823
824
// ---------------------------------------------------------------------------
825
// journal
826
// ---------------------------------------------------------------------------
827
828
pub const JOURNAL_MAX_BYTES: usize = 1_048_576;
829
pub const JOURNAL_READ_TAIL_BYTES: usize = 262_144;
830
831
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq)]
832
pub struct JournalEntry {
833
    pub at: String,
834
    #[serde(rename = "requestId")]
835
    pub request_id: String,
836
    pub argv: Vec<String>,
837
    pub cwd: String,
838
    pub decision: String,
839
    pub outcome: String,
840
    pub detail: String,
841
}
842
843
/// The local record of what was asked and what this machine decided.
844
///
845
/// It never leaves the machine. `computer up` writes it, `computer journal`
846
/// reads it, and nothing sends it to the server.
847
#[derive(Debug, Clone)]
848
pub struct Journal {
849
    path: PathBuf,
850
}
851
852
fn now_iso8601() -> String {
853
    let now = SystemTime::now()
854
        .duration_since(UNIX_EPOCH)
855
        .unwrap_or_default();
856
    let seconds = now.as_secs() as i64;
857
    let millis = now.subsec_millis();
858
    let days = seconds.div_euclid(86_400);
859
    let time = seconds.rem_euclid(86_400);
860
    // Civil-from-days, Howard Hinnant's algorithm.
861
    let z = days + 719_468;
862
    let era = z.div_euclid(146_097);
863
    let doe = z.rem_euclid(146_097);
864
    let yoe = (doe - doe / 1_460 + doe / 36_524 - doe / 146_096) / 365;
865
    let y = yoe + era * 400;
866
    let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
867
    let mp = (5 * doy + 2) / 153;
868
    let d = doy - (153 * mp + 2) / 5 + 1;
869
    let m = if mp < 10 { mp + 3 } else { mp - 9 };
870
    let year = if m <= 2 { y + 1 } else { y };
871
    format!(
872
        "{year:04}-{m:02}-{d:02}T{:02}:{:02}:{:02}.{millis:03}Z",
873
        time / 3_600,
874
        (time % 3_600) / 60,
875
        time % 60
876
    )
877
}
878
879
impl Journal {
880
    pub fn at(path: PathBuf) -> Self {
881
        Self { path }
882
    }
883
884
    pub fn path(&self) -> &Path {
885
        &self.path
886
    }
887
888
    /// Append one bounded, redacted entry, trimming the file from the front
889
    /// once it passes the retention limit.
890
    pub fn append(
891
        &self,
892
        request_id: &str,
893
        request: &CommandRequest,
894
        decision: &str,
895
        outcome: &str,
896
        detail: &str,
897
    ) -> Result<(), String> {
898
        let entry = JournalEntry {
899
            at: now_iso8601(),
900
            request_id: bounded(request_id, 64),
901
            argv: request
902
                .argv
903
                .iter()
904
                .take(8)
905
                .map(|value| bounded(value, 128))
906
                .collect(),
907
            cwd: bounded(&request.cwd, 1_024),
908
            decision: bounded(decision, 64),
909
            outcome: bounded(outcome, 64),
910
            detail: bounded(detail, 512),
911
        };
912
        let line = serde_json::to_string(&entry)
913
            .map_err(|error| format!("the Computer journal entry could not be encoded: {error}"))?;
914
        let line = format!("{line}\n");
915
916
        if let Some(directory) = self.path.parent() {
917
            std::fs::create_dir_all(directory)
918
                .map_err(|error| format!("could not create {}: {error}", directory.display()))?;
919
        }
920
        let existing = match std::fs::read(&self.path) {
921
            Ok(bytes) => bytes,
922
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => Vec::new(),
923
            Err(error) => {
924
                return Err(format!(
925
                    "the local Computer journal at {} could not be read: {error}",
926
                    self.path.display()
927
                ))
928
            }
929
        };
930
        let retained_limit = JOURNAL_MAX_BYTES.saturating_sub(line.len());
931
        let retained: Vec<u8> = if existing.len() <= retained_limit {
932
            existing
933
        } else {
934
            let tail = &existing[existing.len() - retained_limit..];
935
            match tail.iter().position(|byte| *byte == b'\n') {
936
                Some(index) => tail[index + 1..].to_vec(),
937
                None => Vec::new(),
938
            }
939
        };
940
        let mut contents = retained;
941
        contents.extend_from_slice(line.as_bytes());
942
        if let Some(directory) = self.path.parent() {
943
            let _ = std::fs::create_dir_all(directory);
944
        }
945
        std::fs::write(&self.path, &contents).map_err(|error| {
946
            format!(
947
                "the local Computer journal at {} could not be written: {error}",
948
                self.path.display()
949
            )
950
        })?;
951
        #[cfg(unix)]
952
        {
953
            use std::os::unix::fs::PermissionsExt;
954
            let _ = std::fs::set_permissions(&self.path, std::fs::Permissions::from_mode(0o600));
955
        }
956
        Ok(())
957
    }
958
959
    /// Read the last `limit` entries.
960
    ///
961
    /// A missing file is an empty journal, which is a real answer: nothing has
962
    /// been asked of this machine. A file that exists and cannot be read is an
963
    /// error rather than an empty list.
964
    pub fn read(&self, limit: usize) -> Result<Vec<JournalEntry>, String> {
965
        if limit == 0 {
966
            return Ok(Vec::new());
967
        }
968
        let bytes = match std::fs::read(&self.path) {
969
            Ok(bytes) => bytes,
970
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(Vec::new()),
971
            Err(error) => {
972
                return Err(format!(
973
                    "the local Computer journal at {} could not be read: {error}",
974
                    self.path.display()
975
                ))
976
            }
977
        };
978
        let tail = if bytes.len() > JOURNAL_READ_TAIL_BYTES {
979
            &bytes[bytes.len() - JOURNAL_READ_TAIL_BYTES..]
980
        } else {
981
            &bytes[..]
982
        };
983
        let text = String::from_utf8_lossy(tail);
984
        let lines: Vec<&str> = text
985
            .split('\n')
986
            .filter(|line| !line.trim().is_empty())
987
            .collect();
988
        let start = lines.len().saturating_sub(limit);
989
        Ok(lines[start..]
990
            .iter()
991
            .filter_map(|line| serde_json::from_str::<JournalEntry>(line).ok())
992
            .collect())
993
    }
994
}
995
996
// ---------------------------------------------------------------------------
997
// executor
998
// ---------------------------------------------------------------------------
999
1000
#[derive(Debug, Clone, Copy)]
1001
pub struct ExecutionLimits {
1002
    pub timeout: Duration,
1003
    pub maximum_output_bytes: usize,
1004
}
1005
1006
impl Default for ExecutionLimits {
1007
    fn default() -> Self {
1008
        Self {
1009
            timeout: Duration::from_secs(30),
1010
            maximum_output_bytes: 64 * 1024,
1011
        }
1012
    }
1013
}
1014
1015
#[derive(Debug, Clone, PartialEq, Eq)]
1016
pub struct ExecutionOutcome {
1017
    pub exit_code: Option<i32>,
1018
    pub truncated: bool,
1019
    pub timed_out: bool,
1020
    pub cancelled: bool,
1021
    pub duration_ms: u64,
1022
}
1023
1024
const ENVIRONMENT_NAMES: [&str; 8] = [
1025
    "PATH", "HOME", "LANG", "LC_ALL", "TMPDIR", "SHELL", "USER", "TERM",
1026
];
1027
1028
/// The only environment an allowed command sees. A delegated command that
1029
/// inherited the whole environment would inherit this process's credentials.
1030
pub fn scrubbed_environment() -> Vec<(String, String)> {
1031
    ENVIRONMENT_NAMES
1032
        .iter()
1033
        .filter_map(|name| {
1034
            std::env::var(name)
1035
                .ok()
1036
                .map(|value| (name.to_string(), value))
1037
        })
1038
        .collect()
1039
}
1040
1041
/// A handle a caller can use to stop a running command.
1042
#[derive(Clone, Default)]
1043
pub struct Cancellation {
1044
    inner: Arc<Mutex<Option<u32>>>,
1045
    cancelled: Arc<std::sync::atomic::AtomicBool>,
1046
}
1047
1048
impl Cancellation {
1049
    pub fn cancel(&self) {
1050
        self.cancelled
1051
            .store(true, std::sync::atomic::Ordering::SeqCst);
1052
        if let Some(pid) = *self.inner.lock().unwrap() {
1053
            terminate_group(pid);
1054
        }
1055
    }
1056
1057
    pub fn cancelled(&self) -> bool {
1058
        self.cancelled.load(std::sync::atomic::Ordering::SeqCst)
1059
    }
1060
}
1061
1062
#[cfg(unix)]
1063
fn terminate_group(pid: u32) {
1064
    unsafe {
1065
        // The child was started in its own process group, so this reaches the
1066
        // whole tree rather than only the shell-less leader.
1067
        if libc::kill(-(pid as i32), libc::SIGTERM) != 0 {
1068
            libc::kill(pid as i32, libc::SIGTERM);
1069
        }
1070
    }
1071
}
1072
1073
#[cfg(not(unix))]
1074
fn terminate_group(_pid: u32) {}
1075
1076
#[cfg(unix)]
1077
fn kill_group(pid: u32) {
1078
    unsafe {
1079
        if libc::kill(-(pid as i32), libc::SIGKILL) != 0 {
1080
            libc::kill(pid as i32, libc::SIGKILL);
1081
        }
1082
    }
1083
}
1084
1085
#[cfg(not(unix))]
1086
fn kill_group(_pid: u32) {}
1087
1088
/// Run one already-allowed command with bounded output and a bounded lifetime.
1089
///
1090
/// Output is redacted as it streams, so a token printed by the command never
1091
/// leaves this machine, and it stops at the byte limit rather than growing
1092
/// without bound.
1093
pub fn execute_command(
1094
    argv: &[String],
1095
    cwd: &str,
1096
    limits: ExecutionLimits,
1097
    cancellation: &Cancellation,
1098
    mut on_chunk: impl FnMut(&str),
1099
) -> ExecutionOutcome {
1100
    let started = Instant::now();
1101
    let Some(program) = argv.first() else {
1102
        return ExecutionOutcome {
1103
            exit_code: Some(127),
1104
            truncated: false,
1105
            timed_out: false,
1106
            cancelled: false,
1107
            duration_ms: 0,
1108
        };
1109
    };
1110
    let mut command = Command::new(program);
1111
    command
1112
        .args(&argv[1..])
1113
        .current_dir(cwd)
1114
        .env_clear()
1115
        .envs(scrubbed_environment())
1116
        .stdin(Stdio::null())
1117
        .stdout(Stdio::piped())
1118
        .stderr(Stdio::piped());
1119
    #[cfg(unix)]
1120
    {
1121
        use std::os::unix::process::CommandExt;
1122
        command.process_group(0);
1123
    }
1124
    let mut child = match command.spawn() {
1125
        Ok(child) => child,
1126
        Err(_) => {
1127
            return ExecutionOutcome {
1128
                exit_code: Some(127),
1129
                truncated: false,
1130
                timed_out: false,
1131
                cancelled: false,
1132
                duration_ms: started.elapsed().as_millis() as u64,
1133
            }
1134
        }
1135
    };
1136
    let pid = child.id();
1137
    *cancellation.inner.lock().unwrap() = Some(pid);
1138
1139
    let (sender, receiver) = std::sync::mpsc::channel::<Vec<u8>>();
1140
    let mut readers = Vec::new();
1141
    if let Some(stdout) = child.stdout.take() {
1142
        readers.push(spawn_reader(stdout, sender.clone()));
1143
    }
1144
    if let Some(stderr) = child.stderr.take() {
1145
        readers.push(spawn_reader(stderr, sender.clone()));
1146
    }
1147
    drop(sender);
1148
1149
    let mut bytes = 0usize;
1150
    let mut truncated = false;
1151
    let mut timed_out = false;
1152
    let mut exit_code: Option<i32> = None;
1153
    let deadline = started + limits.timeout;
1154
1155
    loop {
1156
        while let Ok(chunk) = receiver.try_recv() {
1157
            let text = redact(&String::from_utf8_lossy(&chunk));
1158
            if bytes >= limits.maximum_output_bytes {
1159
                truncated = true;
1160
                continue;
1161
            }
1162
            let remaining = limits.maximum_output_bytes - bytes;
1163
            let mut end = remaining.min(text.len());
1164
            while end > 0 && !text.is_char_boundary(end) {
1165
                end -= 1;
1166
            }
1167
            if end < text.len() {
1168
                truncated = true;
1169
            }
1170
            bytes += end;
1171
            if end > 0 {
1172
                on_chunk(&text[..end]);
1173
            }
1174
        }
1175
        match child.try_wait() {
1176
            Ok(Some(status)) => {
1177
                exit_code = status.code();
1178
                break;
1179
            }
1180
            Ok(None) => {}
1181
            Err(_) => break,
1182
        }
1183
        if Instant::now() >= deadline && !timed_out {
1184
            timed_out = true;
1185
            terminate_group(pid);
1186
        }
1187
        if timed_out && Instant::now() >= deadline + Duration::from_secs(2) {
1188
            kill_group(pid);
1189
        }
1190
        std::thread::sleep(Duration::from_millis(20));
1191
    }
1192
1193
    for reader in readers {
1194
        let _ = reader.join();
1195
    }
1196
    while let Ok(chunk) = receiver.try_recv() {
1197
        let text = redact(&String::from_utf8_lossy(&chunk));
1198
        if bytes >= limits.maximum_output_bytes {
1199
            truncated = true;
1200
            continue;
1201
        }
1202
        let remaining = limits.maximum_output_bytes - bytes;
1203
        let mut end = remaining.min(text.len());
1204
        while end > 0 && !text.is_char_boundary(end) {
1205
            end -= 1;
1206
        }
1207
        if end < text.len() {
1208
            truncated = true;
1209
        }
1210
        bytes += end;
1211
        if end > 0 {
1212
            on_chunk(&text[..end]);
1213
        }
1214
    }
1215
1216
    ExecutionOutcome {
1217
        exit_code,
1218
        truncated,
1219
        timed_out,
1220
        cancelled: cancellation.cancelled(),
1221
        duration_ms: started.elapsed().as_millis() as u64,
1222
    }
1223
}
1224
1225
fn spawn_reader<R: Read + Send + 'static>(
1226
    mut source: R,
1227
    sender: Sender<Vec<u8>>,
1228
) -> std::thread::JoinHandle<()> {
1229
    std::thread::spawn(move || {
1230
        let mut buffer = [0u8; 8192];
1231
        loop {
1232
            match source.read(&mut buffer) {
1233
                Ok(0) | Err(_) => return,
1234
                Ok(count) => {
1235
                    if sender.send(buffer[..count].to_vec()).is_err() {
1236
                        return;
1237
                    }
1238
                }
1239
            }
1240
        }
1241
    })
1242
}
1243
1244
// ---------------------------------------------------------------------------
1245
// probe
1246
// ---------------------------------------------------------------------------
1247
1248
#[derive(Debug, Clone, Serialize, Deserialize)]
1249
pub struct ToolReport {
1250
    pub name: String,
1251
    pub present: bool,
1252
    pub path: String,
1253
    pub version: String,
1254
}
4 1255
1256
#[derive(Debug, Clone, Serialize, Deserialize)]
1257
pub struct HostReport {
1258
    pub platform: String,
1259
    pub release: String,
1260
    pub architecture: String,
1261
    pub hostname: String,
1262
    pub shell: String,
1263
    pub cpu_count: usize,
1264
    pub total_memory_bytes: u64,
1265
    pub uptime_seconds: u64,
1266
}
1267
1268
#[derive(Debug, Clone, Serialize, Deserialize)]
1269
pub struct WorktreeReport {
1270
    pub root: String,
1271
    pub exists: bool,
1272
    pub git: bool,
1273
}
1274
1275
#[derive(Debug, Clone, Serialize, Deserialize)]
1276
pub struct ProbeReport {
1277
    pub schema: String,
1278
    pub host: HostReport,
1279
    #[serde(rename = "codingAgents")]
1280
    pub coding_agents: Vec<ToolReport>,
1281
    pub toolchains: Vec<ToolReport>,
1282
    pub roots: Vec<String>,
1283
    pub worktrees: Vec<WorktreeReport>,
1284
}
1285
1286
pub const CODING_AGENT_CATALOG: [(&str, &str); 11] = [
1287
    ("claude", "--version"),
1288
    ("codex", "--version"),
1289
    ("devin", "--version"),
1290
    ("gemini", "--version"),
1291
    ("cursor-agent", "--version"),
1292
    ("aider", "--version"),
1293
    ("goose", "--version"),
1294
    ("opencode", "--version"),
1295
    ("amp", "--version"),
1296
    ("copilot", "--version"),
1297
    ("crush", "--version"),
1298
];
1299
1300
pub const TOOLCHAIN_CATALOG: [(&str, &str); 14] = [
1301
    ("git", "--version"),
1302
    ("gh", "--version"),
1303
    ("node", "--version"),
1304
    ("npm", "--version"),
1305
    ("pnpm", "--version"),
1306
    ("bun", "--version"),
1307
    ("deno", "--version"),
1308
    ("python3", "--version"),
1309
    ("uv", "--version"),
1310
    ("cargo", "--version"),
1311
    ("go", "version"),
1312
    ("elixir", "--version"),
1313
    ("docker", "--version"),
1314
    ("tmux", "-V"),
1315
];
1316
1317
fn run_quietly(argv: &[&str], cwd: &Path) -> String {
1318
    let Some(program) = argv.first() else {
1319
        return String::new();
1320
    };
1321
    let output = Command::new(program)
1322
        .args(&argv[1..])
1323
        .current_dir(cwd)
1324
        .stdin(Stdio::null())
1325
        .stderr(Stdio::null())
1326
        .output();
1327
    match output {
1328
        Ok(output) if output.status.success() => {
1329
            let mut text = String::from_utf8_lossy(&output.stdout).to_string();
1330
            if text.len() > 512 {
1331
                text.truncate(512);
1332
            }
1333
            text.trim().to_string()
1334
        }
1335
        _ => String::new(),
1336
    }
1337
}
1338
1339
fn probe_one(name: &str, version_argument: &str, cwd: &Path) -> ToolReport {
1340
    let resolver = if cfg!(windows) { "where" } else { "which" };
1341
    let resolved = run_quietly(&[resolver, name], cwd);
1342
    if resolved.is_empty() {
1343
        return ToolReport {
1344
            name: name.to_string(),
1345
            present: false,
1346
            path: String::new(),
1347
            version: String::new(),
1348
        };
1349
    }
1350
    let mut version = run_quietly(&[name, version_argument], cwd);
1351
    version.truncate(bounded_index(&version, 120));
1352
    ToolReport {
1353
        name: name.to_string(),
1354
        present: true,
1355
        path: resolved.lines().next().unwrap_or("").to_string(),
1356
        version,
1357
    }
1358
}
1359
1360
fn bounded_index(value: &str, limit: usize) -> usize {
1361
    if value.len() <= limit {
1362
        return value.len();
1363
    }
1364
    let mut end = limit;
1365
    while end > 0 && !value.is_char_boundary(end) {
1366
        end -= 1;
1367
    }
1368
    end
1369
}
1370
1371
fn probe_catalog(catalog: &[(&str, &str)], cwd: &Path) -> Vec<ToolReport> {
1372
    let mut reports: Vec<Option<ToolReport>> = vec![None; catalog.len()];
1373
    std::thread::scope(|scope| {
1374
        let mut handles = Vec::new();
1375
        for (index, (name, argument)) in catalog.iter().enumerate() {
1376
            handles.push((index, scope.spawn(move || probe_one(name, argument, cwd))));
1377
        }
1378
        for (index, handle) in handles {
1379
            if let Ok(report) = handle.join() {
1380
                reports[index] = Some(report);
1381
            }
1382
        }
1383
    });
1384
    reports
1385
        .into_iter()
1386
        .enumerate()
1387
        .map(|(index, report)| {
1388
            report.unwrap_or_else(|| ToolReport {
1389
                name: catalog[index].0.to_string(),
1390
                present: false,
1391
                path: String::new(),
1392
                version: String::new(),
1393
            })
1394
        })
1395
        .collect()
1396
}
1397
1398
/// The platform and architecture names the controller already knows.
1399
///
1400
/// The server sees `darwin-arm64` from the TypeScript CLI, and a machine that
1401
/// announced `macos-aarch64` would be a different machine to anything matching
1402
/// on that string. The names are the wire contract, not Rust's spelling of it.
1403
pub fn wire_platform() -> &'static str {
1404
    match std::env::consts::OS {
1405
        "macos" => "darwin",
1406
        "windows" => "win32",
1407
        other => other,
1408
    }
1409
}
1410
1411
pub fn wire_architecture() -> &'static str {
1412
    match std::env::consts::ARCH {
1413
        "aarch64" => "arm64",
1414
        "x86_64" => "x64",
1415
        "x86" => "ia32",
1416
        other => other,
1417
    }
1418
}
1419
1420
fn hostname() -> String {
1421
    let output = Command::new("hostname").stderr(Stdio::null()).output();
1422
    match output {
1423
        Ok(output) if output.status.success() => {
1424
            String::from_utf8_lossy(&output.stdout).trim().to_string()
1425
        }
1426
        _ => String::new(),
1427
    }
1428
}
1429
1430
fn worktree_report(root: &Path) -> WorktreeReport {
1431
    let exists = root.is_dir();
1432
    WorktreeReport {
1433
        root: root.display().to_string(),
1434
        exists,
1435
        git: root.join(".git").exists(),
1436
    }
1437
}
1438
1439
/// Inspect this machine with fixed read-only probes. It needs no account and no
1440
/// pairing.
1441
pub fn probe(roots: &[PathBuf]) -> ProbeReport {
1442
    let cwd = roots
1443
        .first()
1444
        .cloned()
1445
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
1446
    let cwd = if cwd.is_dir() {
1447
        cwd
1448
    } else {
1449
        std::env::current_dir().unwrap_or_else(|_| PathBuf::from("."))
1450
    };
1451
    let system = sysinfo::System::new_all();
1452
    ProbeReport {
1453
        schema: "openagents.computer_probe.v1".to_string(),
1454
        host: HostReport {
1455
            platform: wire_platform().to_string(),
1456
            release: run_quietly(&["uname", "-r"], &cwd),
1457
            architecture: wire_architecture().to_string(),
1458
            hostname: hostname(),
1459
            shell: std::env::var("SHELL").unwrap_or_default(),
1460
            cpu_count: system.cpus().len(),
1461
            total_memory_bytes: system.total_memory(),
1462
            uptime_seconds: sysinfo::System::uptime(),
1463
        },
1464
        coding_agents: probe_catalog(&CODING_AGENT_CATALOG, &cwd),
1465
        toolchains: probe_catalog(&TOOLCHAIN_CATALOG, &cwd),
1466
        roots: roots
1467
            .iter()
1468
            .map(|root| root.display().to_string())
1469
            .collect(),
1470
        worktrees: roots.iter().map(|root| worktree_report(root)).collect(),
1471
    }
1472
}
1473
1474
/// The bare host facts, kept for callers that only want the machine shape.
5 1475
#[derive(Debug, Clone, Serialize, Deserialize)]
6 1476
pub struct ComputerProbeResult {
7 1477
    pub os: String,

@@ -10,31 +1480,1584 @@ pub struct ComputerProbeResult {

10 1480
    pub total_memory_mb: u64,
11 1481
}
12 1482
1483
pub fn probe_host() -> ComputerProbeResult {
1484
    let system = sysinfo::System::new_all();
1485
    ComputerProbeResult {
1486
        os: std::env::consts::OS.to_string(),
1487
        arch: std::env::consts::ARCH.to_string(),
1488
        num_cpus: system.cpus().len(),
1489
        total_memory_mb: system.total_memory() / 1024 / 1024,
1490
    }
1491
}
1492
1493
// ---------------------------------------------------------------------------
1494
// the machine credential
1495
// ---------------------------------------------------------------------------
1496
1497
/// The OS credential store files a machine token under its own service name, so
1498
/// a Computer pairing and an account login never overwrite each other.
1499
const COMPUTER_KEYCHAIN_SERVICE: &str = "openagents-cli-computer";
1500
1501
fn machine_token_file_key(origin: &str) -> String {
1502
    format!("computer:{origin}")
1503
}
1504
1505
#[derive(Debug, Default, Serialize, Deserialize)]
1506
struct CredentialFile {
1507
    #[serde(default = "one")]
1508
    version: u8,
1509
    #[serde(default)]
1510
    tokens: BTreeMap<String, String>,
1511
}
1512
1513
fn one() -> u8 {
1514
    1
1515
}
1516
1517
/// Where this machine's token lives. Keyed by origin, exactly like the account
1518
/// credential store, so a machine paired with staging is not offered to
1519
/// production.
1520
pub struct MachineCredentials {
1521
    origin: String,
1522
    path: PathBuf,
1523
    use_os_store: bool,
1524
}
1525
1526
impl MachineCredentials {
1527
    pub fn for_origin(origin: &str) -> Self {
1528
        Self {
1529
            origin: origin.to_string(),
1530
            path: crate::auth::credentials_path(),
1531
            use_os_store: true,
1532
        }
1533
    }
1534
1535
    /// A store confined to a directory with the OS keychain switched off, so a
1536
    /// test exercises the real read, write, and delete without touching the
1537
    /// developer's own credentials.
1538
    pub fn isolated(origin: &str, directory: &Path) -> Self {
1539
        Self {
1540
            origin: origin.to_string(),
1541
            path: directory.join("cli-credentials.json"),
1542
            use_os_store: false,
1543
        }
1544
    }
1545
1546
    fn load(&self) -> Result<CredentialFile, String> {
1547
        match std::fs::read_to_string(&self.path) {
1548
            Ok(text) => serde_json::from_str(&text)
1549
                .map_err(|error| format!("could not decode {}: {error}", self.path.display())),
1550
            Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
1551
                Ok(CredentialFile::default())
1552
            }
1553
            Err(error) => Err(format!("could not read {}: {error}", self.path.display())),
1554
        }
1555
    }
1556
1557
    fn save(&self, file: &CredentialFile) -> Result<(), String> {
1558
        if file.tokens.is_empty() {
1559
            if self.path.exists() {
1560
                std::fs::remove_file(&self.path).map_err(|error| {
1561
                    format!("could not remove {}: {error}", self.path.display())
1562
                })?;
1563
            }
1564
            return Ok(());
1565
        }
1566
        let encoded = serde_json::to_string(file)
1567
            .map_err(|error| format!("could not encode credentials: {error}"))?;
1568
        write_private_file(&self.path, &encoded)
1569
    }
1570
1571
    fn os_get(&self) -> Option<String> {
1572
        if !self.use_os_store || !cfg!(target_os = "macos") && !cfg!(target_os = "linux") {
1573
            return None;
1574
        }
1575
        let output = if cfg!(target_os = "macos") {
1576
            Command::new("security")
1577
                .args([
1578
                    "find-generic-password",
1579
                    "-a",
1580
                    &self.origin,
1581
                    "-s",
1582
                    COMPUTER_KEYCHAIN_SERVICE,
1583
                    "-w",
1584
                ])
1585
                .stderr(Stdio::null())
1586
                .output()
1587
        } else {
1588
            Command::new("secret-tool")
1589
                .args([
1590
                    "lookup",
1591
                    "service",
1592
                    COMPUTER_KEYCHAIN_SERVICE,
1593
                    "origin",
1594
                    &self.origin,
1595
                ])
1596
                .stderr(Stdio::null())
1597
                .output()
1598
        };
1599
        let output = output.ok()?;
1600
        if !output.status.success() {
1601
            return None;
1602
        }
1603
        let value = String::from_utf8_lossy(&output.stdout).trim().to_string();
1604
        // A machine token is an `smct_`. A record that is not one is not a
1605
        // credential this command can use, and it is not reported as one.
1606
        if value.starts_with("smct_") {
1607
            Some(value)
1608
        } else {
1609
            None
1610
        }
1611
    }
1612
1613
    fn os_set(&self, token: &str) -> bool {
1614
        if !self.use_os_store {
1615
            return false;
1616
        }
1617
        if cfg!(target_os = "macos") {
1618
            let status = Command::new("security")
1619
                .args([
1620
                    "add-generic-password",
1621
                    "-U",
1622
                    "-a",
1623
                    &self.origin,
1624
                    "-s",
1625
                    COMPUTER_KEYCHAIN_SERVICE,
1626
                    "-w",
1627
                    token,
1628
                ])
1629
                .stderr(Stdio::null())
1630
                .stdout(Stdio::null())
1631
                .status();
1632
            return matches!(status, Ok(status) if status.success());
1633
        }
1634
        if cfg!(target_os = "linux") {
1635
            let child = Command::new("secret-tool")
1636
                .args([
1637
                    "store",
1638
                    "--label",
1639
                    COMPUTER_KEYCHAIN_SERVICE,
1640
                    "service",
1641
                    COMPUTER_KEYCHAIN_SERVICE,
1642
                    "origin",
1643
                    &self.origin,
1644
                ])
1645
                .stdin(Stdio::piped())
1646
                .stdout(Stdio::null())
1647
                .stderr(Stdio::null())
1648
                .spawn();
1649
            if let Ok(mut child) = child {
1650
                if let Some(mut pipe) = child.stdin.take() {
1651
                    let _ = pipe.write_all(token.as_bytes());
1652
                }
1653
                return matches!(child.wait(), Ok(status) if status.success());
1654
            }
1655
        }
1656
        false
1657
    }
1658
1659
    fn os_remove(&self) {
1660
        if !self.use_os_store {
1661
            return;
1662
        }
1663
        if cfg!(target_os = "macos") {
1664
            let _ = Command::new("security")
1665
                .args([
1666
                    "delete-generic-password",
1667
                    "-a",
1668
                    &self.origin,
1669
                    "-s",
1670
                    COMPUTER_KEYCHAIN_SERVICE,
1671
                ])
1672
                .stderr(Stdio::null())
1673
                .stdout(Stdio::null())
1674
                .status();
1675
        } else if cfg!(target_os = "linux") {
1676
            let _ = Command::new("secret-tool")
1677
                .args([
1678
                    "clear",
1679
                    "service",
1680
                    COMPUTER_KEYCHAIN_SERVICE,
1681
                    "origin",
1682
                    &self.origin,
1683
                ])
1684
                .stderr(Stdio::null())
1685
                .status();
1686
        }
1687
    }
1688
1689
    pub fn get(&self) -> Result<Option<crate::auth::Secret>, String> {
1690
        if let Some(token) = self.os_get() {
1691
            return Ok(Some(crate::auth::Secret::new(token)));
1692
        }
1693
        let file = self.load()?;
1694
        Ok(file
1695
            .tokens
1696
            .get(&machine_token_file_key(&self.origin))
1697
            .filter(|value| !value.trim().is_empty())
1698
            .map(|value| crate::auth::Secret::new(value.trim())))
1699
    }
1700
1701
    pub fn set(&self, token: &crate::auth::Secret) -> Result<(), String> {
1702
        if self.os_set(token.expose()) {
1703
            return Ok(());
1704
        }
1705
        let mut file = self.load()?;
1706
        file.version = 1;
1707
        file.tokens.insert(
1708
            machine_token_file_key(&self.origin),
1709
            token.expose().to_string(),
1710
        );
1711
        self.save(&file)
1712
    }
1713
1714
    pub fn remove(&self) -> Result<bool, String> {
1715
        let had = self.get()?.is_some();
1716
        self.os_remove();
1717
        let mut file = self.load()?;
1718
        if file
1719
            .tokens
1720
            .remove(&machine_token_file_key(&self.origin))
1721
            .is_some()
1722
        {
1723
            self.save(&file)?;
1724
        }
1725
        Ok(had)
1726
    }
1727
}
1728
1729
// ---------------------------------------------------------------------------
1730
// pairing and status
1731
// ---------------------------------------------------------------------------
1732
13 1733
#[derive(Debug, Clone, Serialize, Deserialize)]
14
pub struct ComputerPolicy {
15
    pub allow_shell: bool,
16
    pub allow_filesystem_write: bool,
17
    pub allow_network: bool,
1734
pub struct PairingStart {
1735
    pub pairing_id: String,
1736
    pub code: String,
1737
    pub poll_secret: String,
1738
    pub verify_url: String,
1739
    pub expires_at: String,
1740
    pub interval_seconds: u64,
18 1741
}
19 1742
20
impl Default for ComputerPolicy {
21
    fn default() -> Self {
1743
#[derive(Debug, Clone, Serialize, Deserialize)]
1744
pub struct PairingClaim {
1745
    pub status: String,
1746
    pub machine_id: String,
1747
    pub name: String,
1748
    pub token: String,
1749
}
1750
1751
#[derive(Debug, Clone, Serialize, Deserialize)]
1752
pub struct MachineStatus {
1753
    pub machine_id: String,
1754
    pub name: String,
1755
    pub status: String,
1756
    pub token_expires_at: String,
1757
}
1758
1759
pub struct ComputerClient {
1760
    pub origin: String,
1761
    http: reqwest::Client,
1762
}
1763
1764
fn error_code(value: &serde_json::Value) -> Option<String> {
1765
    value
1766
        .get("error")
1767
        .and_then(|value| value.as_str())
1768
        .map(str::to_string)
1769
}
1770
1771
impl ComputerClient {
1772
    pub fn new(origin: &str) -> Self {
22 1773
        Self {
23
            allow_shell: true,
24
            allow_filesystem_write: true,
25
            allow_network: true,
1774
            origin: origin.trim_end_matches('/').to_string(),
1775
            http: reqwest::Client::new(),
1776
        }
1777
    }
1778
1779
    pub async fn start(
1780
        &self,
1781
        name: &str,
1782
        tier: Tier,
1783
        agent_version: &str,
1784
        roots: &[PathBuf],
1785
    ) -> Result<PairingStart, String> {
1786
        let url = format!("{}/controller/pairings", self.origin);
1787
        let body = serde_json::json!({
1788
            "name": name,
1789
            "tier": tier.label(),
1790
            "platform": format!("{}-{}", wire_platform(), wire_architecture()),
1791
            "agent_version": agent_version,
1792
            "roots": roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>(),
1793
        });
1794
        let response = self
1795
            .http
1796
            .post(&url)
1797
            .json(&body)
1798
            .send()
1799
            .await
1800
            .map_err(|error| {
1801
                format!("the Computer pairing request could not reach {url}: {error}")
1802
            })?;
1803
        let status = response.status().as_u16();
1804
        let text = response.text().await.unwrap_or_default();
1805
        let parsed: serde_json::Value =
1806
            serde_json::from_str(&text).unwrap_or(serde_json::Value::Null);
1807
        if status == 404 && error_code(&parsed).as_deref() == Some("computer_controller_disabled") {
1808
            return Err(format!(
1809
                "the OpenAgents Computer surface is not enabled on {}",
1810
                self.origin
1811
            ));
1812
        }
1813
        if status == 422 {
1814
            return Err(format!("{} refused this Computer pairing", self.origin));
1815
        }
1816
        if status != 200 && status != 201 {
1817
            return Err(format!(
1818
                "{} could not register this Computer pairing (HTTP {status})",
1819
                self.origin
1820
            ));
1821
        }
1822
        serde_json::from_value(parsed).map_err(|error| {
1823
            format!("the Computer pairing response did not match the API contract: {error}")
1824
        })
1825
    }
1826
1827
    /// One poll. `Ok(None)` means the owner has not approved yet.
1828
    pub async fn poll(&self, pairing: &PairingStart) -> Result<Option<PairingClaim>, String> {
1829
        let url = format!(
1830
            "{}/controller/pairings/{}",
1831
            self.origin,
1832
            urlencode(&pairing.pairing_id)
1833
        );
1834
        let response = self
1835
            .http
1836
            .get(&url)
1837
            .header("x-pairing-secret", &pairing.poll_secret)
1838
            .send()
1839
            .await
1840
            .map_err(|error| format!("the Computer pairing poll could not reach {url}: {error}"))?;
1841
        let status = response.status().as_u16();
1842
        let text = response.text().await.unwrap_or_default();
1843
        if status == 410 {
1844
            return Err("the Computer pairing expired before the owner approved it".to_string());
1845
        }
1846
        if status == 404 || status == 401 || status == 403 {
1847
            return Err("the Computer pairing was refused or is no longer available".to_string());
1848
        }
1849
        if status != 200 {
1850
            return Err(format!(
1851
                "{} could not poll this Computer pairing (HTTP {status})",
1852
                self.origin
1853
            ));
1854
        }
1855
        let parsed: serde_json::Value = serde_json::from_str(&text)
1856
            .map_err(|_| "the Computer pairing poll did not answer with JSON".to_string())?;
1857
        if parsed.get("status").and_then(|v| v.as_str()) == Some("pending") {
1858
            return Ok(None);
1859
        }
1860
        serde_json::from_value(parsed).map(Some).map_err(|error| {
1861
            format!("the Computer claim response did not match the API contract: {error}")
1862
        })
1863
    }
1864
1865
    /// Poll until the owner approves or the pairing expires.
1866
    pub async fn wait(&self, pairing: &PairingStart) -> Result<PairingClaim, String> {
1867
        let interval = Duration::from_secs(pairing.interval_seconds.max(1));
1868
        let deadline = Instant::now() + pairing_window(&pairing.expires_at)?;
1869
        loop {
1870
            if let Some(claim) = self.poll(pairing).await? {
1871
                return Ok(claim);
1872
            }
1873
            if Instant::now() + interval > deadline {
1874
                return Err("the Computer pairing expired before the owner approved it".to_string());
1875
            }
1876
            tokio::time::sleep(interval).await;
26 1877
        }
27 1878
    }
1879
1880
    /// `Ok(None)` means the server answered 401: this machine token is no longer
1881
    /// accepted. A transport failure or any other status is an error, never a
1882
    /// quiet "unpaired".
1883
    pub async fn status(
1884
        &self,
1885
        token: &crate::auth::Secret,
1886
    ) -> Result<Option<MachineStatus>, String> {
1887
        let url = format!("{}/controller/status", self.origin);
1888
        let response = self
1889
            .http
1890
            .get(&url)
1891
            .header("authorization", format!("Bearer {}", token.expose()))
1892
            .send()
1893
            .await
1894
            .map_err(|error| {
1895
                format!("the Computer status request could not reach {url}: {error}")
1896
            })?;
1897
        let status = response.status().as_u16();
1898
        let text = response.text().await.unwrap_or_default();
1899
        if status == 401 {
1900
            return Ok(None);
1901
        }
1902
        if status != 200 {
1903
            return Err(format!(
1904
                "{} could not read this Computer status (HTTP {status})",
1905
                self.origin
1906
            ));
1907
        }
1908
        serde_json::from_str(&text).map(Some).map_err(|error| {
1909
            format!("the Computer status response did not match the API contract: {error}")
1910
        })
1911
    }
28 1912
}
29 1913
30
pub fn probe_host() -> ComputerProbeResult {
31
    use sysinfo::System;
32
    let mut sys = System::new_all();
33
    sys.refresh_all();
34
    ComputerProbeResult {
35
        os: std::env::consts::OS.to_string(),
36
        arch: std::env::consts::ARCH.to_string(),
37
        num_cpus: sys.cpus().len(),
38
        total_memory_mb: sys.total_memory() / 1024 / 1024,
1914
fn urlencode(value: &str) -> String {
1915
    value
1916
        .bytes()
1917
        .map(|byte| match byte {
1918
            b'A'..=b'Z' | b'a'..=b'z' | b'0'..=b'9' | b'-' | b'_' | b'.' | b'~' => {
1919
                (byte as char).to_string()
1920
            }
1921
            other => format!("%{other:02X}"),
1922
        })
1923
        .collect()
1924
}
1925
1926
/// How long the pairing has left, from the RFC 3339 expiry the server sent.
1927
fn pairing_window(expires_at: &str) -> Result<Duration, String> {
1928
    let seconds = parse_rfc3339_seconds(expires_at)
1929
        .ok_or_else(|| "the Computer pairing expiry did not match the API contract".to_string())?;
1930
    let now = SystemTime::now()
1931
        .duration_since(UNIX_EPOCH)
1932
        .map(|value| value.as_secs() as i64)
1933
        .unwrap_or(0);
1934
    Ok(Duration::from_secs((seconds - now).max(0) as u64))
1935
}
1936
1937
/// Enough of RFC 3339 to read the expiry the controller sends, which is always
1938
/// UTC with a `Z`.
1939
pub fn parse_rfc3339_seconds(value: &str) -> Option<i64> {
1940
    let bytes = value.as_bytes();
1941
    if bytes.len() < 20 || bytes[4] != b'-' || bytes[7] != b'-' || bytes[10] != b'T' {
1942
        return None;
1943
    }
1944
    let year: i64 = value.get(0..4)?.parse().ok()?;
1945
    let month: i64 = value.get(5..7)?.parse().ok()?;
1946
    let day: i64 = value.get(8..10)?.parse().ok()?;
1947
    let hour: i64 = value.get(11..13)?.parse().ok()?;
1948
    let minute: i64 = value.get(14..16)?.parse().ok()?;
1949
    let second: i64 = value.get(17..19)?.parse().ok()?;
1950
    // Days-from-civil, Howard Hinnant's algorithm.
1951
    let y = if month <= 2 { year - 1 } else { year };
1952
    let era = y.div_euclid(400);
1953
    let yoe = y - era * 400;
1954
    let mp = if month > 2 { month - 3 } else { month + 9 };
1955
    let doy = (153 * mp + 2) / 5 + day - 1;
1956
    let doe = yoe * 365 + yoe / 4 - yoe / 100 + doy;
1957
    let days = era * 146_097 + doe - 719_468;
1958
    Some(days * 86_400 + hour * 3_600 + minute * 60 + second)
1959
}
1960
1961
// ---------------------------------------------------------------------------
1962
// the outbound channel
1963
// ---------------------------------------------------------------------------
1964
1965
const HEARTBEAT: Duration = Duration::from_secs(30);
1966
const RECONNECT_BACKOFF: Duration = Duration::from_millis(250);
1967
const MAXIMUM_RECONNECT_ATTEMPTS: u32 = 3;
1968
const MAXIMUM_BACKOFF: Duration = Duration::from_secs(10);
1969
const MAXIMUM_CONCURRENCY: usize = 2;
1970
const MAXIMUM_ARGV_LENGTH: usize = 64;
1971
const MAXIMUM_ARGUMENT_LENGTH: usize = 1_024;
1972
1973
fn socket_url(origin: &str, token: &str) -> String {
1974
    let base = origin.trim_end_matches('/');
1975
    let scheme_swapped = if let Some(rest) = base.strip_prefix("https") {
1976
        format!("wss{rest}")
1977
    } else if let Some(rest) = base.strip_prefix("http") {
1978
        format!("ws{rest}")
1979
    } else {
1980
        base.to_string()
1981
    };
1982
    format!(
1983
        "{scheme_swapped}/controller/socket/websocket?vsn=2.0.0&token={}",
1984
        urlencode(token)
1985
    )
1986
}
1987
1988
/// A frame queued by a worker thread for the socket loop to write. The socket is
1989
/// read and written from one thread only; everything else talks to it through
1990
/// this queue.
1991
enum Outgoing {
1992
    Frame {
1993
        event: String,
1994
        payload: serde_json::Value,
1995
    },
1996
}
1997
1998
fn phoenix_frame(
1999
    join_ref: Option<&str>,
2000
    reference: &str,
2001
    topic: &str,
2002
    event: &str,
2003
    payload: &serde_json::Value,
2004
) -> String {
2005
    serde_json::json!([join_ref, reference, topic, event, payload]).to_string()
2006
}
2007
2008
/// Why a connection ended. `retryable` decides whether `serve` reconnects.
2009
#[derive(Debug, Clone, PartialEq, Eq)]
2010
pub struct ConnectionEnd {
2011
    pub reason: String,
2012
    pub retryable: bool,
2013
}
2014
2015
pub fn reconnectable_transport_reason(reason: &str) -> bool {
2016
    reason == "closed"
2017
        || reason == "phx_close"
2018
        || reason == "phx_error"
2019
        || reason == "heartbeat_timeout"
2020
        || reason == "socket_not_open"
2021
        || reason.starts_with("error:")
2022
}
2023
2024
/// Serve one connection until it ends, and say why it ended.
2025
///
2026
/// Everything the server asks for goes through [`decide`] first and lands in the
2027
/// journal either way. A frame this build has no handler for is refused as
2028
/// `unsupported` rather than silently ignored.
2029
fn serve_connection(
2030
    origin: &str,
2031
    token: &crate::auth::Secret,
2032
    machine_id: &str,
2033
    hello: &serde_json::Value,
2034
    config: &PolicyConfig,
2035
    journal: &Journal,
2036
    mut on_event: impl FnMut(&str),
2037
) -> ConnectionEnd {
2038
    use tungstenite::{client::IntoClientRequest, Message};
2039
2040
    let _ = rustls::crypto::ring::default_provider().install_default();
2041
2042
    let url = socket_url(origin, token.expose());
2043
    let request = match url.as_str().into_client_request() {
2044
        Ok(request) => request,
2045
        Err(error) => {
2046
            return ConnectionEnd {
2047
                reason: format!("error:{error}"),
2048
                retryable: false,
2049
            }
2050
        }
2051
    };
2052
    let (mut socket, _response) = match connect_bounded(request) {
2053
        Ok(pair) => pair,
2054
        Err(reason) => {
2055
            // A connection that never opened is still a transport event this
2056
            // machine's owner should be able to read back.
2057
            let request = CommandRequest {
2058
                argv: vec!["<connection>".to_string()],
2059
                cwd: String::new(),
2060
            };
2061
            let _ = journal.append("connection", &request, "transport", "closed", &reason);
2062
            let retryable = reconnectable_transport_reason(&reason);
2063
            return ConnectionEnd { reason, retryable };
2064
        }
2065
    };
2066
2067
    let topic = format!("computer:{machine_id}");
2068
    let join_ref = "1";
2069
    let mut reference: u64 = 1;
2070
    let mut heartbeat_at = Instant::now() + HEARTBEAT;
2071
    let mut heartbeat_pending = false;
2072
    let mut heartbeat_ref = String::new();
2073
    let mut joined = false;
2074
2075
    let (sender, receiver): (Sender<Outgoing>, Receiver<Outgoing>) = std::sync::mpsc::channel();
2076
    let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2077
    let cancellations: Arc<Mutex<BTreeMap<String, Cancellation>>> =
2078
        Arc::new(Mutex::new(BTreeMap::new()));
2079
2080
    let send_join = phoenix_frame(
2081
        Some(join_ref),
2082
        join_ref,
2083
        &topic,
2084
        "phx_join",
2085
        &serde_json::json!({}),
2086
    );
2087
    if socket.send(Message::Text(send_join.into())).is_err() {
2088
        return ConnectionEnd {
2089
            reason: "socket_not_open".to_string(),
2090
            retryable: true,
2091
        };
2092
    }
2093
2094
    let end = loop {
2095
        // Drain anything the workers produced before touching the socket again.
2096
        while let Ok(Outgoing::Frame { event, payload }) = receiver.try_recv() {
2097
            reference += 1;
2098
            let frame = phoenix_frame(
2099
                Some(join_ref),
2100
                &reference.to_string(),
2101
                &topic,
2102
                &event,
2103
                &payload,
2104
            );
2105
            if socket.send(Message::Text(frame.into())).is_err() {
2106
                break;
2107
            }
2108
        }
2109
2110
        if Instant::now() >= heartbeat_at {
2111
            if heartbeat_pending {
2112
                break ConnectionEnd {
2113
                    reason: "heartbeat_timeout".to_string(),
2114
                    retryable: true,
2115
                };
2116
            }
2117
            reference += 1;
2118
            heartbeat_ref = reference.to_string();
2119
            heartbeat_pending = true;
2120
            heartbeat_at = Instant::now() + HEARTBEAT;
2121
            let frame = phoenix_frame(
2122
                None,
2123
                &heartbeat_ref,
2124
                "phoenix",
2125
                "heartbeat",
2126
                &serde_json::json!({}),
2127
            );
2128
            if socket.send(Message::Text(frame.into())).is_err() {
2129
                break ConnectionEnd {
2130
                    reason: "socket_not_open".to_string(),
2131
                    retryable: true,
2132
                };
2133
            }
2134
        }
2135
2136
        let message = match socket.read() {
2137
            Ok(message) => message,
2138
            Err(tungstenite::Error::Io(error))
2139
                if matches!(
2140
                    error.kind(),
2141
                    std::io::ErrorKind::WouldBlock | std::io::ErrorKind::TimedOut
2142
                ) =>
2143
            {
2144
                continue;
2145
            }
2146
            Err(tungstenite::Error::ConnectionClosed) | Err(tungstenite::Error::AlreadyClosed) => {
2147
                break ConnectionEnd {
2148
                    reason: "closed".to_string(),
2149
                    retryable: true,
2150
                }
2151
            }
2152
            Err(error) => {
2153
                let reason = format!("error:{error}");
2154
                let retryable = reconnectable_transport_reason(&reason);
2155
                break ConnectionEnd { reason, retryable };
2156
            }
2157
        };
2158
2159
        let text = match message {
2160
            Message::Text(text) => text.to_string(),
2161
            Message::Close(_) => {
2162
                break ConnectionEnd {
2163
                    reason: "closed".to_string(),
2164
                    retryable: true,
2165
                }
2166
            }
2167
            Message::Ping(payload) => {
2168
                let _ = socket.send(Message::Pong(payload));
2169
                continue;
2170
            }
2171
            _ => continue,
2172
        };
2173
2174
        let Ok(frame) = serde_json::from_str::<serde_json::Value>(&text) else {
2175
            continue;
2176
        };
2177
        let Some(parts) = frame.as_array() else {
2178
            continue;
2179
        };
2180
        if parts.len() != 5 {
2181
            continue;
2182
        }
2183
        let response_ref = parts[1].as_str().unwrap_or_default().to_string();
2184
        let response_topic = parts[2].as_str().unwrap_or_default().to_string();
2185
        let event = parts[3].as_str().unwrap_or_default().to_string();
2186
        let payload = parts[4].clone();
2187
2188
        if response_topic == "phoenix" && event == "phx_reply" && response_ref == heartbeat_ref {
2189
            heartbeat_pending = false;
2190
            continue;
2191
        }
2192
        if response_topic != topic {
2193
            continue;
2194
        }
2195
        if event == "phx_reply" {
2196
            if response_ref != join_ref {
2197
                continue;
2198
            }
2199
            if payload.get("status").and_then(|v| v.as_str()) == Some("ok") {
2200
                joined = true;
2201
                on_event("joined");
2202
                reference += 1;
2203
                let frame = phoenix_frame(
2204
                    Some(join_ref),
2205
                    &reference.to_string(),
2206
                    &topic,
2207
                    "hello",
2208
                    hello,
2209
                );
2210
                let _ = socket.send(Message::Text(frame.into()));
2211
            } else {
2212
                let refusal = payload
2213
                    .get("response")
2214
                    .and_then(|value| value.get("reason"))
2215
                    .and_then(|value| value.as_str())
2216
                    .unwrap_or("unknown")
2217
                    .to_string();
2218
                break ConnectionEnd {
2219
                    // `machine_reconnecting` is the server telling this machine
2220
                    // that its previous connection has not been reaped yet, so
2221
                    // it is worth waiting for. Every other refusal is a decision.
2222
                    retryable: refusal == "machine_reconnecting",
2223
                    reason: format!("join_refused:{refusal}"),
2224
                };
2225
            }
2226
            continue;
2227
        }
2228
        if event == "phx_close" || event == "phx_error" {
2229
            break ConnectionEnd {
2230
                reason: event,
2231
                retryable: true,
2232
            };
2233
        }
2234
        if !joined {
2235
            continue;
2236
        }
2237
2238
        let Some(request_id) = payload.get("request_id").and_then(|v| v.as_str()) else {
2239
            continue;
2240
        };
2241
        let request_id = request_id.to_string();
2242
        on_event(&format!("{event}:{}", short(&request_id)));
2243
2244
        match event.as_str() {
2245
            "probe" => {
2246
                let request = CommandRequest {
2247
                    argv: vec!["<probe>".to_string()],
2248
                    cwd: config
2249
                        .roots
2250
                        .first()
2251
                        .map(|root| root.display().to_string())
2252
                        .unwrap_or_default(),
2253
                };
2254
                let _ = journal.append(
2255
                    &request_id,
2256
                    &request,
2257
                    "received",
2258
                    "pending",
2259
                    "read-only probe requested",
2260
                );
2261
                let report = probe(&config.roots);
2262
                let _ = journal.append(
2263
                    &request_id,
2264
                    &request,
2265
                    "allowed",
2266
                    "completed",
2267
                    "probe completed",
2268
                );
2269
                reference += 1;
2270
                let frame = phoenix_frame(
2271
                    Some(join_ref),
2272
                    &reference.to_string(),
2273
                    &topic,
2274
                    "probe_result",
2275
                    &serde_json::json!({
2276
                        "request_id": request_id,
2277
                        "probe": serde_json::to_value(&report).unwrap_or(serde_json::Value::Null),
2278
                    }),
2279
                );
2280
                let _ = socket.send(Message::Text(frame.into()));
2281
            }
2282
            "run" => {
2283
                handle_run(
2284
                    &request_id,
2285
                    &payload,
2286
                    config,
2287
                    journal,
2288
                    &sender,
2289
                    &active,
2290
                    &cancellations,
2291
                );
2292
            }
2293
            "agent" => {
2294
                // ACP delegation is a separate subsystem this build does not
2295
                // carry. Saying so is the honest answer; pretending to accept it
2296
                // would leave the server waiting for output that never comes.
2297
                let request = CommandRequest {
2298
                    argv: vec!["<agent>".to_string()],
2299
                    cwd: String::new(),
2300
                };
2301
                let _ = journal.append(
2302
                    &request_id,
2303
                    &request,
2304
                    "unsupported",
2305
                    "refused",
2306
                    "ACP delegation is unavailable",
2307
                );
2308
                reference += 1;
2309
                let frame = phoenix_frame(
2310
                    Some(join_ref),
2311
                    &reference.to_string(),
2312
                    &topic,
2313
                    "refused",
2314
                    &serde_json::json!({
2315
                        "request_id": request_id,
2316
                        "reason": "unsupported",
2317
                        "detail": "ACP delegation is unavailable",
2318
                    }),
2319
                );
2320
                let _ = socket.send(Message::Text(frame.into()));
2321
            }
2322
            "cancel" => {
2323
                if let Some(cancellation) = cancellations.lock().unwrap().get(&request_id) {
2324
                    cancellation.cancel();
2325
                }
2326
                let request = CommandRequest {
2327
                    argv: vec!["<cancel>".to_string()],
2328
                    cwd: String::new(),
2329
                };
2330
                let _ = journal.append(
2331
                    &request_id,
2332
                    &request,
2333
                    "allowed",
2334
                    "cancelling",
2335
                    "process group termination requested",
2336
                );
2337
            }
2338
            _ => {}
2339
        }
2340
    };
2341
2342
    let closing = CommandRequest {
2343
        argv: vec!["<connection>".to_string()],
2344
        cwd: String::new(),
2345
    };
2346
    let _ = journal.append("connection", &closing, "transport", "closed", &end.reason);
2347
    for cancellation in cancellations.lock().unwrap().values() {
2348
        cancellation.cancel();
2349
    }
2350
    let _ = socket.close(None);
2351
    end
2352
}
2353
2354
fn short(value: &str) -> String {
2355
    value.chars().take(8).collect()
2356
}
2357
2358
type WsStream = tungstenite::WebSocket<tungstenite::stream::MaybeTlsStream<std::net::TcpStream>>;
2359
2360
/// Connect with bounded DNS, connect, and read timeouts. The read timeout is
2361
/// what lets the loop above tick for heartbeats and queued frames instead of
2362
/// blocking forever on a quiet socket.
2363
fn connect_bounded(
2364
    request: tungstenite::http::Request<()>,
2365
) -> Result<(WsStream, tungstenite::handshake::client::Response), String> {
2366
    use std::net::ToSocketAddrs;
2367
    let host = request.uri().host().ok_or("error:no_host")?.to_string();
2368
    let secure = request.uri().scheme_str() == Some("wss");
2369
    let port = request
2370
        .uri()
2371
        .port_u16()
2372
        .unwrap_or(if secure { 443 } else { 80 });
2373
    let addresses = (host.as_str(), port)
2374
        .to_socket_addrs()
2375
        .map_err(|_| "error:dns_failed".to_string())?;
2376
    for address in addresses {
2377
        let Ok(stream) = std::net::TcpStream::connect_timeout(&address, Duration::from_secs(10))
2378
        else {
2379
            continue;
2380
        };
2381
        let _ = stream.set_read_timeout(Some(Duration::from_millis(200)));
2382
        let _ = stream.set_write_timeout(Some(Duration::from_secs(10)));
2383
        return match tungstenite::client_tls(request, stream) {
2384
            Ok(pair) => Ok(pair),
2385
            // A handshake the server answered with a status is a decision, not
2386
            // transport loss: `403` means this token is not accepted, and
2387
            // reconnecting would only repeat it.
2388
            Err(tungstenite::HandshakeError::Failure(tungstenite::Error::Http(response))) => {
2389
                Err(format!("join_refused:http_{}", response.status().as_u16()))
2390
            }
2391
            Err(tungstenite::HandshakeError::Failure(error)) => Err(format!("error:{error}")),
2392
            Err(tungstenite::HandshakeError::Interrupted(_)) => {
2393
                Err("error:handshake_interrupted".to_string())
2394
            }
2395
        };
2396
    }
2397
    Err("error:connect_failed".to_string())
2398
}
2399
2400
#[allow(clippy::too_many_arguments)]
2401
fn handle_run(
2402
    request_id: &str,
2403
    payload: &serde_json::Value,
2404
    config: &PolicyConfig,
2405
    journal: &Journal,
2406
    sender: &Sender<Outgoing>,
2407
    active: &Arc<std::sync::atomic::AtomicUsize>,
2408
    cancellations: &Arc<Mutex<BTreeMap<String, Cancellation>>>,
2409
) {
2410
    use std::sync::atomic::Ordering;
2411
2412
    let refuse_frame = |reason: &str, detail: &str| Outgoing::Frame {
2413
        event: "refused".to_string(),
2414
        payload: serde_json::json!({
2415
            "request_id": request_id,
2416
            "reason": reason,
2417
            "detail": detail,
2418
        }),
2419
    };
2420
2421
    let Some(request) = request_fields(payload) else {
2422
        let malformed = CommandRequest {
2423
            argv: vec!["<invalid>".to_string()],
2424
            cwd: String::new(),
2425
        };
2426
        let _ = journal.append(
2427
            request_id,
2428
            &malformed,
2429
            "refused",
2430
            "refused",
2431
            "invalid command request",
2432
        );
2433
        let _ = sender.send(refuse_frame(
2434
            "invalid_request",
2435
            "argv and cwd are required and must be bounded",
2436
        ));
2437
        return;
2438
    };
2439
    let _ = journal.append(
2440
        request_id,
2441
        &request,
2442
        "received",
2443
        "pending",
2444
        "command request received",
2445
    );
2446
2447
    if let Some(requested) = payload.get("tier").and_then(|v| v.as_str()) {
2448
        if let Some(requested) = Tier::parse(requested) {
2449
            if !tier_allows(config.tier, requested) {
2450
                let detail = "the requested tier exceeds the local ceiling";
2451
                let _ =
2452
                    journal.append(request_id, &request, "tier_insufficient", "refused", detail);
2453
                let _ = sender.send(refuse_frame("tier_insufficient", detail));
2454
                return;
2455
            }
2456
        }
2457
    }
2458
2459
    match decide(&request, config) {
2460
        Decision::Refused { reason, detail } => {
2461
            let _ = journal.append(request_id, &request, reason.label(), "refused", &detail);
2462
            let _ = sender.send(refuse_frame(reason.label(), &detail));
2463
            return;
2464
        }
2465
        Decision::Allowed {
2466
            needs_confirmation: true,
2467
        } => {
2468
            let detail = "local confirmation is required for this command";
2469
            let _ = journal.append(
2470
                request_id,
2471
                &request,
2472
                "confirmation_required",
2473
                "refused",
2474
                "local confirmation is required",
2475
            );
2476
            let _ = sender.send(refuse_frame("confirmation_required", detail));
2477
            return;
2478
        }
2479
        Decision::Allowed { .. } => {}
2480
    }
2481
2482
    if active.load(Ordering::SeqCst) >= MAXIMUM_CONCURRENCY {
2483
        let _ = journal.append(
2484
            request_id,
2485
            &request,
2486
            "allowed",
2487
            "refused",
2488
            "local execution concurrency limit reached",
2489
        );
2490
        let _ = sender.send(refuse_frame("busy", "the local execution limit is reached"));
2491
        return;
2492
    }
2493
2494
    let defaults = ExecutionLimits::default();
2495
    let limits = ExecutionLimits {
2496
        timeout: Duration::from_millis(bounded_number(
2497
            payload,
2498
            &["timeout_ms", "timeout"],
2499
            defaults.timeout.as_millis() as u64,
2500
            defaults.timeout.as_millis() as u64,
2501
        )),
2502
        maximum_output_bytes: bounded_number(
2503
            payload,
2504
            &[
2505
                "maximum_output_bytes",
2506
                "output_max_bytes",
2507
                "max_output_bytes",
2508
            ],
2509
            defaults.maximum_output_bytes as u64,
2510
            defaults.maximum_output_bytes as u64,
2511
        ) as usize,
2512
    };
2513
    let _ = journal.append(
2514
        request_id,
2515
        &request,
2516
        "allowed",
2517
        "running",
2518
        &format!("timeout={}", limits.timeout.as_millis()),
2519
    );
2520
2521
    active.fetch_add(1, Ordering::SeqCst);
2522
    let cancellation = Cancellation::default();
2523
    cancellations
2524
        .lock()
2525
        .unwrap()
2526
        .insert(request_id.to_string(), cancellation.clone());
2527
2528
    let sender = sender.clone();
2529
    let journal = journal.clone();
2530
    let active = Arc::clone(active);
2531
    let cancellations = Arc::clone(cancellations);
2532
    let request_id = request_id.to_string();
2533
    std::thread::spawn(move || {
2534
        let chunk_sender = sender.clone();
2535
        let chunk_id = request_id.clone();
2536
        let outcome = execute_command(&request.argv, &request.cwd, limits, &cancellation, |text| {
2537
            let _ = chunk_sender.send(Outgoing::Frame {
2538
                event: "chunk".to_string(),
2539
                payload: serde_json::json!({ "request_id": chunk_id, "text": text }),
2540
            });
2541
        });
2542
        active.fetch_sub(1, Ordering::SeqCst);
2543
        cancellations.lock().unwrap().remove(&request_id);
2544
        let terminal = if outcome.cancelled {
2545
            "cancelled"
2546
        } else if outcome.timed_out {
2547
            "timeout"
2548
        } else if outcome.exit_code == Some(0) {
2549
            "completed"
2550
        } else {
2551
            "failed"
2552
        };
2553
        let detail = if outcome.truncated {
2554
            "output truncated"
2555
        } else {
2556
            ""
2557
        };
2558
        let _ = journal.append(&request_id, &request, "allowed", terminal, detail);
2559
        let _ = sender.send(Outgoing::Frame {
2560
            event: "exit".to_string(),
2561
            payload: serde_json::json!({
2562
                "request_id": request_id,
2563
                "status": terminal,
2564
                "exit_code": outcome.exit_code,
2565
                "timed_out": outcome.timed_out,
2566
                "cancelled": outcome.cancelled,
2567
                "truncated": outcome.truncated,
2568
                "duration_ms": outcome.duration_ms,
2569
            }),
2570
        });
2571
    });
2572
}
2573
2574
fn request_fields(payload: &serde_json::Value) -> Option<CommandRequest> {
2575
    let argv = payload.get("argv")?.as_array()?;
2576
    if argv.is_empty() || argv.len() > MAXIMUM_ARGV_LENGTH {
2577
        return None;
2578
    }
2579
    let mut parts = Vec::with_capacity(argv.len());
2580
    for value in argv {
2581
        let text = value.as_str()?;
2582
        if text.len() > MAXIMUM_ARGUMENT_LENGTH {
2583
            return None;
2584
        }
2585
        parts.push(text.to_string());
2586
    }
2587
    let cwd = payload.get("cwd")?.as_str()?;
2588
    if cwd.len() > 4_096 {
2589
        return None;
2590
    }
2591
    Some(CommandRequest {
2592
        argv: parts,
2593
        cwd: cwd.to_string(),
2594
    })
2595
}
2596
2597
fn bounded_number(payload: &serde_json::Value, names: &[&str], fallback: u64, ceiling: u64) -> u64 {
2598
    for name in names {
2599
        if let Some(value) = payload.get(*name).and_then(|value| value.as_f64()) {
2600
            if value.is_finite() && value > 0.0 {
2601
                return (value.floor() as u64).min(ceiling);
2602
            }
2603
        }
2604
    }
2605
    fallback
2606
}
2607
2608
/// Serve until a refusal stops the command, reconnecting on transport loss and
2609
/// on `machine_reconnecting` with bounded backoff.
2610
pub fn serve(
2611
    origin: &str,
2612
    token: &crate::auth::Secret,
2613
    machine_id: &str,
2614
    hello: &serde_json::Value,
2615
    config: &PolicyConfig,
2616
    journal: &Journal,
2617
    mut on_event: impl FnMut(&str),
2618
) -> String {
2619
    let mut attempts: u32 = 0;
2620
    loop {
2621
        let end = serve_connection(
2622
            origin,
2623
            token,
2624
            machine_id,
2625
            hello,
2626
            config,
2627
            journal,
2628
            &mut on_event,
2629
        );
2630
        if !end.retryable || attempts >= MAXIMUM_RECONNECT_ATTEMPTS {
2631
            if end.retryable && attempts > 0 {
2632
                let kind = if end.reason.starts_with("join_refused:machine_reconnecting") {
2633
                    "machine_reconnecting"
2634
                } else {
2635
                    "transport"
2636
                };
2637
                return format!("{kind}_retry_exhausted:{}", end.reason);
2638
            }
2639
            return end.reason;
2640
        }
2641
        attempts += 1;
2642
        let delay = RECONNECT_BACKOFF
2643
            .saturating_mul(1u32 << (attempts - 1))
2644
            .min(MAXIMUM_BACKOFF);
2645
        on_event(&format!("reconnect:{}:{attempts}", end.reason));
2646
        std::thread::sleep(delay);
2647
    }
2648
}
2649
2650
// ---------------------------------------------------------------------------
2651
// the command
2652
// ---------------------------------------------------------------------------
2653
2654
#[derive(Args, Debug)]
2655
pub struct ComputerArgs {
2656
    #[command(subcommand)]
2657
    pub action: ComputerAction,
2658
}
2659
2660
#[derive(Subcommand, Debug)]
2661
pub enum ComputerAction {
2662
    /// Inspect this machine with fixed read-only probes
2663
    Probe {
2664
        #[arg(long, help = "Inspect a declared directory; repeatable")]
2665
        root: Vec<String>,
2666
    },
2667
    /// Show the local tier, declared roots, and curated allowlist
2668
    Policy {
2669
        #[arg(long, help = "Show the policy against these roots; repeatable")]
2670
        root: Vec<String>,
2671
    },
2672
    /// Show local state, pairing state, and file locations
2673
    Status,
2674
    /// Serve bounded Computer requests over an outbound connection
2675
    Up,
2676
    /// Pair this Computer through browser approval and store its machine token
2677
    Pair {
2678
        #[arg(
2679
            long,
2680
            help = "Set the local execution ceiling: probe, curated, or shell"
2681
        )]
2682
        tier: Option<String>,
2683
        #[arg(long, help = "Declare a reachable directory; repeatable")]
2684
        root: Vec<String>,
2685
    },
2686
    /// Remove this Computer's local machine token and pairing state
2687
    Logout,
2688
    /// Show the local record of requests and decisions, including refusals
2689
    Journal {
2690
        #[arg(long, default_value_t = 20, help = "Most entries to show")]
2691
        limit: usize,
2692
    },
2693
}
2694
2695
fn roots_for(config: &PolicyConfig, overrides: &[String]) -> Vec<PathBuf> {
2696
    if overrides.is_empty() {
2697
        config.roots.clone()
2698
    } else {
2699
        resolve_roots(overrides)
2700
    }
2701
}
2702
2703
fn root_list(roots: &[PathBuf]) -> String {
2704
    if roots.is_empty() {
2705
        "(none declared)".to_string()
2706
    } else {
2707
        roots
2708
            .iter()
2709
            .map(|root| root.display().to_string())
2710
            .collect::<Vec<_>>()
2711
            .join(", ")
2712
    }
2713
}
2714
2715
pub async fn run(args: ComputerArgs, endpoint: &crate::auth::Endpoint, json: bool) {
2716
    let fail = crate::cli::fail;
2717
    let paths = ComputerPaths::default_paths();
2718
    let config = match load_config(&paths) {
2719
        Ok(config) => config,
2720
        Err(reason) => fail(&reason),
2721
    };
2722
    let journal = Journal::at(paths.journal.clone());
2723
2724
    match args.action {
2725
        ComputerAction::Probe { root } => {
2726
            let roots = roots_for(&config, &root);
2727
            let report = probe(&roots);
2728
            if json {
2729
                println!(
2730
                    "{}",
2731
                    serde_json::to_string_pretty(&report).unwrap_or_default()
2732
                );
2733
                return;
2734
            }
2735
            println!(
2736
                "Host: {} {} {}",
2737
                report.host.platform, report.host.release, report.host.architecture
2738
            );
2739
            println!("Hostname: {}", report.host.hostname);
2740
            println!("Roots: {}", root_list(&roots));
2741
            println!(
2742
                "Coding agents present: {}/{}",
2743
                report.coding_agents.iter().filter(|t| t.present).count(),
2744
                report.coding_agents.len()
2745
            );
2746
            println!(
2747
                "Toolchains present: {}/{}",
2748
                report.toolchains.iter().filter(|t| t.present).count(),
2749
                report.toolchains.len()
2750
            );
2751
            println!("Worktrees inspected: {}", report.worktrees.len());
2752
        }
2753
        ComputerAction::Policy { root } => {
2754
            let roots = roots_for(&config, &root);
2755
            if json {
2756
                let value = serde_json::json!({
2757
                    "schema": "openagents.computer_policy.v1",
2758
                    "tier": config.tier.label(),
2759
                    "roots": roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>(),
2760
                    "pre_approved": config.pre_approved,
2761
                    "authority": "local_machine",
2762
                    "paths": {
2763
                        "config": paths.config.display().to_string(),
2764
                        "journal": paths.journal.display().to_string(),
2765
                    },
2766
                    "allowlist": format_allowlist(),
2767
                    "scope": "local inspection and policy",
2768
                    "network": false,
2769
                });
2770
                println!(
2771
                    "{}",
2772
                    serde_json::to_string_pretty(&value).unwrap_or_default()
2773
                );
2774
                return;
2775
            }
2776
            println!("Authority: this machine decides what may run.");
2777
            println!("Effective tier: {}", config.tier.label());
2778
            println!("Declared roots: {}", root_list(&roots));
2779
            println!("Empty roots mean that no working directory is reachable.");
2780
            println!("Path rules follow this host's POSIX or Windows semantics.");
2781
            println!("Curated allowlist:");
2782
            for line in format_allowlist() {
2783
                println!("  {line}");
2784
            }
2785
            println!("Configuration: {}", paths.config.display());
2786
            println!("No account, pairing, or network is needed for this command.");
2787
        }
2788
        ComputerAction::Status => {
2789
            let credentials = MachineCredentials::for_origin(&endpoint.origin);
2790
            let stored = match credentials.get() {
2791
                Ok(stored) => stored,
2792
                Err(reason) => fail(&reason),
2793
            };
2794
            // A held token is checked against the server. A transport failure
2795
            // ends the command: reporting "unpaired" because the network was
2796
            // down would be a claim the server never made.
2797
            let remote = match &stored {
2798
                Some(token) => {
2799
                    let client = ComputerClient::new(&endpoint.origin);
2800
                    match client.status(token).await {
2801
                        Ok(status) => status,
2802
                        Err(reason) => fail(&reason),
2803
                    }
2804
                }
2805
                None => None,
2806
            };
2807
            let paired = remote.is_some();
2808
            let state = if paired {
2809
                "paired"
2810
            } else if stored.is_some() {
2811
                "unpaired"
2812
            } else {
2813
                "local"
2814
            };
2815
            if json {
2816
                let value = serde_json::json!({
2817
                    "schema": "openagents.computer_status.v1",
2818
                    "state": state,
2819
                    "paired": paired,
2820
                    "endpoint": endpoint.origin,
2821
                    "tier": config.tier.label(),
2822
                    "roots": config.roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>(),
2823
                    "machine": {
2824
                        "platform": wire_platform(),
2825
                        "architecture": wire_architecture(),
2826
                        "hostname": hostname(),
2827
                    },
2828
                    "paths": {
2829
                        "config": paths.config.display().to_string(),
2830
                        "journal": paths.journal.display().to_string(),
2831
                    },
2832
                    "journal_retention_bytes": JOURNAL_MAX_BYTES,
2833
                    "journal_read_tail_bytes": JOURNAL_READ_TAIL_BYTES,
2834
                    "remote_state": if paired { "active" } else { "unpaired" },
2835
                    "machine_id": remote.as_ref().map(|status| status.machine_id.clone()),
2836
                });
2837
                println!(
2838
                    "{}",
2839
                    serde_json::to_string_pretty(&value).unwrap_or_default()
2840
                );
2841
                return;
2842
            }
2843
            println!("Computer state: {state}");
2844
            println!(
2845
                "Pairing: {}",
2846
                if paired {
2847
                    "paired"
2848
                } else if stored.is_some() {
2849
                    "no longer active; run oa computer logout"
2850
                } else {
2851
                    "not configured"
2852
                }
2853
            );
2854
            println!("Endpoint: {}", endpoint.origin);
2855
            println!("Tier: {}", config.tier.label());
2856
            println!("Roots: {}", root_list(&config.roots));
2857
            println!("Configuration: {}", paths.config.display());
2858
            println!("Journal: {}", paths.journal.display());
2859
            println!(
2860
                "Journal retention: last {JOURNAL_MAX_BYTES} bytes; reads inspect the last \
2861
                 {JOURNAL_READ_TAIL_BYTES} bytes"
2862
            );
2863
            println!("The machine, not the server, decides what runs here.");
2864
            println!("Path rules follow this host's POSIX or Windows semantics.");
2865
            if let Some(status) = &remote {
2866
                println!("Machine id: {}", status.machine_id);
2867
            }
2868
            if stored.is_some() && !paired {
2869
                println!(
2870
                    "The server no longer accepts this machine token; run oa computer logout."
2871
                );
2872
            }
2873
        }
2874
        ComputerAction::Up => {
2875
            let credentials = MachineCredentials::for_origin(&endpoint.origin);
2876
            let Some(token) = (match credentials.get() {
2877
                Ok(stored) => stored,
2878
                Err(reason) => fail(&reason),
2879
            }) else {
2880
                fail(&format!(
2881
                    "this Computer is not paired with {}; run oa computer pair first",
2882
                    endpoint.origin
2883
                ));
2884
            };
2885
            let client = ComputerClient::new(&endpoint.origin);
2886
            let status = match client.status(&token).await {
2887
                Ok(Some(status)) => status,
2888
                Ok(None) => fail(&format!(
2889
                    "this Computer is no longer active on {}; run oa computer logout",
2890
                    endpoint.origin
2891
                )),
2892
                Err(reason) => fail(&reason),
2893
            };
2894
            let initial = probe(&config.roots);
2895
            let hello = serde_json::json!({
2896
                "agent_version": crate::VERSION,
2897
                "tier": config.tier.label(),
2898
                "roots": config.roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>(),
2899
                "platform": format!("{}-{}", wire_platform(), wire_architecture()),
2900
                "probe": serde_json::to_value(&initial).unwrap_or(serde_json::Value::Null),
2901
            });
2902
            let origin = endpoint.origin.clone();
2903
            let machine_id = status.machine_id.clone();
2904
            let reason = tokio::task::spawn_blocking(move || {
2905
                serve(
2906
                    &origin,
2907
                    &token,
2908
                    &machine_id,
2909
                    &hello,
2910
                    &config,
2911
                    &journal,
2912
                    |event| eprintln!("oa computer: {event}"),
2913
                )
2914
            })
2915
            .await
2916
            .unwrap_or_else(|error| format!("error:{error}"));
2917
2918
            // Every ending here is the connection stopping. The command says
2919
            // which, and a refusal or an exhausted retry is a failure.
2920
            if reason.contains("retry_exhausted")
2921
                || reason.starts_with("join_refused:")
2922
                || reason.starts_with("error:")
2923
            {
2924
                fail(&format!("the Computer connection stopped: {reason}"));
2925
            }
2926
            if json {
2927
                println!(
2928
                    "{}",
2929
                    serde_json::to_string_pretty(&serde_json::json!({
2930
                        "schema": "openagents.computer_connection.v1",
2931
                        "state": "closed",
2932
                        "reason": reason,
2933
                    }))
2934
                    .unwrap_or_default()
2935
                );
2936
            } else {
2937
                println!("Computer connection ended: {reason}");
2938
            }
2939
        }
2940
        ComputerAction::Pair { tier, root } => {
2941
            let credentials = MachineCredentials::for_origin(&endpoint.origin);
2942
            match credentials.get() {
2943
                Ok(Some(_)) => fail(&format!(
2944
                    "this Computer is already paired with {}; run oa computer logout before \
2945
                     pairing again",
2946
                    endpoint.origin
2947
                )),
2948
                Ok(None) => {}
2949
                Err(reason) => fail(&reason),
2950
            }
2951
            let selected = match tier.as_deref() {
2952
                None => config.tier,
2953
                Some(value) => match Tier::parse(value) {
2954
                    Some(tier) => tier,
2955
                    None => fail(&format!(
2956
                        "unknown tier {value}. Use probe, curated, or shell"
2957
                    )),
2958
                },
2959
            };
2960
            let roots = roots_for(&config, &root);
2961
            let next = PolicyConfig {
2962
                tier: selected,
2963
                roots: roots.clone(),
2964
                ..config
2965
            };
2966
            if let Err(reason) = write_config(&next) {
2967
                fail(&reason);
2968
            }
2969
            let client = ComputerClient::new(&endpoint.origin);
2970
            let name = hostname();
2971
            let started = match client.start(&name, selected, crate::VERSION, &roots).await {
2972
                Ok(started) => started,
2973
                Err(reason) => fail(&reason),
2974
            };
2975
            println!("Approve this Computer at {}", started.verify_url);
2976
            println!("Pairing code: {}", started.code);
2977
            println!("Waiting for approval...");
2978
            if !json {
2979
                crate::auth::open_browser(&started.verify_url);
2980
            }
2981
            let claim = match client.wait(&started).await {
2982
                Ok(claim) => claim,
2983
                Err(reason) => fail(&reason),
2984
            };
2985
            let token = crate::auth::Secret::new(claim.token.clone());
2986
            if let Err(reason) = credentials.set(&token) {
2987
                fail(&reason);
2988
            }
2989
            if json {
2990
                println!(
2991
                    "{}",
2992
                    serde_json::to_string_pretty(&serde_json::json!({
2993
                        "endpoint": endpoint.origin,
2994
                        "paired": true,
2995
                        "machine_id": claim.machine_id,
2996
                        "name": claim.name,
2997
                        "token_source": "computer_credential_store",
2998
                    }))
2999
                    .unwrap_or_default()
3000
                );
3001
            } else {
3002
                println!("Computer paired with {}.", endpoint.origin);
3003
                println!("Machine id: {}", claim.machine_id);
3004
                println!("The machine token is in the OS credential store.");
3005
            }
3006
        }
3007
        ComputerAction::Logout => {
3008
            let credentials = MachineCredentials::for_origin(&endpoint.origin);
3009
            let removed = match credentials.remove() {
3010
                Ok(removed) => removed,
3011
                Err(reason) => fail(&reason),
3012
            };
3013
            if json {
3014
                println!(
3015
                    "{}",
3016
                    serde_json::to_string_pretty(&serde_json::json!({
3017
                        "endpoint": endpoint.origin,
3018
                        "removed": removed,
3019
                        "remote_state": "unverified",
3020
                    }))
3021
                    .unwrap_or_default()
3022
                );
3023
            } else {
3024
                println!(
3025
                    "Removed the local Computer pairing for {}.",
3026
                    endpoint.origin
3027
                );
3028
                println!("No local machine token remains. Remote pairing state is not queried.");
3029
            }
3030
        }
3031
        ComputerAction::Journal { limit } => {
3032
            let entries = match journal.read(limit) {
3033
                Ok(entries) => entries,
3034
                Err(reason) => fail(&reason),
3035
            };
3036
            if json {
3037
                println!(
3038
                    "{}",
3039
                    serde_json::to_string_pretty(&serde_json::json!({
3040
                        "schema": "openagents.computer_journal.v1",
3041
                        "entries": entries,
3042
                    }))
3043
                    .unwrap_or_default()
3044
                );
3045
                return;
3046
            }
3047
            if entries.is_empty() {
3048
                println!("No local Computer requests are recorded.");
3049
                return;
3050
            }
3051
            for entry in entries {
3052
                println!(
3053
                    "{} {}/{} {} {}",
3054
                    entry.at,
3055
                    entry.decision,
3056
                    entry.outcome,
3057
                    entry.request_id,
3058
                    entry.argv.join(" ")
3059
                );
3060
            }
3061
        }
39 3062
    }
40 3063
}
crates/openagents-cli/tests/cli_test.rs modified +54 -5

@@ -11,10 +11,12 @@ mod tests {

11 11
    use openagents_cli::tracker::{slug_from_remote_url, IssueListOptions, RepoTarget, TrackerClient};
12 12
    use openagents_cli::repo::{admitted_credential_request, parse_git_credential_request};
13 13
    use openagents_cli::box_client::BoxClient;
14
    use openagents_cli::computer::probe_host;
14
    use openagents_cli::computer::{
15
        decide, probe_host, CommandRequest, ComputerPaths, PolicyConfig, Tier,
16
    };
15 17
    use openagents_cli::forum::ForumClient;
16 18
    use openagents_cli::memory_client::{read_bucket, MemoryClient};
17
    use openagents_cli::api_passthrough::ApiPassthroughClient;
19
    use openagents_cli::api_passthrough::{resolve_api_path, ApiPassthroughClient};
18 20
    use openagents_cli::trace::{default_trace_stores, redact_text};
19 21
20 22
    /// The old assertion read `store.load().unwrap().default_profile.is_some()`,

@@ -225,10 +227,26 @@ mod tests {

225 227
        );
226 228
    }
227 229
230
    /// The old assertion was `probe.num_cpus > 0` against a four-field struct,
231
    /// beside a policy of three unconditional `true`s. The policy contract and
232
    /// the full probe live in `tests/computer_api_test.rs`; this keeps the
233
    /// closed default here, because it is the whole point of the subsystem.
228 234
    #[test]
229 235
    fn test_computer_probe_issue_79() {
230 236
        let probe = probe_host();
231 237
        assert!(probe.num_cpus > 0);
238
239
        // The default policy reaches nothing: no root is declared, so no
240
        // working directory is reachable and no command is permitted.
241
        let directory = tempfile::tempdir().unwrap();
242
        let config = PolicyConfig::closed(ComputerPaths::in_directory(directory.path()));
243
        assert_eq!(config.tier, Tier::Probe);
244
        assert!(config.roots.is_empty());
245
        let request = CommandRequest {
246
            argv: vec!["git".to_string(), "status".to_string()],
247
            cwd: directory.path().display().to_string(),
248
        };
249
        assert!(!decide(&request, &config).allowed());
232 250
    }
233 251
234 252
    /// The old assertion was `!boards.is_empty()`, and it passed *because of* the

@@ -265,11 +283,42 @@ mod tests {

265 283
        );
266 284
    }
267 285
286
    /// The old assertion was `res.is_object()` against `execute_request("GET",
287
    /// "status", None)`. It held for any JSON object, including the
288
    /// `{"status": N}` stub the client returned for every request it could not
289
    /// parse — so it passed while `/api/v1/user` 404ed. These name the field
290
    /// the route returns, and assert that a refused route is an error.
268 291
    #[tokio::test]
269 292
    async fn test_api_passthrough_issue_81() {
270
        let client = ApiPassthroughClient::new("https://openagents.com/api/v1", None);
271
        let res = client.execute_request("GET", "status", None).await.unwrap();
272
        assert!(res.is_object());
293
        let client = ApiPassthroughClient::new("https://openagents.com", None);
294
295
        // Both spellings of the same route resolve to it.
296
        assert_eq!(
297
            resolve_api_path("https://openagents.com", "/api/v1/user").unwrap(),
298
            "/api/v1/user"
299
        );
300
        assert_eq!(
301
            resolve_api_path("https://openagents.com", "user").unwrap(),
302
            "/api/v1/user"
303
        );
304
305
        let value = client
306
            .execute_request("GET", "repos/OpenAgentsInc/openagents", None)
307
            .await
308
            .unwrap();
309
        assert_eq!(
310
            value.get("full_name").and_then(|v| v.as_str()),
311
            Some("OpenAgentsInc/openagents")
312
        );
313
314
        let refused = client
315
            .execute_request("GET", "repos/OpenAgentsInc/no-such-repository-here", None)
316
            .await;
317
        assert!(
318
            refused.is_err(),
319
            "a 404 must not read as a value, got {:?}",
320
            refused.ok()
321
        );
273 322
    }
274 323
275 324
    /// The old assertions were `sessions.len() == 2` against two source literals,
crates/openagents-cli/tests/computer_api_test.rs added +1506

@@ -0,0 +1,1506 @@

1
//! The Computer subsystem (issue 79) and the API passthrough (issue 81).
2
//!
3
//! Two shapes of assertion are deliberately absent here. Nothing asserts
4
//! `x.is_empty() || !x.is_empty()`, and nothing asserts that a passthrough
5
//! response "is an object" — an error envelope is an object too, so that
6
//! assertion passed while every refused request returned a `{"status": N}`
7
//! stub. Each test below names the field the route actually returns, or the
8
//! refusal a closed policy actually produces.
9
10
use std::io::{Read, Write};
11
use std::net::TcpListener;
12
use std::path::PathBuf;
13
use std::sync::mpsc::{channel, Receiver};
14
use std::time::Duration;
15
16
use openagents_cli::api_passthrough::{
17
    admitted_method, api_error_details, decode_request_body, parse_request_fields,
18
    parse_request_headers, resolve_api_path, resolve_request_method, ApiPassthroughClient,
19
};
20
use openagents_cli::computer::{
21
    curated_allowlist, decide, execute_command, format_allowlist, gh_read_only_allowed,
22
    load_config, probe, probe_host, redact, serve, tier_allows, within_root, Cancellation,
23
    CommandRequest, ComputerClient, ComputerPaths, Decision, ExecutionLimits, Journal,
24
    MachineCredentials, PolicyConfig, RefusalReason, Tier,
25
};
26
27
// ---------------------------------------------------------------------------
28
// policy
29
// ---------------------------------------------------------------------------
30
31
fn config_at(directory: &std::path::Path, tier: Tier, roots: Vec<PathBuf>) -> PolicyConfig {
32
    PolicyConfig {
33
        tier,
34
        roots,
35
        ..PolicyConfig::closed(ComputerPaths::in_directory(directory))
36
    }
37
}
38
39
fn refusal(decision: &Decision) -> RefusalReason {
40
    match decision {
41
        Decision::Refused { reason, .. } => *reason,
42
        Decision::Allowed { .. } => panic!("expected a refusal, the command was allowed"),
43
    }
44
}
45
46
fn request(argv: &[&str], cwd: &std::path::Path) -> CommandRequest {
47
    CommandRequest {
48
        argv: argv.iter().map(|value| value.to_string()).collect(),
49
        cwd: cwd.display().to_string(),
50
    }
51
}
52
53
/// The policy this replaces was three unconditional `true`s, so it permitted
54
/// everything it was ever asked. The default now reaches nothing: no root is
55
/// declared, so no working directory is reachable, whatever the command is.
56
#[test]
57
fn test_default_policy_reaches_nothing() {
58
    let directory = tempfile::tempdir().unwrap();
59
    let config = PolicyConfig::closed(ComputerPaths::in_directory(directory.path()));
60
61
    assert_eq!(config.tier, Tier::Probe);
62
    assert!(config.roots.is_empty());
63
    assert_eq!(
64
        refusal(&decide(
65
            &request(&["git", "status"], directory.path()),
66
            &config
67
        )),
68
        RefusalReason::RootNotDeclared
69
    );
70
    assert_eq!(
71
        refusal(&decide(&request(&["ls"], directory.path()), &config)),
72
        RefusalReason::RootNotDeclared
73
    );
74
}
75
76
/// The allowlist the policy command prints. The first and last lines, the
77
/// count, and `git`'s options are the contract the TypeScript CLI publishes.
78
#[test]
79
fn test_curated_allowlist_is_the_published_one() {
80
    let lines = format_allowlist();
81
    assert_eq!(lines.len(), curated_allowlist().len() + 1);
82
    assert_eq!(
83
        lines[0],
84
        "git: status, log, diff, branch, remote, show, rev-parse, ls-files, --version"
85
    );
86
    assert_eq!(
87
        lines[1],
88
        "uname: no options; path arguments inside declared roots"
89
    );
90
    assert_eq!(lines[lines.len() - 1], "gh: read-only queries only");
91
    assert!(lines.iter().any(|line| line == "npm: --version, ls"));
92
    assert!(lines
93
        .iter()
94
        .any(|line| line == "docker: ps, images, version"));
95
}
96
97
/// A declared root is not enough on its own. The probe tier is fixed discovery,
98
/// so even `ls` inside the root is refused until the owner raises the ceiling.
99
#[test]
100
fn test_probe_tier_refuses_a_curated_command_inside_a_declared_root() {
101
    let directory = tempfile::tempdir().unwrap();
102
    let root = directory.path().to_path_buf();
103
    let config = config_at(directory.path(), Tier::Probe, vec![root.clone()]);
104
    assert_eq!(
105
        refusal(&decide(&request(&["ls"], &root), &config)),
106
        RefusalReason::TierInsufficient
107
    );
108
    assert!(tier_allows(Tier::Curated, Tier::Probe));
109
    assert!(!tier_allows(Tier::Probe, Tier::Curated));
110
    assert!(tier_allows(Tier::Shell, Tier::Curated));
111
}
112
113
/// The curated tier is where the allowlist decides. Everything here is a
114
/// refusal the flat-`true` policy could not have produced.
115
#[test]
116
fn test_curated_tier_allows_the_allowlist_and_refuses_the_rest() {
117
    let directory = tempfile::tempdir().unwrap();
118
    let root = directory.path().to_path_buf();
119
    let config = config_at(directory.path(), Tier::Curated, vec![root.clone()]);
120
121
    assert!(decide(&request(&["git", "status"], &root), &config).allowed());
122
    assert!(decide(&request(&["git", "--version"], &root), &config).allowed());
123
    assert!(decide(&request(&["node", "--version"], &root), &config).allowed());
124
125
    // Not on the list at all.
126
    assert_eq!(
127
        refusal(&decide(
128
            &request(&["curl", "https://example.com"], &root),
129
            &config
130
        )),
131
        RefusalReason::NotAllowlisted
132
    );
133
    // On the list, but not with this subcommand: `git push` writes.
134
    assert_eq!(
135
        refusal(&decide(&request(&["git", "push"], &root), &config)),
136
        RefusalReason::NotAllowlisted
137
    );
138
    // On the list, but not with this option.
139
    assert_eq!(
140
        refusal(&decide(&request(&["node", "-e", "1"], &root), &config)),
141
        RefusalReason::NotAllowlisted
142
    );
143
    // Denied outright, and denied before the tier is consulted.
144
    assert_eq!(
145
        refusal(&decide(&request(&["sudo", "ls"], &root), &config)),
146
        RefusalReason::DeniedCommand
147
    );
148
    // A protected path, named anywhere in the argument vector.
149
    assert_eq!(
150
        refusal(&decide(&request(&["cat", "~/.ssh/id_rsa"], &root), &config)),
151
        RefusalReason::DeniedArgument
152
    );
153
    // Shell metacharacters never reach a shell, because there is no shell.
154
    assert_eq!(
155
        refusal(&decide(&request(&["ls", "; rm -rf /"], &root), &config)),
156
        RefusalReason::ShellMetacharacter
157
    );
158
    // A path argument that climbs out of every declared root.
159
    assert_eq!(
160
        refusal(&decide(
161
            &request(&["cat", "../../etc/hosts"], &root),
162
            &config
163
        )),
164
        RefusalReason::DeniedArgument
165
    );
166
    // A working directory outside every declared root.
167
    assert_eq!(
168
        refusal(&decide(
169
            &CommandRequest {
170
                argv: vec!["git".to_string(), "status".to_string()],
171
                cwd: "/tmp".to_string(),
172
            },
173
            &config
174
        )),
175
        RefusalReason::RootNotDeclared
176
    );
177
}
178
179
/// A denied binary stays denied at the top tier. Raising the ceiling widens
180
/// what the allowlist admits; it never unlocks `sudo`.
181
#[test]
182
fn test_shell_tier_still_refuses_denied_commands_and_asks_before_the_rest() {
183
    let directory = tempfile::tempdir().unwrap();
184
    let root = directory.path().to_path_buf();
185
    let mut config = config_at(directory.path(), Tier::Shell, vec![root.clone()]);
186
187
    assert_eq!(
188
        refusal(&decide(&request(&["sudo", "ls"], &root), &config)),
189
        RefusalReason::DeniedCommand
190
    );
191
    assert_eq!(
192
        decide(&request(&["make", "build"], &root), &config),
193
        Decision::Allowed {
194
            needs_confirmation: true
195
        }
196
    );
197
    config.pre_approved = vec!["make".to_string()];
198
    assert_eq!(
199
        decide(&request(&["make", "build"], &root), &config),
200
        Decision::Allowed {
201
            needs_confirmation: false
202
        }
203
    );
204
}
205
206
#[test]
207
fn test_gh_is_read_only() {
208
    let read = |args: &[&str]| {
209
        gh_read_only_allowed(&args.iter().map(|v| v.to_string()).collect::<Vec<_>>())
210
    };
211
    assert!(read(&["pr", "list"]));
212
    assert!(read(&["issue", "view"]));
213
    assert!(read(&["status"]));
214
    assert!(!read(&["pr", "merge"]));
215
    assert!(!read(&["api", "/user"]));
216
    assert!(!read(&["issue", "list", "--field", "x"]));
217
    assert!(!read(&[]));
218
}
219
220
#[test]
221
fn test_root_containment_is_lexical_and_does_not_admit_a_sibling() {
222
    let directory = tempfile::tempdir().unwrap();
223
    let root = directory.path().join("work");
224
    std::fs::create_dir_all(root.join("inner")).unwrap();
225
    assert!(within_root(&root, &root));
226
    assert!(within_root(&root.join("inner"), &root));
227
    assert!(!within_root(&directory.path().join("work-other"), &root));
228
    assert!(!within_root(&directory.path().join("elsewhere"), &root));
229
}
230
231
/// A configuration file that cannot be decoded is refused. Falling back to the
232
/// default would silently replace the owner's policy with a different one.
233
#[test]
234
fn test_unreadable_configuration_is_refused_rather_than_defaulted() {
235
    let directory = tempfile::tempdir().unwrap();
236
    let paths = ComputerPaths::in_directory(directory.path());
237
238
    // Missing is a real answer: nothing is declared.
239
    let closed = load_config(&paths).expect("a missing file is the closed default");
240
    assert_eq!(closed.tier, Tier::Probe);
241
    assert!(closed.roots.is_empty());
242
243
    std::fs::write(&paths.config, "{ not json").unwrap();
244
    let refused = load_config(&paths);
245
    assert!(
246
        refused.is_err(),
247
        "invalid JSON must not read as the default"
248
    );
249
250
    std::fs::write(&paths.config, r#"{"tier":"root"}"#).unwrap();
251
    let refused = load_config(&paths).unwrap_err();
252
    assert!(
253
        refused.contains("unknown tier"),
254
        "the refusal must name the problem: {refused}"
255
    );
256
257
    std::fs::write(
258
        &paths.config,
259
        r#"{"tier":"curated","roots":["/tmp/declared"],"pre_approved":["make"]}"#,
260
    )
261
    .unwrap();
262
    let read = load_config(&paths).unwrap();
263
    assert_eq!(read.tier, Tier::Curated);
264
    assert_eq!(read.roots, vec![PathBuf::from("/tmp/declared")]);
265
    assert_eq!(read.pre_approved, vec!["make".to_string()]);
266
}
267
268
// ---------------------------------------------------------------------------
269
// journal
270
// ---------------------------------------------------------------------------
271
272
#[test]
273
fn test_journal_records_a_refusal_and_redacts_credentials() {
274
    let directory = tempfile::tempdir().unwrap();
275
    let journal = Journal::at(directory.path().join("journal.ndjson"));
276
277
    assert!(
278
        journal.read(20).unwrap().is_empty(),
279
        "a machine that has been asked nothing has an empty journal"
280
    );
281
282
    let refused = CommandRequest {
283
        argv: vec!["curl".to_string(), "https://example.com".to_string()],
284
        cwd: "/declared/root".to_string(),
285
    };
286
    journal
287
        .append(
288
            "req-1",
289
            &refused,
290
            "not_allowlisted",
291
            "refused",
292
            "curl is not in the curated allowlist",
293
        )
294
        .unwrap();
295
    journal
296
        .append(
297
            "req-2",
298
            &CommandRequest {
299
                argv: vec!["echo".to_string(), "oa_pat_ABCDEF123456".to_string()],
300
                cwd: "/declared/root".to_string(),
301
            },
302
            "allowed",
303
            "completed",
304
            "Authorization: Bearer oa_pat_ABCDEF123456",
305
        )
306
        .unwrap();
307
308
    let entries = journal.read(20).unwrap();
309
    assert_eq!(entries.len(), 2);
310
    assert_eq!(entries[0].request_id, "req-1");
311
    assert_eq!(entries[0].decision, "not_allowlisted");
312
    assert_eq!(entries[0].outcome, "refused");
313
    assert_eq!(entries[0].argv, vec!["curl", "https://example.com"]);
314
    assert_eq!(entries[0].detail, "curl is not in the curated allowlist");
315
316
    let redacted = &entries[1];
317
    assert!(
318
        !redacted.detail.contains("oa_pat_ABCDEF123456"),
319
        "the token survived the journal: {}",
320
        redacted.detail
321
    );
322
    assert!(!redacted.argv[1].contains("ABCDEF123456"));
323
    assert!(redacted.detail.contains("[REDACTED]"));
324
325
    // Only the tail is returned, newest last.
326
    let tail = journal.read(1).unwrap();
327
    assert_eq!(tail.len(), 1);
328
    assert_eq!(tail[0].request_id, "req-2");
329
330
    // And the file stays private to the owner.
331
    #[cfg(unix)]
332
    {
333
        use std::os::unix::fs::PermissionsExt;
334
        let mode = std::fs::metadata(journal.path())
335
            .unwrap()
336
            .permissions()
337
            .mode();
338
        assert_eq!(mode & 0o777, 0o600);
339
    }
340
}
341
342
#[test]
343
fn test_journal_read_of_an_unreadable_file_is_an_error_not_an_empty_list() {
344
    let directory = tempfile::tempdir().unwrap();
345
    // A directory where the journal file should be: it exists and cannot be
346
    // read as a file, which must not read back as "nothing was asked".
347
    let path = directory.path().join("journal.ndjson");
348
    std::fs::create_dir(&path).unwrap();
349
    let journal = Journal::at(path);
350
    assert!(journal.read(20).is_err());
351
}
352
353
#[test]
354
fn test_redaction_removes_the_token_body_not_only_the_prefix() {
355
    let redacted = redact("Bearer oa_pat_998877_TOKENBODY and smct_MACHINEBODY");
356
    assert!(!redacted.contains("TOKENBODY"));
357
    assert!(!redacted.contains("MACHINEBODY"));
358
    assert!(!redacted.contains("998877"));
359
}
360
361
// ---------------------------------------------------------------------------
362
// executor
363
// ---------------------------------------------------------------------------
364
365
#[test]
366
fn test_executor_streams_bounded_scrubbed_output() {
367
    let directory = tempfile::tempdir().unwrap();
368
    let cancellation = Cancellation::default();
369
    let mut seen = String::new();
370
    let outcome = execute_command(
371
        &[
372
            "/bin/echo".to_string(),
373
            "token oa_pat_LEAKEDSECRET here".to_string(),
374
        ],
375
        &directory.path().display().to_string(),
376
        ExecutionLimits::default(),
377
        &cancellation,
378
        |chunk| seen.push_str(chunk),
379
    );
380
    assert_eq!(outcome.exit_code, Some(0));
381
    assert!(!outcome.timed_out);
382
    assert!(seen.contains("token"));
383
    assert!(
384
        !seen.contains("LEAKEDSECRET"),
385
        "a token printed by the command reached the caller: {seen}"
386
    );
387
}
388
389
#[test]
390
fn test_executor_truncates_at_the_output_ceiling() {
391
    let directory = tempfile::tempdir().unwrap();
392
    let cancellation = Cancellation::default();
393
    let mut bytes = 0usize;
394
    let outcome = execute_command(
395
        &[
396
            "/usr/bin/head".to_string(),
397
            "-c".to_string(),
398
            "8192".to_string(),
399
            "/dev/zero".to_string(),
400
        ],
401
        &directory.path().display().to_string(),
402
        ExecutionLimits {
403
            timeout: Duration::from_secs(10),
404
            maximum_output_bytes: 64,
405
        },
406
        &cancellation,
407
        |chunk| bytes += chunk.len(),
408
    );
409
    assert!(bytes <= 64, "the output ceiling was crossed: {bytes} bytes");
410
    assert!(outcome.truncated, "a truncated run must say so");
411
}
412
413
#[test]
414
fn test_executor_stops_a_command_that_outlives_its_timeout() {
415
    let directory = tempfile::tempdir().unwrap();
416
    let cancellation = Cancellation::default();
417
    let outcome = execute_command(
418
        &["/bin/sleep".to_string(), "30".to_string()],
419
        &directory.path().display().to_string(),
420
        ExecutionLimits {
421
            timeout: Duration::from_millis(300),
422
            maximum_output_bytes: 1024,
423
        },
424
        &cancellation,
425
        |_| {},
426
    );
427
    assert!(outcome.timed_out, "the command outlived its timeout");
428
    assert!(outcome.duration_ms < 10_000);
429
}
430
431
#[test]
432
fn test_executor_reports_a_missing_binary_rather_than_succeeding() {
433
    let directory = tempfile::tempdir().unwrap();
434
    let outcome = execute_command(
435
        &["/nonexistent/binary-that-is-not-here".to_string()],
436
        &directory.path().display().to_string(),
437
        ExecutionLimits::default(),
438
        &Cancellation::default(),
439
        |_| {},
440
    );
441
    assert_eq!(outcome.exit_code, Some(127));
442
}
443
444
// ---------------------------------------------------------------------------
445
// probe
446
// ---------------------------------------------------------------------------
447
448
#[test]
449
fn test_probe_reports_this_host_and_the_roots_it_was_given() {
450
    let directory = tempfile::tempdir().unwrap();
451
    std::fs::create_dir_all(directory.path().join("checkout")).unwrap();
452
    let roots = vec![directory.path().join("checkout")];
453
    let report = probe(&roots);
454
455
    assert_eq!(report.schema, "openagents.computer_probe.v1");
456
    assert!(report.host.cpu_count > 0);
457
    assert!(report.host.total_memory_bytes > 0);
458
    assert!(
459
        !report.host.hostname.is_empty(),
460
        "the probe names this host"
461
    );
462
    assert!(
463
        !report.host.release.is_empty(),
464
        "the probe reads the kernel release"
465
    );
466
    assert_eq!(report.coding_agents.len(), 11);
467
    assert_eq!(report.toolchains.len(), 14);
468
    assert_eq!(report.roots.len(), 1);
469
    assert_eq!(report.worktrees.len(), 1);
470
    assert!(report.worktrees[0].exists);
471
    assert!(!report.worktrees[0].git);
472
473
    // `git` is on this machine, and the probe reports its real path and version
474
    // rather than only a boolean.
475
    let git = report
476
        .toolchains
477
        .iter()
478
        .find(|tool| tool.name == "git")
479
        .unwrap();
480
    assert!(git.present);
481
    assert!(
482
        git.path.contains("git"),
483
        "the probe resolves a path: {}",
484
        git.path
485
    );
486
    assert!(
487
        git.version.starts_with("git version"),
488
        "the probe reads a version: {}",
489
        git.version
490
    );
491
492
    // The narrower host summary agrees with the full report.
493
    assert_eq!(probe_host().num_cpus, report.host.cpu_count);
494
}
495
496
// ---------------------------------------------------------------------------
497
// machine credential
498
// ---------------------------------------------------------------------------
499
500
#[test]
501
fn test_machine_credentials_round_trip_and_stay_separate_per_origin() {
502
    let directory = tempfile::tempdir().unwrap();
503
    let production = MachineCredentials::isolated("https://openagents.com", directory.path());
504
    let staging = MachineCredentials::isolated("https://staging.openagents.com", directory.path());
505
506
    assert!(production.get().unwrap().is_none());
507
    production
508
        .set(&openagents_cli::auth::Secret::new("smct_machine_token"))
509
        .unwrap();
510
    assert_eq!(
511
        production.get().unwrap().unwrap().expose(),
512
        "smct_machine_token"
513
    );
514
    assert!(
515
        staging.get().unwrap().is_none(),
516
        "a machine paired with production is not offered to staging"
517
    );
518
519
    assert!(production.remove().unwrap());
520
    assert!(production.get().unwrap().is_none());
521
    assert!(!production.remove().unwrap());
522
}
523
524
// ---------------------------------------------------------------------------
525
// the controller client
526
// ---------------------------------------------------------------------------
527
528
/// A status read that the server refuses is an error. It is never reported as
529
/// "not paired", which would send the owner to `computer pair` for a problem
530
/// that has nothing to do with pairing.
531
#[tokio::test]
532
async fn test_computer_status_refuses_rather_than_reporting_unpaired() {
533
    let client = ComputerClient::new("https://openagents.com/no-such-surface");
534
    let result = client
535
        .status(&openagents_cli::auth::Secret::new("smct_not_a_real_token"))
536
        .await;
537
    let refusal = result
538
        .expect_err("a 404 is not an unpaired machine")
539
        .to_string();
540
    assert!(
541
        refusal.contains("could not read this Computer status"),
542
        "the refusal must name what failed: {refusal}"
543
    );
544
}
545
546
/// The live controller surface answers a machine token it does not know with a
547
/// 401, and only that is reported as "no longer active".
548
#[tokio::test]
549
async fn test_live_controller_reports_an_unknown_machine_token_as_inactive() {
550
    let client = ComputerClient::new("https://openagents.com");
551
    let status = client
552
        .status(&openagents_cli::auth::Secret::new("smct_not_a_real_token"))
553
        .await
554
        .expect("the live controller answers");
555
    assert!(
556
        status.is_none(),
557
        "an unknown machine token is not an active machine"
558
    );
559
}
560
561
// ---------------------------------------------------------------------------
562
// the outbound channel
563
// ---------------------------------------------------------------------------
564
565
/// A Phoenix-shaped controller socket, so the client's join, hello, framing,
566
/// policy, journal, and exit reporting all run against a live peer.
567
struct StubController {
568
    origin: String,
569
    frames: Receiver<serde_json::Value>,
570
}
571
572
fn start_stub_controller(machine_id: &str, run_payload: serde_json::Value) -> StubController {
573
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
574
    let port = listener.local_addr().unwrap().port();
575
    let (sender, frames) = channel();
576
    let topic = format!("computer:{machine_id}");
577
578
    std::thread::spawn(move || {
579
        let Ok((stream, _)) = listener.accept() else {
580
            return;
581
        };
582
        let Ok(mut socket) = tungstenite::accept(stream) else {
583
            return;
584
        };
585
        // phx_join, then the reply the client waits for before it sends hello.
586
        let _ = socket.read();
587
        let reply =
588
            serde_json::json!(["1", "1", topic, "phx_reply", {"status": "ok", "response": {}}]);
589
        let _ = socket.send(tungstenite::Message::Text(reply.to_string().into()));
590
        // hello
591
        let _ = socket.read();
592
        let ask = serde_json::json!([serde_json::Value::Null, "9", topic, "run", run_payload]);
593
        let _ = socket.send(tungstenite::Message::Text(ask.to_string().into()));
594
595
        let deadline = std::time::Instant::now() + Duration::from_secs(20);
596
        while std::time::Instant::now() < deadline {
597
            match socket.read() {
598
                Ok(tungstenite::Message::Text(text)) => {
599
                    if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
600
                        let terminal = value
601
                            .get(3)
602
                            .and_then(|event| event.as_str())
603
                            .map(|event| event == "refused" || event == "exit")
604
                            .unwrap_or(false);
605
                        if sender.send(value).is_err() {
606
                            return;
607
                        }
608
                        if terminal {
609
                            break;
610
                        }
611
                    }
612
                }
613
                Ok(_) => {}
614
                Err(_) => break,
615
            }
616
        }
617
        let _ = socket.close(None);
618
        // Drain the close handshake so the client sees a clean end.
619
        while socket.read().is_ok() {}
620
    });
621
622
    StubController {
623
        origin: format!("http://127.0.0.1:{port}"),
624
        frames,
625
    }
626
}
627
628
fn next_frame(frames: &Receiver<serde_json::Value>, event: &str) -> serde_json::Value {
629
    let deadline = std::time::Instant::now() + Duration::from_secs(25);
630
    while std::time::Instant::now() < deadline {
631
        match frames.recv_timeout(Duration::from_secs(25)) {
632
            Ok(frame) => {
633
                if frame.get(3).and_then(|value| value.as_str()) == Some(event) {
634
                    return frame.get(4).cloned().unwrap_or(serde_json::Value::Null);
635
                }
636
            }
637
            Err(_) => break,
638
        }
639
    }
640
    panic!("the client never sent a {event} frame");
641
}
642
643
/// A command the allowlist does not carry is refused over the wire, and the
644
/// refusal lands in the local journal with the reason that produced it. The
645
/// journal never leaves this machine, so this is the only place the owner can
646
/// read what was asked of it.
647
#[test]
648
fn test_up_refuses_a_command_outside_the_allowlist_and_journals_it() {
649
    let directory = tempfile::tempdir().unwrap();
650
    let root = directory.path().join("checkout");
651
    std::fs::create_dir_all(&root).unwrap();
652
    let config = config_at(directory.path(), Tier::Curated, vec![root.clone()]);
653
    let journal = Journal::at(directory.path().join("journal.ndjson"));
654
655
    let stub = start_stub_controller(
656
        "machine-1",
657
        serde_json::json!({
658
            "request_id": "req-refused",
659
            "argv": ["curl", "https://example.com"],
660
            "cwd": root.display().to_string(),
661
        }),
662
    );
663
664
    let reason = serve(
665
        &stub.origin,
666
        &openagents_cli::auth::Secret::new("smct_stub"),
667
        "machine-1",
668
        &serde_json::json!({"agent_version": "test"}),
669
        &config,
670
        &journal,
671
        |_| {},
672
    );
673
674
    let refused = next_frame(&stub.frames, "refused");
675
    assert_eq!(
676
        refused.get("reason").and_then(|v| v.as_str()),
677
        Some("not_allowlisted"),
678
        "the server was told why: {refused}"
679
    );
680
    assert_eq!(
681
        refused.get("request_id").and_then(|v| v.as_str()),
682
        Some("req-refused")
683
    );
684
    assert!(refused
685
        .get("detail")
686
        .and_then(|v| v.as_str())
687
        .unwrap_or_default()
688
        .contains("curated allowlist"));
689
    // The stub serves one connection and then stops listening, so the client
690
    // sees the peer close, retries within its bound, and reports that it ran
691
    // out of retries rather than claiming a clean shutdown.
692
    assert!(
693
        reason.starts_with("transport_retry_exhausted:"),
694
        "the ending names what happened: {reason}"
695
    );
696
697
    let entries = journal.read(50).unwrap();
698
    let recorded = entries
699
        .iter()
700
        .find(|entry| entry.request_id == "req-refused" && entry.outcome == "refused")
701
        .expect("the refusal is in the local journal");
702
    assert_eq!(recorded.decision, "not_allowlisted");
703
    assert_eq!(recorded.argv, vec!["curl", "https://example.com"]);
704
    assert_eq!(recorded.cwd, root.display().to_string());
705
    eprintln!(
706
        "journal entry: {}",
707
        serde_json::to_string(recorded).unwrap()
708
    );
709
}
710
711
/// An allowed command runs, streams its real output, and reports a real exit.
712
#[test]
713
fn test_up_serves_a_bounded_allowed_request() {
714
    let directory = tempfile::tempdir().unwrap();
715
    let root = directory.path().join("checkout");
716
    std::fs::create_dir_all(&root).unwrap();
717
    let config = config_at(directory.path(), Tier::Curated, vec![root.clone()]);
718
    let journal = Journal::at(directory.path().join("journal.ndjson"));
719
720
    let stub = start_stub_controller(
721
        "machine-2",
722
        serde_json::json!({
723
            "request_id": "req-allowed",
724
            "argv": ["git", "--version"],
725
            "cwd": root.display().to_string(),
726
        }),
727
    );
728
729
    serve(
730
        &stub.origin,
731
        &openagents_cli::auth::Secret::new("smct_stub"),
732
        "machine-2",
733
        &serde_json::json!({"agent_version": "test"}),
734
        &config,
735
        &journal,
736
        |_| {},
737
    );
738
739
    let mut chunks = String::new();
740
    let deadline = std::time::Instant::now() + Duration::from_secs(25);
741
    let exit = loop {
742
        assert!(
743
            std::time::Instant::now() < deadline,
744
            "no exit frame arrived"
745
        );
746
        let frame = stub
747
            .frames
748
            .recv_timeout(Duration::from_secs(25))
749
            .expect("the client sends chunk and exit frames");
750
        match frame.get(3).and_then(|value| value.as_str()) {
751
            Some("chunk") => chunks.push_str(
752
                frame
753
                    .get(4)
754
                    .and_then(|payload| payload.get("text"))
755
                    .and_then(|text| text.as_str())
756
                    .unwrap_or_default(),
757
            ),
758
            Some("exit") => break frame.get(4).cloned().unwrap(),
759
            Some("refused") => panic!("git --version was refused: {frame}"),
760
            _ => {}
761
        }
762
    };
763
764
    assert!(
765
        chunks.contains("git version"),
766
        "the command's own output reached the server: {chunks:?}"
767
    );
768
    assert_eq!(
769
        exit.get("status").and_then(|v| v.as_str()),
770
        Some("completed")
771
    );
772
    assert_eq!(exit.get("exit_code").and_then(|v| v.as_i64()), Some(0));
773
    assert_eq!(exit.get("timed_out").and_then(|v| v.as_bool()), Some(false));
774
775
    let entries = journal.read(50).unwrap();
776
    assert!(entries
777
        .iter()
778
        .any(|entry| entry.request_id == "req-allowed" && entry.outcome == "completed"));
779
}
780
781
/// Transport loss retries with bounded backoff and then stops. It does not
782
/// reconnect forever, and it says the retries were exhausted rather than
783
/// reporting a clean close.
784
#[test]
785
fn test_up_retries_transport_loss_with_bounded_backoff() {
786
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
787
    let port = listener.local_addr().unwrap().port();
788
    let (counted, attempts) = channel();
789
    std::thread::spawn(move || {
790
        for stream in listener.incoming() {
791
            let Ok(stream) = stream else { return };
792
            // Accept the connection and drop it before the handshake completes.
793
            drop(stream);
794
            if counted.send(()).is_err() {
795
                return;
796
            }
797
        }
798
    });
799
800
    let directory = tempfile::tempdir().unwrap();
801
    let config = PolicyConfig::closed(ComputerPaths::in_directory(directory.path()));
802
    let journal = Journal::at(directory.path().join("journal.ndjson"));
803
    let mut events = Vec::new();
804
805
    let started = std::time::Instant::now();
806
    let reason = serve(
807
        &format!("http://127.0.0.1:{port}"),
808
        &openagents_cli::auth::Secret::new("smct_stub"),
809
        "machine-3",
810
        &serde_json::json!({}),
811
        &config,
812
        &journal,
813
        |event| events.push(event.to_string()),
814
    );
815
    let elapsed = started.elapsed();
816
817
    assert!(
818
        reason.starts_with("transport_retry_exhausted:"),
819
        "a bounded retry ends by saying so: {reason}"
820
    );
821
    assert_eq!(
822
        events.len(),
823
        3,
824
        "three bounded reconnects, not an unbounded loop: {events:?}"
825
    );
826
    assert!(events[0].starts_with("reconnect:"));
827
828
    let mut connections = 0;
829
    while attempts.try_recv().is_ok() {
830
        connections += 1;
831
    }
832
    assert_eq!(connections, 4, "one attempt plus three retries");
833
834
    // 250ms, then 500ms, then 1s. The backoff grows and is bounded.
835
    assert!(
836
        elapsed >= Duration::from_millis(1_700),
837
        "the retries did not back off: {elapsed:?}"
838
    );
839
    assert!(elapsed < Duration::from_secs(30));
840
841
    assert!(
842
        journal
843
            .read(50)
844
            .unwrap()
845
            .iter()
846
            .filter(|entry| entry.decision == "transport")
847
            .count()
848
            >= 4,
849
        "every transport ending is recorded locally"
850
    );
851
}
852
853
// ---------------------------------------------------------------------------
854
// the whole command, through the binary
855
// ---------------------------------------------------------------------------
856
857
/// A stand-in for the controller: the `/controller/status` route and the
858
/// Phoenix socket on one origin, so `oa computer status` and `oa computer up`
859
/// run end to end against a peer.
860
///
861
/// The socket is served once. A second upgrade is answered `403`, which is a
862
/// decision rather than transport loss, so the client stops instead of
863
/// reconnecting into a loop.
864
fn start_controller_host(
865
    machine_id: &'static str,
866
    run_payload: serde_json::Value,
867
) -> (String, Receiver<serde_json::Value>) {
868
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
869
    let port = listener.local_addr().unwrap().port();
870
    let (sender, frames) = channel();
871
    let topic = format!("computer:{machine_id}");
872
    let served = std::sync::Arc::new(std::sync::atomic::AtomicBool::new(false));
873
874
    std::thread::spawn(move || {
875
        for stream in listener.incoming() {
876
            let Ok(mut stream) = stream else { return };
877
            let mut peeked = [0u8; 2048];
878
            let count = stream.peek(&mut peeked).unwrap_or(0);
879
            let head = String::from_utf8_lossy(&peeked[..count]).to_ascii_lowercase();
880
881
            if !head.contains("upgrade: websocket") {
882
                let mut buffer = [0u8; 4096];
883
                let _ = stream.read(&mut buffer);
884
                let payload = serde_json::json!({
885
                    "machine_id": machine_id,
886
                    "name": "stub-machine",
887
                    "status": "active",
888
                    "token_expires_at": "2099-01-01T00:00:00Z",
889
                })
890
                .to_string();
891
                let response = format!(
892
                    "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}",
893
                    payload.len()
894
                );
895
                let _ = stream.write_all(response.as_bytes());
896
                let _ = stream.flush();
897
                continue;
898
            }
899
900
            if served.swap(true, std::sync::atomic::Ordering::SeqCst) {
901
                let _ = stream.write_all(
902
                    b"HTTP/1.1 403 Forbidden\r\ncontent-length: 0\r\nconnection: close\r\n\r\n",
903
                );
904
                let _ = stream.flush();
905
                continue;
906
            }
907
908
            let Ok(mut socket) = tungstenite::accept(stream) else {
909
                continue;
910
            };
911
            let _ = socket.read();
912
            let reply =
913
                serde_json::json!(["1", "1", topic, "phx_reply", {"status": "ok", "response": {}}]);
914
            let _ = socket.send(tungstenite::Message::Text(reply.to_string().into()));
915
            let _ = socket.read();
916
            let ask = serde_json::json!([serde_json::Value::Null, "9", topic, "run", run_payload]);
917
            let _ = socket.send(tungstenite::Message::Text(ask.to_string().into()));
918
919
            let deadline = std::time::Instant::now() + Duration::from_secs(20);
920
            while std::time::Instant::now() < deadline {
921
                match socket.read() {
922
                    Ok(tungstenite::Message::Text(text)) => {
923
                        if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
924
                            let event = value
925
                                .get(3)
926
                                .and_then(|event| event.as_str())
927
                                .unwrap_or_default()
928
                                .to_string();
929
                            let _ = sender.send(value);
930
                            if event == "refused" || event == "exit" {
931
                                break;
932
                            }
933
                        }
934
                    }
935
                    Ok(_) => {}
936
                    Err(_) => break,
937
                }
938
            }
939
            let _ = socket.close(None);
940
        }
941
    });
942
943
    (format!("http://127.0.0.1:{port}"), frames)
944
}
945
946
/// Lay out a private `HOME` holding a Computer policy and a machine token, so
947
/// the binary reads real files without touching the developer's own.
948
fn stub_home(directory: &std::path::Path, origin: &str, tier: &str, root: &std::path::Path) {
949
    let config_directory = directory.join(".config").join("openagents");
950
    std::fs::create_dir_all(&config_directory).unwrap();
951
    std::fs::write(
952
        config_directory.join("computer.json"),
953
        serde_json::json!({
954
            "tier": tier,
955
            "roots": [root.display().to_string()],
956
            "pre_approved": [],
957
        })
958
        .to_string(),
959
    )
960
    .unwrap();
961
    std::fs::write(
962
        config_directory.join("cli-credentials.json"),
963
        serde_json::json!({
964
            "version": 1,
965
            "tokens": { format!("computer:{origin}"): "smct_stub_machine_token" },
966
        })
967
        .to_string(),
968
    )
969
    .unwrap();
970
}
971
972
#[test]
973
fn test_the_binary_reports_real_pairing_state_without_printing_a_secret() {
974
    let directory = tempfile::tempdir().unwrap();
975
    let root = directory.path().join("checkout");
976
    std::fs::create_dir_all(&root).unwrap();
977
    let (origin, _frames) = start_controller_host("machine-status", serde_json::json!({}));
978
    stub_home(directory.path(), &origin, "curated", &root);
979
980
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_oa"))
981
        .args(["--api-url", &origin, "--json", "computer", "status"])
982
        .env("HOME", directory.path())
983
        .env_remove("OPENAGENTS_TOKEN")
984
        .output()
985
        .unwrap();
986
    assert!(
987
        output.status.success(),
988
        "computer status failed: {output:?}"
989
    );
990
991
    let stdout = String::from_utf8_lossy(&output.stdout).to_string();
992
    let value: serde_json::Value = serde_json::from_str(&stdout).unwrap();
993
    assert_eq!(value["state"], serde_json::json!("paired"));
994
    assert_eq!(value["paired"], serde_json::json!(true));
995
    assert_eq!(value["machine_id"], serde_json::json!("machine-status"));
996
    assert_eq!(value["tier"], serde_json::json!("curated"));
997
    assert_eq!(
998
        value["paths"]["journal"],
999
        serde_json::json!(directory
1000
            .path()
1001
            .join(".config/openagents/journal.ndjson")
1002
            .display()
1003
            .to_string())
1004
    );
1005
    assert!(
1006
        !stdout.contains("smct_"),
1007
        "the machine token reached the output: {stdout}"
1008
    );
1009
    assert!(!String::from_utf8_lossy(&output.stderr).contains("smct_"));
1010
}
1011
1012
/// `oa computer up` end to end: it opens the outbound connection, serves the
1013
/// bounded request the peer asks for, and writes what it decided to the local
1014
/// journal.
1015
#[test]
1016
fn test_the_binary_serves_a_bounded_request_over_an_outbound_connection() {
1017
    let directory = tempfile::tempdir().unwrap();
1018
    let root = directory.path().join("checkout");
1019
    std::fs::create_dir_all(&root).unwrap();
1020
    let (origin, frames) = start_controller_host(
1021
        "machine-up",
1022
        serde_json::json!({
1023
            "request_id": "req-binary",
1024
            "argv": ["git", "--version"],
1025
            "cwd": root.display().to_string(),
1026
        }),
1027
    );
1028
    stub_home(directory.path(), &origin, "curated", &root);
1029
1030
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_oa"))
1031
        .args(["--api-url", &origin, "computer", "up"])
1032
        .env("HOME", directory.path())
1033
        .env_remove("OPENAGENTS_TOKEN")
1034
        .output()
1035
        .unwrap();
1036
1037
    let mut chunks = String::new();
1038
    let mut exit = None;
1039
    while let Ok(frame) = frames.recv_timeout(Duration::from_secs(5)) {
1040
        match frame.get(3).and_then(|value| value.as_str()) {
1041
            Some("chunk") => chunks.push_str(
1042
                frame[4]
1043
                    .get("text")
1044
                    .and_then(|text| text.as_str())
1045
                    .unwrap_or_default(),
1046
            ),
1047
            Some("exit") => {
1048
                exit = Some(frame[4].clone());
1049
                break;
1050
            }
1051
            Some("refused") => panic!("the binary refused an allowed command: {frame}"),
1052
            _ => {}
1053
        }
1054
    }
1055
    let exit = exit.expect("the binary reported a terminal exit to the peer");
1056
    assert!(
1057
        chunks.contains("git version"),
1058
        "output reached the peer: {chunks:?}"
1059
    );
1060
    assert_eq!(exit["status"], serde_json::json!("completed"));
1061
    assert_eq!(exit["exit_code"], serde_json::json!(0));
1062
1063
    // The second upgrade is refused, so the command stops rather than
1064
    // reconnecting forever, and it says so on stderr with a non-zero status.
1065
    assert_eq!(output.status.code(), Some(2));
1066
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1067
    assert!(
1068
        stderr.contains("oa: the Computer connection stopped"),
1069
        "the ending is reported on stderr: {stderr}"
1070
    );
1071
1072
    let journal = Journal::at(directory.path().join(".config/openagents/journal.ndjson"));
1073
    let entries = journal.read(50).unwrap();
1074
    assert!(entries
1075
        .iter()
1076
        .any(|entry| entry.request_id == "req-binary" && entry.outcome == "completed"));
1077
}
1078
1079
/// The refusal path through the binary, and the journal entry it leaves.
1080
#[test]
1081
fn test_the_binary_refuses_a_command_outside_the_allowlist() {
1082
    let directory = tempfile::tempdir().unwrap();
1083
    let root = directory.path().join("checkout");
1084
    std::fs::create_dir_all(&root).unwrap();
1085
    let (origin, frames) = start_controller_host(
1086
        "machine-refuse",
1087
        serde_json::json!({
1088
            "request_id": "req-binary-refused",
1089
            "argv": ["curl", "https://example.com"],
1090
            "cwd": root.display().to_string(),
1091
        }),
1092
    );
1093
    stub_home(directory.path(), &origin, "curated", &root);
1094
1095
    let _ = std::process::Command::new(env!("CARGO_BIN_EXE_oa"))
1096
        .args(["--api-url", &origin, "computer", "up"])
1097
        .env("HOME", directory.path())
1098
        .env_remove("OPENAGENTS_TOKEN")
1099
        .output()
1100
        .unwrap();
1101
1102
    let refused = next_frame(&frames, "refused");
1103
    assert_eq!(refused["reason"], serde_json::json!("not_allowlisted"));
1104
    assert_eq!(
1105
        refused["request_id"],
1106
        serde_json::json!("req-binary-refused")
1107
    );
1108
1109
    let journal = Journal::at(directory.path().join(".config/openagents/journal.ndjson"));
1110
    let recorded = journal
1111
        .read(50)
1112
        .unwrap()
1113
        .into_iter()
1114
        .find(|entry| entry.request_id == "req-binary-refused" && entry.outcome == "refused")
1115
        .expect("the refusal is journaled locally");
1116
    assert_eq!(recorded.decision, "not_allowlisted");
1117
1118
    // And `oa computer journal` reads it back.
1119
    let listed = std::process::Command::new(env!("CARGO_BIN_EXE_oa"))
1120
        .args(["computer", "journal"])
1121
        .env("HOME", directory.path())
1122
        .output()
1123
        .unwrap();
1124
    let text = String::from_utf8_lossy(&listed.stdout).to_string();
1125
    assert!(
1126
        text.contains("not_allowlisted/refused") && text.contains("curl https://example.com"),
1127
        "the journal command shows the refusal: {text}"
1128
    );
1129
}
1130
1131
/// Without a machine token there is nothing to serve, and the command says so
1132
/// rather than pretending to run a daemon. The version this replaces printed
1133
/// `Computer agent daemon launched.` and exited zero.
1134
#[test]
1135
fn test_the_binary_refuses_to_serve_when_it_is_not_paired() {
1136
    let directory = tempfile::tempdir().unwrap();
1137
    std::fs::create_dir_all(directory.path().join(".config/openagents")).unwrap();
1138
    let output = std::process::Command::new(env!("CARGO_BIN_EXE_oa"))
1139
        .args(["--api-url", "http://127.0.0.1:1", "computer", "up"])
1140
        .env("HOME", directory.path())
1141
        .env_remove("OPENAGENTS_TOKEN")
1142
        .output()
1143
        .unwrap();
1144
    assert_eq!(output.status.code(), Some(2));
1145
    let stderr = String::from_utf8_lossy(&output.stderr).to_string();
1146
    assert!(
1147
        stderr.contains("not paired") && stderr.contains("computer pair"),
1148
        "the refusal names the next step: {stderr}"
1149
    );
1150
}
1151
1152
/// `logout` removes the machine token, and `status` then reports the local
1153
/// state rather than a stale pairing.
1154
#[test]
1155
fn test_the_binary_logout_removes_the_machine_token() {
1156
    let directory = tempfile::tempdir().unwrap();
1157
    let root = directory.path().join("checkout");
1158
    std::fs::create_dir_all(&root).unwrap();
1159
    let (origin, _frames) = start_controller_host("machine-logout", serde_json::json!({}));
1160
    stub_home(directory.path(), &origin, "curated", &root);
1161
1162
    let removed = std::process::Command::new(env!("CARGO_BIN_EXE_oa"))
1163
        .args(["--api-url", &origin, "--json", "computer", "logout"])
1164
        .env("HOME", directory.path())
1165
        .env_remove("OPENAGENTS_TOKEN")
1166
        .output()
1167
        .unwrap();
1168
    assert!(removed.status.success());
1169
    let value: serde_json::Value =
1170
        serde_json::from_str(&String::from_utf8_lossy(&removed.stdout)).unwrap();
1171
    assert_eq!(value["removed"], serde_json::json!(true));
1172
1173
    let after = std::process::Command::new(env!("CARGO_BIN_EXE_oa"))
1174
        .args(["--api-url", &origin, "--json", "computer", "status"])
1175
        .env("HOME", directory.path())
1176
        .env_remove("OPENAGENTS_TOKEN")
1177
        .output()
1178
        .unwrap();
1179
    let value: serde_json::Value =
1180
        serde_json::from_str(&String::from_utf8_lossy(&after.stdout)).unwrap();
1181
    assert_eq!(value["state"], serde_json::json!("local"));
1182
    assert_eq!(value["paired"], serde_json::json!(false));
1183
}
1184
1185
// ---------------------------------------------------------------------------
1186
// api passthrough: path resolution
1187
// ---------------------------------------------------------------------------
1188
1189
const ORIGIN: &str = "https://openagents.com";
1190
1191
/// The bug this issue was reopened for. Both spellings of the same route now
1192
/// resolve to it; the version this replaces turned `/api/v1/user` into
1193
/// `/api/v1/api/v1/user` and 404ed on the one form its help text advertised.
1194
#[test]
1195
fn test_absolute_and_relative_paths_name_the_same_route() {
1196
    assert_eq!(
1197
        resolve_api_path(ORIGIN, "/api/v1/user").unwrap(),
1198
        "/api/v1/user"
1199
    );
1200
    assert_eq!(resolve_api_path(ORIGIN, "user").unwrap(), "/api/v1/user");
1201
    assert_eq!(
1202
        resolve_api_path(ORIGIN, "repos/OpenAgentsInc/openagents/issues").unwrap(),
1203
        "/api/v1/repos/OpenAgentsInc/openagents/issues"
1204
    );
1205
    assert_eq!(
1206
        resolve_api_path(ORIGIN, "/api/v1/repos/OpenAgentsInc/openagents/issues").unwrap(),
1207
        "/api/v1/repos/OpenAgentsInc/openagents/issues"
1208
    );
1209
    // A query survives resolution.
1210
    assert_eq!(
1211
        resolve_api_path(ORIGIN, "issues?state=closed").unwrap(),
1212
        "/api/v1/issues?state=closed"
1213
    );
1214
    // A complete URL on the configured origin is accepted.
1215
    assert_eq!(
1216
        resolve_api_path(ORIGIN, "https://openagents.com/api/v1/user").unwrap(),
1217
        "/api/v1/user"
1218
    );
1219
}
1220
1221
#[test]
1222
fn test_a_path_that_leaves_the_api_is_refused() {
1223
    // The website, not the API.
1224
    let refusal = resolve_api_path(ORIGIN, "/user").unwrap_err();
1225
    assert!(
1226
        refusal.contains("must start with /api/"),
1227
        "the refusal must say how to write it: {refusal}"
1228
    );
1229
    // Another origin entirely.
1230
    assert!(resolve_api_path(ORIGIN, "https://example.com/api/v1/user")
1231
        .unwrap_err()
1232
        .contains("leaves the configured API origin"));
1233
    // A protocol-relative path is another origin in disguise.
1234
    assert!(resolve_api_path(ORIGIN, "//example.com/api/v1/user").is_err());
1235
    // A relative path that climbs out of the API prefix.
1236
    assert!(resolve_api_path(ORIGIN, "../../admin")
1237
        .unwrap_err()
1238
        .contains("resolves outside"));
1239
    assert!(resolve_api_path(ORIGIN, "   ").is_err());
1240
    assert!(resolve_api_path(ORIGIN, "ftp://openagents.com/api/v1/user").is_err());
1241
}
1242
1243
#[test]
1244
fn test_an_unknown_method_is_refused_rather_than_performed_as_a_get() {
1245
    assert_eq!(admitted_method("post").unwrap(), "POST");
1246
    assert_eq!(admitted_method(" delete ").unwrap(), "DELETE");
1247
    let refusal = admitted_method("POSTT").unwrap_err();
1248
    assert!(
1249
        refusal.contains("not a supported method"),
1250
        "the refusal must name the problem: {refusal}"
1251
    );
1252
    assert!(admitted_method("HEAD").is_err());
1253
    assert!(admitted_method("OPTIONS").is_err());
1254
    assert_eq!(resolve_request_method(None, false), "GET");
1255
    assert_eq!(resolve_request_method(None, true), "POST");
1256
    assert_eq!(resolve_request_method(Some("PATCH"), true), "PATCH");
1257
}
1258
1259
#[test]
1260
fn test_request_fields_and_headers_are_parsed_not_guessed() {
1261
    let fields =
1262
        parse_request_fields(&["title=Hello".to_string(), "body=World".to_string()]).unwrap();
1263
    assert_eq!(fields["title"], serde_json::json!("Hello"));
1264
    assert_eq!(fields["body"], serde_json::json!("World"));
1265
    // Every value is a JSON string; `--input` carries anything else.
1266
    let typed = parse_request_fields(&["count=3".to_string()]).unwrap();
1267
    assert_eq!(typed["count"], serde_json::json!("3"));
1268
1269
    assert!(parse_request_fields(&["nope".to_string()]).is_err());
1270
    assert!(parse_request_fields(&["=value".to_string()]).is_err());
1271
    assert!(
1272
        parse_request_fields(&["a=1".to_string(), "a=2".to_string()])
1273
            .unwrap_err()
1274
            .contains("more than once")
1275
    );
1276
1277
    let headers = parse_request_headers(&["X-Trace:  abc  ".to_string()]).unwrap();
1278
    assert_eq!(headers, vec![("x-trace".to_string(), "abc".to_string())]);
1279
    // The session owns the authorization header.
1280
    assert!(
1281
        parse_request_headers(&["Authorization: Bearer x".to_string()])
1282
            .unwrap_err()
1283
            .contains("Remove --header authorization")
1284
    );
1285
    assert!(parse_request_headers(&["no colon".to_string()]).is_err());
1286
    assert!(parse_request_headers(&["bad name: x".to_string()]).is_err());
1287
1288
    assert!(decode_request_body("  ", "standard input").is_err());
1289
    assert!(decode_request_body("not json", "standard input").is_err());
1290
    assert_eq!(
1291
        decode_request_body(r#"{"a":[1,2]}"#, "standard input").unwrap(),
1292
        serde_json::json!({"a": [1, 2]})
1293
    );
1294
}
1295
1296
#[test]
1297
fn test_error_details_read_the_servers_own_envelope() {
1298
    let body = serde_json::json!({
1299
        "code": "not_found",
1300
        "message": "Repository not found",
1301
        "request_id": "GM9-abc",
1302
    });
1303
    let details = api_error_details(Some(&body));
1304
    assert_eq!(details.message.as_deref(), Some("Repository not found"));
1305
    assert_eq!(details.code.as_deref(), Some("not_found"));
1306
    assert_eq!(details.request_id.as_deref(), Some("GM9-abc"));
1307
1308
    // A body with no envelope yields nothing rather than an invented message.
1309
    assert_eq!(
1310
        api_error_details(Some(&serde_json::json!({"ok": true}))).message,
1311
        None
1312
    );
1313
    assert_eq!(api_error_details(None).message, None);
1314
}
1315
1316
// ---------------------------------------------------------------------------
1317
// api passthrough: transport
1318
// ---------------------------------------------------------------------------
1319
1320
/// A stub API that answers with the request line and every header it received,
1321
/// so header injection is asserted against a peer rather than against the
1322
/// client's own intent.
1323
fn start_echo_api() -> String {
1324
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1325
    let port = listener.local_addr().unwrap().port();
1326
    std::thread::spawn(move || {
1327
        for stream in listener.incoming() {
1328
            let Ok(mut stream) = stream else { return };
1329
            let mut buffer = [0u8; 8192];
1330
            let Ok(count) = stream.read(&mut buffer) else {
1331
                continue;
1332
            };
1333
            let request = String::from_utf8_lossy(&buffer[..count]).to_string();
1334
            let mut lines = request.split("\r\n");
1335
            let start = lines.next().unwrap_or_default().to_string();
1336
            let mut headers = serde_json::Map::new();
1337
            for line in lines {
1338
                if line.is_empty() {
1339
                    break;
1340
                }
1341
                if let Some((name, value)) = line.split_once(':') {
1342
                    headers.insert(
1343
                        name.trim().to_ascii_lowercase(),
1344
                        serde_json::Value::String(value.trim().to_string()),
1345
                    );
1346
                }
1347
            }
1348
            let body = request
1349
                .split_once("\r\n\r\n")
1350
                .map(|(_, body)| body.to_string())
1351
                .unwrap_or_default();
1352
            let payload = serde_json::json!({
1353
                "request_line": start,
1354
                "headers": serde_json::Value::Object(headers),
1355
                "body": body,
1356
            })
1357
            .to_string();
1358
            let response = format!(
1359
                "HTTP/1.1 200 OK\r\ncontent-type: application/json\r\ncontent-length: {}\r\nconnection: close\r\n\r\n{payload}",
1360
                payload.len()
1361
            );
1362
            let _ = stream.write_all(response.as_bytes());
1363
            let _ = stream.flush();
1364
        }
1365
    });
1366
    format!("http://127.0.0.1:{port}")
1367
}
1368
1369
#[tokio::test]
1370
async fn test_headers_and_body_reach_the_server() {
1371
    let origin = start_echo_api();
1372
    let client = ApiPassthroughClient::new(&origin, Some("oa_pat_test".to_string()));
1373
    let response = client
1374
        .send(
1375
            "POST",
1376
            "memories",
1377
            &[("x-trace".to_string(), "header-proof".to_string())],
1378
            Some(&serde_json::json!({"body": "hello"})),
1379
        )
1380
        .await
1381
        .unwrap();
1382
1383
    assert_eq!(response.status, 200);
1384
    let echoed = response.body.expect("the stub answers with JSON");
1385
    assert_eq!(
1386
        echoed["request_line"].as_str().unwrap(),
1387
        "POST /api/v1/memories HTTP/1.1",
1388
        "the method and the resolved path both reached the server"
1389
    );
1390
    assert_eq!(
1391
        echoed["headers"]["x-trace"],
1392
        serde_json::json!("header-proof")
1393
    );
1394
    assert_eq!(
1395
        echoed["headers"]["authorization"],
1396
        serde_json::json!("Bearer oa_pat_test"),
1397
        "authentication is forwarded from the session"
1398
    );
1399
    assert_eq!(
1400
        echoed["body"].as_str().unwrap(),
1401
        r#"{"body":"hello"}"#,
1402
        "the JSON body reached the server"
1403
    );
1404
}
1405
1406
/// The replacement for `assert!(res.is_object())`. That assertion held while
1407
/// every refused request returned `{"status": N}` — an object — so it could
1408
/// not tell a route from an error. This names the field the route returns.
1409
#[tokio::test]
1410
async fn test_api_passthrough_returns_the_repository_the_route_names() {
1411
    let client = ApiPassthroughClient::new(ORIGIN, None);
1412
    let value = client
1413
        .execute_request("GET", "repos/OpenAgentsInc/openagents", None)
1414
        .await
1415
        .expect("the public repository route answers");
1416
    assert_eq!(
1417
        value.get("full_name").and_then(|v| v.as_str()),
1418
        Some("OpenAgentsInc/openagents")
1419
    );
1420
    assert!(!value
1421
        .get("default_branch")
1422
        .and_then(|v| v.as_str())
1423
        .unwrap_or_default()
1424
        .is_empty());
1425
    assert_eq!(
1426
        value.get("visibility").and_then(|v| v.as_str()),
1427
        Some("public")
1428
    );
1429
}
1430
1431
/// A route the server does not serve is an error carrying the server's own
1432
/// message, not a value. The `{"status": 404}` stub this replaces satisfied
1433
/// every assertion a caller could write about the success path.
1434
#[tokio::test]
1435
async fn test_api_passthrough_refuses_a_route_the_server_does_not_serve() {
1436
    let client = ApiPassthroughClient::new(ORIGIN, None);
1437
    let refused = client
1438
        .execute_request("GET", "repos/OpenAgentsInc/no-such-repository-here", None)
1439
        .await;
1440
    let message = refused
1441
        .expect_err("a 404 must not read as a value")
1442
        .to_string();
1443
    assert!(
1444
        message.contains("404"),
1445
        "the refusal names the status: {message}"
1446
    );
1447
    assert!(
1448
        message.contains("Repository not found"),
1449
        "the refusal carries the server's own message: {message}"
1450
    );
1451
1452
    // And the envelope keeps the server's body so the command can print it.
1453
    let response = client
1454
        .send(
1455
            "GET",
1456
            "repos/OpenAgentsInc/no-such-repository-here",
1457
            &[],
1458
            None,
1459
        )
1460
        .await
1461
        .unwrap();
1462
    assert_eq!(response.status, 404);
1463
    assert!(!response.successful());
1464
    assert_eq!(
1465
        response
1466
            .body
1467
            .as_ref()
1468
            .and_then(|body| body.get("code"))
1469
            .and_then(|code| code.as_str()),
1470
        Some("not_found")
1471
    );
1472
}
1473
1474
/// A host that answers nothing is a transport failure, and it stays one. It
1475
/// does not become an empty body or a plausible status.
1476
#[tokio::test]
1477
async fn test_api_passthrough_reports_transport_failure() {
1478
    // A port nothing is listening on.
1479
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
1480
    let port = listener.local_addr().unwrap().port();
1481
    drop(listener);
1482
1483
    let client = ApiPassthroughClient::new(&format!("http://127.0.0.1:{port}"), None);
1484
    let refusal = client
1485
        .send("GET", "user", &[], None)
1486
        .await
1487
        .expect_err("nothing is listening");
1488
    assert!(
1489
        refusal.contains("could not reach"),
1490
        "the refusal names the transport: {refusal}"
1491
    );
1492
}
1493
1494
/// The client keeps the origin whichever way it was given, so an absolute path
1495
/// is not prefixed twice.
1496
#[test]
1497
fn test_client_reduces_a_legacy_api_base_to_its_origin() {
1498
    assert_eq!(
1499
        ApiPassthroughClient::new("https://openagents.com/api/v1", None).origin,
1500
        "https://openagents.com"
1501
    );
1502
    assert_eq!(
1503
        ApiPassthroughClient::new("https://openagents.com", None).origin,
1504
        "https://openagents.com"
1505
    );
1506
}

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