Run a delegated ACP agent under the machine's own policy (#113)

caac30fa8fd8 · AtlantisPleb · · parent 71520c7de7ed

Run a delegated ACP agent under the machine's own policy (#113)

An `agent` frame on the Computer controller answered
`{"reason":"unsupported","detail":"ACP delegation is unavailable"}`. It now
drives a real ACP child and maps its events onto the channel's frames:
`session` as soon as the session opens (the server checkpoints it mid-stream
for reattach), `chunk` as the agent writes, and exactly one terminal `exit`.

This is wiring, not a port of `computer-agents.ts`. The harness already
existed — twice. `openagents-cli/src/acp.rs` and `coder-lite/src/acp_harness.rs`
were copies that had already drifted: the coder-lite copy carried a
character-boundary fix the original lacked, the original carried cancellation
the copy lacked, and the Computer controller could use neither, because
coder-lite depends on openagents-cli and not the other way round. There is one
harness now, in the crate both reach, carrying both fixes; coder-lite's file is
the name it reaches it by.

What the harness gained, all of it additive:

- a permission gate. Without one it answers "yes" to everything, which is right
  for a child the reader started and wrong for one a server asked for.
- reverse-request handling, so `git/push` can be served and anything else is
  answered `method not found` rather than left to hang the agent.
- `session/load` for a resume, refused when the agent does not report
  `loadSession` — a silent fresh session looks like a resume and loses
  everything the earlier one knew.
- an explicit child environment, so a delegated agent does not inherit this
  process's credentials.
- the `stopReason`, which is the difference between an agent that finished and
  one that refused.

A delegated agent is not a way around the policy. The tier ceiling, the
declared roots, and the shell-metacharacter refusal decide the request before a
child is started, and every action the agent asks permission for is decided
again by the same policy read through the agent's vocabulary: an execute must
be allowlisted in every segment it chains, `cd` may not leave the declared
roots, an edit must land inside one, and a denied binary or a protected path is
refused before the tier is consulted. Substitution and redirection are refused
outright, because a per-segment allowlist cannot bound them: `ls $(curl …)` has
`ls` as its first word. The delegation names the asking mode rather than
inheriting the agent's, since a gate the agent never consults decides nothing.

Scoped forge credentials now govern something on this side. A credential the
server delivered — it withholds one unless the Computers page checkbox is
ticked — is used for one `git push` of the assigned branch, through a helper
that hands the token over only for the assigned repository's host and path, in
a directory only this user can enter. The token never becomes an argument, an
environment variable, part of a URL, or a journal line. The local
`scoped_forge_credentials` switch starts closed like the rest of the policy;
with it off the credential is refused and journaled and the agent's push is
answered rather than hung.

The probe now reports `acp_agents`. `OpenAgents.ComputerAgentJobs.start/4`
refuses any `agent_id` absent from `last_probe["acp_agents"]`, so a report
without it is a machine the server will not delegate to whatever is installed
on it. The controller resolves against the same list: what the owner declared
in `computer.json`, and what the probe actually found.

Every refusal is journaled with its reason and every request reaches a terminal
frame, including the paths that fail — the `devin` silent drop fixed in
`7b7453bd10` is the shape being kept out. The server's answer to `hello` is now
reported too; a rejected hello was invisible, which made a machine that had
announced nothing look identical to one that had announced everything.

Verified live: `oa computer up` joins the production controller and its hello,
carrying the new inventory, is accepted.

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

Deploy story

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

Not deployed through the forge lane

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

Changed files

  • modified crates/coder-lite/src/acp_harness.rs
  • modified crates/coder-lite/src/acp_tool.rs
  • modified crates/openagents-cli/src/acp.rs
  • modified crates/openagents-cli/src/computer.rs
  • modified crates/openagents-cli/tests/acp_test.rs
  • added crates/openagents-cli/tests/computer_agent_test.rs
  • modified crates/openagents-cli/tests/computer_api_test.rs

Diff

7 files changed, +3263 -571

crates/coder-lite/src/acp_harness.rs modified +18 -426

@@ -1,10 +1,20 @@

1 1
//! ACP child agent harness for coder-lite.
2 2
//!
3
//! Spawns an ACP-compatible CLI agent over stdio and streams JSON-RPC
4
//! `session/update` events as they arrive: `initialize`, `session/new`, an
5
//! optional `session/set_mode`, then `session/prompt`. A
6
//! `session/request_permission` the agent sends back is answered without
7
//! asking the reader, preferring whichever option the agent marked `allow*`.
3
//! There is one harness, and it lives in `openagents_cli::acp` — the crate
4
//! coder-lite already takes its runtime from. This file was a second copy of
5
//! it, and the two had already drifted: the copy carried a character-boundary
6
//! fix the original did not, the original carried cancellation the copy did
7
//! not, and the Computer controller could use neither because a crate cannot
8
//! depend on the crate that depends on it. Both fixes now live in the one
9
//! implementation, and this is the name coder-lite reaches it by.
10
//!
11
//! What it does is unchanged. It spawns an ACP-compatible CLI agent over stdio
12
//! and streams JSON-RPC `session/update` events as they arrive: `initialize`,
13
//! `session/new` (or `session/load` for a resume), an optional
14
//! `session/set_mode`, then `session/prompt`. A `session/request_permission`
15
//! the agent sends back is answered without asking the reader, unless a caller
16
//! supplies a gate — the Computer controller does, so a delegated agent runs
17
//! under the machine's policy rather than around it.
8 18
//!
9 19
//! ## The child is stopped with its whole tree
10 20
//!

@@ -16,427 +26,9 @@

16 26
//! way out gets to write it. This used to be a bare `child.kill()`, which
17 27
//! stopped the agent and orphaned everything under it.
18 28
19
use std::path::Path;
20
use std::process::Stdio;
21
use std::time::Duration;
22
23
use openagents_cli::signals::stop_tree;
24
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
25
use tokio::process::{ChildStdin, ChildStdout, Command};
26
27
/// How much the child is allowed to do without being asked.
28
///
29
/// The names are coder-lite's; the wire carries the agent's own. A build of
30
/// the agent that does not know a mode is not a reason to lose the child, so
31
/// setting it is best effort.
32
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
33
pub enum PermissionMode {
34
    Dangerous,
35
    Prompt,
36
    ReadOnly,
37
}
38
39
impl PermissionMode {
40
    pub fn parse(name: &str) -> Option<Self> {
41
        match name.trim().to_lowercase().as_str() {
42
            "dangerous" | "bypass" => Some(PermissionMode::Dangerous),
43
            "prompt" | "default" | "ask" => Some(PermissionMode::Prompt),
44
            "read-only" | "readonly" => Some(PermissionMode::ReadOnly),
45
            _ => None,
46
        }
47
    }
48
49
    /// The mode id sent in `session/set_mode`.
50
    pub fn mode_id(self) -> &'static str {
51
        match self {
52
            PermissionMode::Dangerous => "bypass",
53
            PermissionMode::Prompt => "default",
54
            PermissionMode::ReadOnly => "read-only",
55
        }
56
    }
57
}
58
59
#[derive(Debug)]
60
pub enum AcpFailure {
61
    Unstartable(String),
62
    Refused(String),
63
}
64
65
impl std::fmt::Display for AcpFailure {
66
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
67
        match self {
68
            AcpFailure::Unstartable(why) => write!(f, "{why}"),
69
            AcpFailure::Refused(why) => write!(f, "{why}"),
70
        }
71
    }
72
}
73
74
#[derive(Debug, Clone)]
75
pub enum AcpEvent {
76
    Session { id: String },
77
    Tool { kind: String, title: String },
78
    Tokens { input: u64, output: u64 },
79
    Text { chunk: String },
80
}
81
82
#[derive(Debug, Clone)]
83
pub struct AcpHarness {
84
    pub command: String,
85
    pub args: Vec<String>,
86
    /// Sent as `session/set_mode` after the session opens. `None` leaves the
87
    /// agent's own default, which is a different answer from naming one.
88
    pub mode: Option<PermissionMode>,
89
}
90
91
impl Default for AcpHarness {
92
    fn default() -> Self {
93
        Self {
94
            command: "devin".to_string(),
95
            args: vec!["acp".to_string()],
96
            mode: None,
97
        }
98
    }
99
}
100
101
const REQUEST_TIMEOUT: Duration = Duration::from_secs(900);
102
const HANDSHAKE_TIMEOUT: Duration = Duration::from_secs(60);
103
104
impl AcpHarness {
105
    pub async fn run<F>(
106
        &self,
107
        prompt: &str,
108
        cwd: &Path,
109
        mut on_event: F,
110
    ) -> Result<String, AcpFailure>
111
    where
112
        F: FnMut(AcpEvent) + Send,
113
    {
114
        let mut command = Command::new(&self.command);
115
        command
116
            .args(&self.args)
117
            .current_dir(cwd)
118
            .stdin(Stdio::piped())
119
            .stdout(Stdio::piped())
120
            .stderr(Stdio::piped());
121
        // Its own process group, so stopping the child stops what the child
122
        // started.
123
        #[cfg(unix)]
124
        command.process_group(0);
125
        let mut child = command
126
            .spawn()
127
            .map_err(|error| {
128
                AcpFailure::Unstartable(if error.kind() == std::io::ErrorKind::NotFound {
129
                    format!("the `{}` command is not on PATH", self.command)
130
                } else {
131
                    format!("the `{}` command would not start: {error}", self.command)
132
                })
133
            })?;
134
135
        if let Some(stderr) = child.stderr.take() {
136
            tokio::spawn(async move {
137
                let mut lines = BufReader::new(stderr).lines();
138
                while let Ok(Some(_)) = lines.next_line().await {}
139
            });
140
        }
141
142
        let stdin = child.stdin.take();
143
        let stdout = child.stdout.take();
144
        let (Some(mut stdin), Some(stdout)) = (stdin, stdout) else {
145
            stop_tree(&mut child).await;
146
            return Err(AcpFailure::Refused(
147
                "the agent's standard streams could not be opened".to_string(),
148
            ));
149
        };
150
        let mut lines = BufReader::new(stdout).lines();
151
152
        let outcome = self
153
            .converse(prompt, cwd, &mut stdin, &mut lines, &mut on_event)
154
            .await;
155
156
        stop_tree(&mut child).await;
157
        outcome
158
    }
159
160
    async fn converse<F>(
161
        &self,
162
        prompt: &str,
163
        cwd: &Path,
164
        stdin: &mut ChildStdin,
165
        lines: &mut tokio::io::Lines<BufReader<ChildStdout>>,
166
        on_event: &mut F,
167
    ) -> Result<String, AcpFailure>
168
    where
169
        F: FnMut(AcpEvent) + Send,
170
    {
171
        let mut seq: u64 = 0;
172
        let mut answer = String::new();
173
174
        request(
175
            stdin,
176
            lines,
177
            &mut seq,
178
            "initialize",
179
            serde_json::json!({
180
                "protocolVersion": 1,
181
                "clientCapabilities": {"fs": {"readTextFile": false, "writeTextFile": false}}
182
            }),
183
            HANDSHAKE_TIMEOUT,
184
            &mut answer,
185
            on_event,
186
        )
187
        .await?;
188
189
        let opened = request(
190
            stdin,
191
            lines,
192
            &mut seq,
193
            "session/new",
194
            serde_json::json!({"cwd": cwd.to_string_lossy(), "mcpServers": []}),
195
            REQUEST_TIMEOUT,
196
            &mut answer,
197
            on_event,
198
        )
199
        .await?;
200
201
        let session_id = opened
202
            .get("sessionId")
203
            .and_then(|v| v.as_str())
204
            .ok_or_else(|| AcpFailure::Refused("the agent opened no session".to_string()))?
205
            .to_string();
206
        on_event(AcpEvent::Session {
207
            id: session_id.clone(),
208
        });
209
210
        if let Some(mode) = self.mode {
211
            // Best effort: a build of the agent without this mode should not
212
            // cost the child over the name of a permission setting.
213
            let _ = request(
214
                stdin,
215
                lines,
216
                &mut seq,
217
                "session/set_mode",
218
                serde_json::json!({"sessionId": session_id, "modeId": mode.mode_id()}),
219
                REQUEST_TIMEOUT,
220
                &mut answer,
221
                on_event,
222
            )
223
            .await;
224
        }
225
226
        request(
227
            stdin,
228
            lines,
229
            &mut seq,
230
            "session/prompt",
231
            serde_json::json!({
232
                "sessionId": session_id,
233
                "prompt": [{"type": "text", "text": prompt}]
234
            }),
235
            REQUEST_TIMEOUT,
236
            &mut answer,
237
            on_event,
238
        )
239
        .await?;
240
241
        Ok(answer.trim().to_string())
242
    }
243
}
244
245
#[allow(clippy::too_many_arguments)]
246
async fn request<F>(
247
    stdin: &mut ChildStdin,
248
    lines: &mut tokio::io::Lines<BufReader<ChildStdout>>,
249
    seq: &mut u64,
250
    method: &str,
251
    params: serde_json::Value,
252
    limit: Duration,
253
    answer: &mut String,
254
    on_event: &mut F,
255
) -> Result<serde_json::Value, AcpFailure>
256
where
257
    F: FnMut(AcpEvent) + Send,
258
{
259
    *seq += 1;
260
    let id = *seq;
261
    let line = serde_json::json!({"jsonrpc": "2.0", "id": id, "method": method, "params": params});
262
    write_line(stdin, &line).await?;
263
264
    let deadline = tokio::time::Instant::now() + limit;
265
266
    loop {
267
        let next = tokio::select! {
268
            read = lines.next_line() => read,
269
            _ = tokio::time::sleep_until(deadline) => {
270
                return Err(AcpFailure::Refused(format!(
271
                    "the agent did not answer `{method}` within {}s", limit.as_secs()
272
                )));
273
            }
274
        };
275
276
        let raw = match next {
277
            Ok(Some(raw)) => raw,
278
            Ok(None) => {
279
                return Err(AcpFailure::Refused(
280
                    "the agent exited before it answered".to_string(),
281
                ))
282
            }
283
            Err(error) => {
284
                return Err(AcpFailure::Refused(format!(
285
                    "the agent's output could not be read: {error}"
286
                )))
287
            }
288
        };
289
290
        let trimmed = raw.trim();
291
        if trimmed.is_empty() {
292
            continue;
293
        }
294
        let Ok(message) = serde_json::from_str::<serde_json::Value>(trimmed) else {
295
            continue;
296
        };
297
298
        let is_reply = message.get("id").and_then(|v| v.as_u64()).is_some()
299
            && message.get("method").is_none();
300
        if is_reply {
301
            if message.get("id").and_then(|v| v.as_u64()) != Some(id) {
302
                continue;
303
            }
304
            if let Some(error) = message.get("error") {
305
                // The agent's own bytes, and `serde_json` does not escape
306
                // non-ASCII, so a refusal carrying an accent or an emoji
307
                // across byte 200 used to panic here and take the whole
308
                // session with it. Floored to a character boundary, as the
309
                // four cuts in `28704f72ff` were.
310
                let text = serde_json::to_string(error).unwrap_or_default();
311
                let end = openagents_cli::tracker::floor_char_boundary(&text, 200);
312
                return Err(AcpFailure::Refused(format!(
313
                    "the agent refused `{method}`: {}",
314
                    &text[..end]
315
                )));
316
            }
317
            return Ok(message
318
                .get("result")
319
                .cloned()
320
                .unwrap_or(serde_json::json!({})));
321
        }
322
323
        handle_incoming(&message, stdin, answer, on_event).await?;
324
    }
325
}
326
327
async fn handle_incoming<F>(
328
    message: &serde_json::Value,
329
    stdin: &mut ChildStdin,
330
    answer: &mut String,
331
    on_event: &mut F,
332
) -> Result<(), AcpFailure>
333
where
334
    F: FnMut(AcpEvent) + Send,
335
{
336
    let method = message.get("method").and_then(|v| v.as_str()).unwrap_or("");
337
338
    if method == "session/request_permission" {
339
        let Some(id) = message.get("id").and_then(|v| v.as_u64()) else {
340
            return Ok(());
341
        };
342
        let params = message
343
            .get("params")
344
            .cloned()
345
            .unwrap_or(serde_json::json!({}));
346
        let outcome = match first_allow_option(&params) {
347
            Some(option) => serde_json::json!({"outcome": "selected", "optionId": option}),
348
            None => serde_json::json!({"outcome": "cancelled"}),
349
        };
350
        write_line(
351
            stdin,
352
            &serde_json::json!({"jsonrpc": "2.0", "id": id, "result": {"outcome": outcome}}),
353
        )
354
        .await?;
355
        return Ok(());
356
    }
357
358
    if method != "session/update" {
359
        return Ok(());
360
    }
361
362
    let update = message
363
        .get("params")
364
        .and_then(|p| p.get("update"))
365
        .cloned()
366
        .unwrap_or(serde_json::json!({}));
367
368
    match update.get("sessionUpdate").and_then(|v| v.as_str()) {
369
        Some("tool_call") => {
370
            let kind = update
371
                .get("kind")
372
                .and_then(|v| v.as_str())
373
                .unwrap_or("tool")
374
                .to_string();
375
            let title = update
376
                .get("title")
377
                .and_then(|v| v.as_str())
378
                .unwrap_or("")
379
                .to_string();
380
            answer.push_str(&format!("[{}] {}\n", kind, title));
381
            on_event(AcpEvent::Tool { kind, title });
382
        }
383
        Some("usage_update") => {
384
            let meta = update.get("_meta").cloned().unwrap_or(serde_json::json!({}));
385
            let input = meta.get("cognition.ai/inputTokens").and_then(|v| v.as_u64());
386
            let output = meta.get("cognition.ai/outputTokens").and_then(|v| v.as_u64());
387
            if let (Some(input), Some(output)) = (input, output) {
388
                on_event(AcpEvent::Tokens { input, output });
389
            }
390
        }
391
        Some("agent_message_chunk") => {
392
            if let Some(piece) = update
393
                .get("content")
394
                .and_then(|c| c.get("text"))
395
                .and_then(|v| v.as_str())
396
            {
397
                answer.push_str(piece);
398
                on_event(AcpEvent::Text {
399
                    chunk: piece.to_string(),
400
                });
401
            }
402
        }
403
        _ => {}
404
    }
405
406
    Ok(())
407
}
408
409
async fn write_line(stdin: &mut ChildStdin, value: &serde_json::Value) -> Result<(), AcpFailure> {
410
    let mut line = serde_json::to_string(value).unwrap_or_default();
411
    line.push('\n');
412
    stdin
413
        .write_all(line.as_bytes())
414
        .await
415
        .map_err(|error| AcpFailure::Refused(format!("the agent stopped reading: {error}")))?;
416
    stdin
417
        .flush()
418
        .await
419
        .map_err(|error| AcpFailure::Refused(format!("the agent stopped reading: {error}")))
420
}
421
422
fn first_allow_option(params: &serde_json::Value) -> Option<String> {
423
    let options = params.get("options")?.as_array()?;
424
    let named: Vec<&serde_json::Value> = options
425
        .iter()
426
        .filter(|option| option.get("optionId").and_then(|v| v.as_str()).is_some())
427
        .collect();
428
    let allow = named.iter().find(|option| {
429
        option
430
            .get("kind")
431
            .and_then(|v| v.as_str())
432
            .is_some_and(|kind| kind.starts_with("allow"))
433
    });
434
    allow
435
        .or(named.first())
436
        .and_then(|option| option.get("optionId"))
437
        .and_then(|v| v.as_str())
438
        .map(String::from)
439
}
29
pub use openagents_cli::acp::{
30
    AcpEvent, AcpFailure, AcpHarness, AcpOutcome, PermissionGate, PermissionMode, PermissionQuery,
31
};
440 32
441 33
#[cfg(test)]
442 34
mod tests {
crates/coder-lite/src/acp_tool.rs modified +41 -26

@@ -49,7 +49,9 @@ pub const ACP_TOOL: &str = "acp";

49 49
/// result is how a session comes to believe a file was edited when nothing
50 50
/// touched it.
51 51
fn is_refusal(answer: &str) -> bool {
52
    answer.to_lowercase().contains("upgrade your plan to continue")
52
    answer
53
        .to_lowercase()
54
        .contains("upgrade your plan to continue")
53 55
}
54 56
55 57
/// The `acp` tool for `agents`, or `None` when none are installed.

@@ -182,37 +184,47 @@ pub fn acp_host_tool(

182 184
                                 `dangerous`, or omit it for the agent's own default."
183 185
                            ),
184 186
                            true,
185
                        )
187
                        );
186 188
                    }
187 189
                },
188 190
            };
189 191
190 192
            let streaming = Arc::clone(&sink);
191 193
            let id = call_id.clone();
194
            // Nothing here stops the child early, so the cancellation channel
195
            // is held open for the length of the run: dropping the sender
196
            // would signal a cancel the reader never asked for.
197
            let (_stop, mut cancel) = tokio::sync::watch::channel(false);
192 198
            let result = AcpHarness {
193 199
                command: agent.command,
194 200
                args: agent.args,
195 201
                mode,
202
                ..AcpHarness::default()
196 203
            }
197
            .run(&prompt, &cwd, move |event| {
198
                // What the child is doing, into the box under its header,
199
                // while it is still doing it.
200
                let chunk = match event {
201
                    AcpEvent::Text { chunk } => chunk,
202
                    AcpEvent::Tool { kind, title } => format!("[{kind}] {title}\n"),
203
                    AcpEvent::Tokens { input, output } => {
204
                        format!("[{input} in / {output} out tokens]\n")
205
                    }
206
                    AcpEvent::Session { .. } => return,
207
                };
208
                send(
209
                    &streaming,
210
                    Control::ToolOutput {
211
                        call_id: id.clone(),
212
                        chunk,
213
                    },
214
                );
215
            })
204
            .run(
205
                &prompt,
206
                &cwd,
207
                move |event| {
208
                    // What the child is doing, into the box under its header,
209
                    // while it is still doing it.
210
                    let chunk = match event {
211
                        AcpEvent::Text { chunk } => chunk,
212
                        AcpEvent::Tool { kind, title } => format!("[{kind}] {title}\n"),
213
                        AcpEvent::Tokens { input, output } => {
214
                            format!("[{input} in / {output} out tokens]\n")
215
                        }
216
                        AcpEvent::Session { .. } => return,
217
                    };
218
                    send(
219
                        &streaming,
220
                        Control::ToolOutput {
221
                            call_id: id.clone(),
222
                            chunk,
223
                        },
224
                    );
225
                },
226
                &mut cancel,
227
            )
216 228
            .await;
217 229
218 230
            match result {

@@ -220,10 +232,9 @@ pub fn acp_host_tool(

220 232
                    format!("`{wanted}` refused the task rather than doing it: {answer}"),
221 233
                    true,
222 234
                ),
223
                Ok(answer) if answer.trim().is_empty() => (
224
                    format!("`{wanted}` finished and said nothing."),
225
                    false,
226
                ),
235
                Ok(answer) if answer.trim().is_empty() => {
236
                    (format!("`{wanted}` finished and said nothing."), false)
237
                }
227 238
                Ok(answer) => (answer, false),
228 239
                Err(AcpFailure::Unstartable(why)) => {
229 240
                    (format!("`{wanted}` could not be started: {why}"), true)

@@ -231,9 +242,13 @@ pub fn acp_host_tool(

231 242
                Err(AcpFailure::Refused(why)) => {
232 243
                    (format!("`{wanted}` did not finish the task: {why}"), true)
233 244
                }
245
                Err(AcpFailure::Cancelled) => {
246
                    (format!("`{wanted}` was stopped before it finished."), true)
247
                }
234 248
            }
235 249
        };
236
        Box::pin(future) as std::pin::Pin<Box<dyn std::future::Future<Output = (String, bool)> + Send>>
250
        Box::pin(future)
251
            as std::pin::Pin<Box<dyn std::future::Future<Output = (String, bool)> + Send>>
237 252
    });
238 253
239 254
    Some(HostTool { definition, run })
crates/openagents-cli/src/acp.rs modified +314 -52

@@ -22,6 +22,7 @@

22 22
23 23
use std::path::Path;
24 24
use std::process::Stdio;
25
use std::sync::Arc;
25 26
use std::time::Duration;
26 27
27 28
use serde::{Deserialize, Serialize};

@@ -82,23 +83,96 @@ impl PermissionMode {

82 83
/// What a running ACP child reports as it works.
83 84
#[derive(Debug, Clone)]
84 85
pub enum AcpEvent {
85
    Session { id: String },
86
    Session {
87
        id: String,
88
    },
86 89
    /// A tool the agent ran. `title` is Devin's own phrase — "Ran ls", "Read
87 90
    /// src/a.ts" — so it is the activity rather than a name to look up.
88
    Tool { kind: String, title: String },
89
    Tokens { input: u64, output: u64 },
91
    Tool {
92
        kind: String,
93
        title: String,
94
    },
95
    Tokens {
96
        input: u64,
97
        output: u64,
98
    },
90 99
    /// A piece of the answer, as it is written.
91
    Text { chunk: String },
100
    Text {
101
        chunk: String,
102
    },
92 103
}
93 104
94
/// How an ACP child is started.
105
/// What the agent is asking permission to do.
106
///
107
/// The wire shape is ACP's `session/request_permission` `toolCall`: a `kind`
108
/// naming the class of action, a `title` the agent wrote, and the tool's raw
109
/// input. A caller that runs the agent under a policy needs all three — the
110
/// command a shell tool wants to run is in `raw_input`, not in `kind`.
95 111
#[derive(Debug, Clone)]
112
pub struct PermissionQuery {
113
    pub kind: String,
114
    pub title: String,
115
    pub raw_input: serde_json::Value,
116
}
117
118
/// Whether the agent may do what it just asked to do.
119
///
120
/// A delegated agent has nobody to ask, so somebody has to answer for it. With
121
/// no gate the harness answers "yes" to everything, which is the right answer
122
/// for a child the reader started themselves and the wrong one for a child a
123
/// server asked for: see [`AcpHarness::permission`].
124
pub type PermissionGate = Arc<dyn Fn(&PermissionQuery) -> bool + Send + Sync>;
125
126
/// A JSON-RPC request the agent sends back to its client, other than a
127
/// permission request. `None` answers `method not found`.
128
pub type ReverseHandler =
129
    Arc<dyn Fn(&str, &serde_json::Value) -> Option<serde_json::Value> + Send + Sync>;
130
131
/// How an ACP child is started.
132
#[derive(Clone)]
96 133
pub struct AcpHarness {
97 134
    /// The binary. Defaults to `devin`; a test points it at a stand-in.
98 135
    pub command: String,
99 136
    /// The subcommand that puts it in ACP mode.
100 137
    pub args: Vec<String>,
101 138
    pub mode: Option<PermissionMode>,
139
    /// Who decides what the agent may do.
140
    ///
141
    /// `None` allows whatever the agent asks, which is what a child the reader
142
    /// started in their own terminal should get. A delegated child gets a gate
143
    /// carrying the machine's policy, and a refused request is answered with
144
    /// the agent's own `reject*` option rather than left hanging.
145
    pub permission: Option<PermissionGate>,
146
    /// Reverse requests other than `session/request_permission` — the delegated
147
    /// `git/push` among them. Unset means the agent is told the method does not
148
    /// exist, which is the honest answer for a client that cannot serve it.
149
    pub on_request: Option<ReverseHandler>,
150
    /// Reattach to this session with `session/load` instead of opening a new
151
    /// one. Refused when the agent does not report the `loadSession`
152
    /// capability: silently opening a fresh session would look like a resume
153
    /// and lose everything the earlier one knew.
154
    pub resume_session_id: Option<String>,
155
    /// The child's whole environment. `None` inherits this process's, which is
156
    /// right for a child the reader started and wrong for a delegated one —
157
    /// that would hand a server-requested agent this process's credentials.
158
    pub env: Option<Vec<(String, String)>>,
159
}
160
161
impl std::fmt::Debug for AcpHarness {
162
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
163
        // The hooks are closures and the environment may carry a credential,
164
        // so neither is printed. Everything a reader needs to identify the
165
        // child is.
166
        f.debug_struct("AcpHarness")
167
            .field("command", &self.command)
168
            .field("args", &self.args)
169
            .field("mode", &self.mode)
170
            .field("gated", &self.permission.is_some())
171
            .field("reverse_handler", &self.on_request.is_some())
172
            .field("resume_session_id", &self.resume_session_id)
173
            .field("environment_scrubbed", &self.env.is_some())
174
            .finish()
175
    }
102 176
}
103 177
104 178
impl Default for AcpHarness {

@@ -107,10 +181,27 @@ impl Default for AcpHarness {

107 181
            command: "devin".to_string(),
108 182
            args: vec!["acp".to_string()],
109 183
            mode: Some(PermissionMode::Dangerous),
184
            permission: None,
185
            on_request: None,
186
            resume_session_id: None,
187
            env: None,
110 188
        }
111 189
    }
112 190
}
113 191
192
/// Everything one run produced, for a caller that needs more than the answer.
193
#[derive(Debug, Clone, Default)]
194
pub struct AcpOutcome {
195
    /// What the agent said, and the tools it named on the way.
196
    pub answer: String,
197
    /// The ACP session, whether opened or reattached. A caller checkpoints
198
    /// this so the next run can resume it.
199
    pub session_id: String,
200
    /// The agent's own `stopReason` from `session/prompt` — `end_turn`,
201
    /// `cancelled`, `refusal`, and so on. Empty when the agent sent none.
202
    pub stop_reason: String,
203
}
204
114 205
/// How long the client waits for the agent to answer one request.
115 206
///
116 207
/// `session/prompt` is the whole turn, so this is the child's own ceiling

@@ -156,13 +247,34 @@ impl AcpHarness {

156 247
        &self,
157 248
        prompt: &str,
158 249
        cwd: &Path,
159
        mut on_event: F,
250
        on_event: F,
160 251
        cancel: &mut watch::Receiver<bool>,
161 252
    ) -> Result<String, AcpFailure>
162 253
    where
163 254
        F: FnMut(AcpEvent) + Send,
164 255
    {
165
        let mut child = Command::new(&self.command)
256
        self.run_detailed(prompt, cwd, on_event, cancel)
257
            .await
258
            .map(|outcome| outcome.answer)
259
    }
260
261
    /// The same run, reporting the session it used and why it stopped.
262
    ///
263
    /// A delegated run needs both: the session id is what a later request
264
    /// resumes, and the stop reason is the difference between an agent that
265
    /// finished and one that refused.
266
    pub async fn run_detailed<F>(
267
        &self,
268
        prompt: &str,
269
        cwd: &Path,
270
        mut on_event: F,
271
        cancel: &mut watch::Receiver<bool>,
272
    ) -> Result<AcpOutcome, AcpFailure>
273
    where
274
        F: FnMut(AcpEvent) + Send,
275
    {
276
        let mut command = Command::new(&self.command);
277
        command
166 278
            .args(&self.args)
167 279
            .current_dir(cwd)
168 280
            .stdin(Stdio::piped())

@@ -170,15 +282,17 @@ impl AcpHarness {

170 282
            .stderr(Stdio::piped())
171 283
            // Its own process group, so stopping the child stops what the
172 284
            // child started.
173
            .process_group(0)
174
            .spawn()
175
            .map_err(|error| {
176
                AcpFailure::Unstartable(if error.kind() == std::io::ErrorKind::NotFound {
177
                    format!("the `{}` command is not on PATH", self.command)
178
                } else {
179
                    format!("the `{}` command would not start: {error}", self.command)
180
                })
181
            })?;
285
            .process_group(0);
286
        if let Some(environment) = &self.env {
287
            command.env_clear().envs(environment.iter().cloned());
288
        }
289
        let mut child = command.spawn().map_err(|error| {
290
            AcpFailure::Unstartable(if error.kind() == std::io::ErrorKind::NotFound {
291
                format!("the `{}` command is not on PATH", self.command)
292
            } else {
293
                format!("the `{}` command would not start: {error}", self.command)
294
            })
295
        })?;
182 296
183 297
        // Devin's own logging. Not protocol, and there is a lot of it.
184 298
        if let Some(stderr) = child.stderr.take() {

@@ -215,14 +329,14 @@ impl AcpHarness {

215 329
        lines: &mut tokio::io::Lines<BufReader<ChildStdout>>,
216 330
        on_event: &mut F,
217 331
        cancel: &mut watch::Receiver<bool>,
218
    ) -> Result<String, AcpFailure>
332
    ) -> Result<AcpOutcome, AcpFailure>
219 333
    where
220 334
        F: FnMut(AcpEvent) + Send,
221 335
    {
222 336
        let mut seq: u64 = 0;
223 337
        let mut answer = String::new();
224 338
225
        request(
339
        let initialized = request(
226 340
            stdin,
227 341
            lines,
228 342
            &mut seq,

@@ -235,27 +349,68 @@ impl AcpHarness {

235 349
            &mut answer,
236 350
            on_event,
237 351
            cancel,
352
            self,
238 353
        )
239 354
        .await?;
240 355
241
        let opened = request(
242
            stdin,
243
            lines,
244
            &mut seq,
245
            "session/new",
246
            serde_json::json!({"cwd": cwd.to_string_lossy(), "mcpServers": []}),
247
            REQUEST_TIMEOUT,
248
            &mut answer,
249
            on_event,
250
            cancel,
251
        )
252
        .await?;
253
254
        let session_id = opened
255
            .get("sessionId")
256
            .and_then(|v| v.as_str())
257
            .ok_or_else(|| AcpFailure::Refused("the agent opened no session".to_string()))?
258
            .to_string();
356
        let session_id = match &self.resume_session_id {
357
            // Reattach. An agent that cannot load a session must say so here
358
            // rather than have a fresh, empty session passed off as a resume:
359
            // the caller asked to continue work the new session has never
360
            // heard of.
361
            Some(resume) => {
362
                let loads = initialized
363
                    .get("agentCapabilities")
364
                    .and_then(|value| value.get("loadSession"))
365
                    .and_then(|value| value.as_bool())
366
                    .unwrap_or(false);
367
                if !loads {
368
                    return Err(AcpFailure::Refused(format!(
369
                        "the `{}` agent cannot reattach a session",
370
                        self.command
371
                    )));
372
                }
373
                request(
374
                    stdin,
375
                    lines,
376
                    &mut seq,
377
                    "session/load",
378
                    serde_json::json!({
379
                        "sessionId": resume,
380
                        "cwd": cwd.to_string_lossy(),
381
                        "mcpServers": [],
382
                    }),
383
                    REQUEST_TIMEOUT,
384
                    &mut answer,
385
                    on_event,
386
                    cancel,
387
                    self,
388
                )
389
                .await?;
390
                resume.clone()
391
            }
392
            None => {
393
                let opened = request(
394
                    stdin,
395
                    lines,
396
                    &mut seq,
397
                    "session/new",
398
                    serde_json::json!({"cwd": cwd.to_string_lossy(), "mcpServers": []}),
399
                    REQUEST_TIMEOUT,
400
                    &mut answer,
401
                    on_event,
402
                    cancel,
403
                    self,
404
                )
405
                .await?;
406
                opened
407
                    .get("sessionId")
408
                    .and_then(|v| v.as_str())
409
                    .filter(|value| !value.is_empty())
410
                    .ok_or_else(|| AcpFailure::Refused("the agent opened no session".to_string()))?
411
                    .to_string()
412
            }
413
        };
259 414
        on_event(AcpEvent::Session {
260 415
            id: session_id.clone(),
261 416
        });

@@ -273,11 +428,12 @@ impl AcpHarness {

273 428
                &mut answer,
274 429
                on_event,
275 430
                cancel,
431
                self,
276 432
            )
277 433
            .await;
278 434
        }
279 435
280
        request(
436
        let finished = request(
281 437
            stdin,
282 438
            lines,
283 439
            &mut seq,

@@ -290,10 +446,19 @@ impl AcpHarness {

290 446
            &mut answer,
291 447
            on_event,
292 448
            cancel,
449
            self,
293 450
        )
294 451
        .await?;
295 452
296
        Ok(answer.trim().to_string())
453
        Ok(AcpOutcome {
454
            answer: answer.trim().to_string(),
455
            session_id,
456
            stop_reason: finished
457
                .get("stopReason")
458
                .and_then(|value| value.as_str())
459
                .unwrap_or_default()
460
                .to_string(),
461
        })
297 462
    }
298 463
}
299 464

@@ -314,6 +479,7 @@ async fn request<F>(

314 479
    answer: &mut String,
315 480
    on_event: &mut F,
316 481
    cancel: &mut watch::Receiver<bool>,
482
    harness: &AcpHarness,
317 483
) -> Result<serde_json::Value, AcpFailure>
318 484
where
319 485
    F: FnMut(AcpEvent) + Send,

@@ -361,17 +527,23 @@ where

361 527
        };
362 528
363 529
        // A reply to something asked for.
364
        let is_reply = message.get("id").and_then(|v| v.as_u64()).is_some()
365
            && message.get("method").is_none();
530
        let is_reply =
531
            message.get("id").and_then(|v| v.as_u64()).is_some() && message.get("method").is_none();
366 532
        if is_reply {
367 533
            if message.get("id").and_then(|v| v.as_u64()) != Some(id) {
368 534
                continue;
369 535
            }
370 536
            if let Some(error) = message.get("error") {
537
                // The agent's own bytes, and `serde_json` does not escape
538
                // non-ASCII, so a refusal carrying an accent or an emoji across
539
                // byte 200 panicked here and took the whole run with it.
540
                // Floored to a character boundary, as the four cuts in
541
                // `28704f72ff` were.
371 542
                let text = serde_json::to_string(error).unwrap_or_default();
543
                let end = crate::tracker::floor_char_boundary(&text, 200);
372 544
                return Err(AcpFailure::Refused(format!(
373 545
                    "the agent refused `{method}`: {}",
374
                    &text[..text.len().min(200)]
546
                    &text[..end]
375 547
                )));
376 548
            }
377 549
            return Ok(message

@@ -380,7 +552,7 @@ where

380 552
                .unwrap_or(serde_json::json!({})));
381 553
        }
382 554
383
        handle_incoming(&message, stdin, answer, on_event).await?;
555
        handle_incoming(&message, stdin, answer, on_event, harness).await?;
384 556
    }
385 557
}
386 558

@@ -390,6 +562,7 @@ async fn handle_incoming<F>(

390 562
    stdin: &mut ChildStdin,
391 563
    answer: &mut String,
392 564
    on_event: &mut F,
565
    harness: &AcpHarness,
393 566
) -> Result<(), AcpFailure>
394 567
where
395 568
    F: FnMut(AcpEvent) + Send,

@@ -404,9 +577,24 @@ where

404 577
            .get("params")
405 578
            .cloned()
406 579
            .unwrap_or(serde_json::json!({}));
407
        let outcome = match first_allow_option(&params) {
408
            Some(option) => serde_json::json!({"outcome": "selected", "optionId": option}),
409
            None => serde_json::json!({"outcome": "cancelled"}),
580
        let outcome = match &harness.permission {
581
            // Ungated: whatever the agent asks for, which is the answer for a
582
            // child the reader started themselves.
583
            None => match first_allow_option(&params) {
584
                Some(option) => serde_json::json!({"outcome": "selected", "optionId": option}),
585
                None => serde_json::json!({"outcome": "cancelled"}),
586
            },
587
            // Gated: the policy answers. A refusal picks the agent's own
588
            // `reject*` option so the agent learns it was denied and carries
589
            // on; cancelling would end the turn over one denied tool call.
590
            Some(gate) => {
591
                let query = permission_query(&params);
592
                let allowed = gate(&query);
593
                match option_of_kind(&params, if allowed { "allow" } else { "reject" }) {
594
                    Some(option) => serde_json::json!({"outcome": "selected", "optionId": option}),
595
                    None => serde_json::json!({"outcome": "cancelled"}),
596
                }
597
            }
410 598
        };
411 599
        write_line(
412 600
            stdin,

@@ -416,6 +604,32 @@ where

416 604
        return Ok(());
417 605
    }
418 606
607
    // Any other request the agent makes of its client. An unanswered request
608
    // hangs the agent, so every one gets a reply: the handler's, or the
609
    // JSON-RPC "method not found" that says this client does not serve it.
610
    if !method.is_empty() && method != "session/update" {
611
        if let Some(id) = message.get("id").and_then(|v| v.as_u64()) {
612
            let params = message
613
                .get("params")
614
                .cloned()
615
                .unwrap_or(serde_json::json!({}));
616
            let reply = match &harness.on_request {
617
                Some(handler) => handler(method, &params),
618
                None => None,
619
            };
620
            let answer_line = match reply {
621
                Some(result) => serde_json::json!({"jsonrpc": "2.0", "id": id, "result": result}),
622
                None => serde_json::json!({
623
                    "jsonrpc": "2.0",
624
                    "id": id,
625
                    "error": {"code": -32601, "message": "method not found"},
626
                }),
627
            };
628
            write_line(stdin, &answer_line).await?;
629
            return Ok(());
630
        }
631
    }
632
419 633
    if method != "session/update" {
420 634
        return Ok(());
421 635
    }

@@ -442,9 +656,16 @@ where

442 656
            });
443 657
        }
444 658
        Some("usage_update") => {
445
            let meta = update.get("_meta").cloned().unwrap_or(serde_json::json!({}));
446
            let input = meta.get("cognition.ai/inputTokens").and_then(|v| v.as_u64());
447
            let output = meta.get("cognition.ai/outputTokens").and_then(|v| v.as_u64());
659
            let meta = update
660
                .get("_meta")
661
                .cloned()
662
                .unwrap_or(serde_json::json!({}));
663
            let input = meta
664
                .get("cognition.ai/inputTokens")
665
                .and_then(|v| v.as_u64());
666
            let output = meta
667
                .get("cognition.ai/outputTokens")
668
                .and_then(|v| v.as_u64());
448 669
            if let (Some(input), Some(output)) = (input, output) {
449 670
                on_event(AcpEvent::Tokens { input, output });
450 671
            }

@@ -467,10 +688,7 @@ where

467 688
    Ok(())
468 689
}
469 690
470
async fn write_line(
471
    stdin: &mut ChildStdin,
472
    value: &serde_json::Value,
473
) -> Result<(), AcpFailure> {
691
async fn write_line(stdin: &mut ChildStdin, value: &serde_json::Value) -> Result<(), AcpFailure> {
474 692
    let mut line = serde_json::to_string(value).unwrap_or_default();
475 693
    line.push('\n');
476 694
    stdin

@@ -483,6 +701,50 @@ async fn write_line(

483 701
        .map_err(|error| AcpFailure::Refused(format!("the agent stopped reading: {error}")))
484 702
}
485 703
704
/// What the agent asked for, out of a `session/request_permission` payload.
705
///
706
/// Agents disagree about where the tool call lives — some nest it under
707
/// `toolCall`, some put `kind` and `title` at the top — so both are read. A
708
/// query that finds nothing is still a query, and a gate that sees an empty
709
/// kind should refuse rather than guess.
710
pub fn permission_query(params: &serde_json::Value) -> PermissionQuery {
711
    let empty = serde_json::json!({});
712
    let call = params.get("toolCall").unwrap_or(params);
713
    let text = |value: &serde_json::Value, name: &str| {
714
        value
715
            .get(name)
716
            .and_then(|found| found.as_str())
717
            .unwrap_or_default()
718
            .to_string()
719
    };
720
    PermissionQuery {
721
        kind: text(call, "kind"),
722
        title: text(call, "title"),
723
        raw_input: call
724
            .get("rawInput")
725
            .or_else(|| call.get("input"))
726
            .cloned()
727
            .unwrap_or(empty),
728
    }
729
}
730
731
/// The option whose `kind` starts with `prefix` — `allow` or `reject`.
732
pub fn option_of_kind(params: &serde_json::Value, prefix: &str) -> Option<String> {
733
    params
734
        .get("options")?
735
        .as_array()?
736
        .iter()
737
        .find(|option| {
738
            option
739
                .get("kind")
740
                .and_then(|value| value.as_str())
741
                .is_some_and(|kind| kind.starts_with(prefix))
742
        })
743
        .and_then(|option| option.get("optionId"))
744
        .and_then(|value| value.as_str())
745
        .map(String::from)
746
}
747
486 748
/// The option a permission request offers that lets the work continue.
487 749
pub fn first_allow_option(params: &serde_json::Value) -> Option<String> {
488 750
    let options = params.get("options")?.as_array()?;
crates/openagents-cli/src/computer.rs modified +1399 -42

@@ -13,6 +13,7 @@

13 13
//! so no working directory is reachable — and widens only where the owner
14 14
//! declares it. The version this replaces held three unconditional `true`s.
15 15
16
use crate::acp::{AcpEvent, AcpFailure, AcpHarness, PermissionQuery};
16 17
use clap::{Args, Subcommand};
17 18
use serde::{Deserialize, Serialize};
18 19
use std::collections::BTreeMap;

@@ -92,12 +93,35 @@ impl ComputerPaths {

92 93
    }
93 94
}
94 95
96
/// An ACP agent the owner declared in `computer.json`.
97
///
98
/// A declared agent widens what may be delegated here, so it is the owner's
99
/// statement rather than the server's: the controller runs `argv` and passes
100
/// through only the environment variables named in `env`.
101
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
102
pub struct AgentEntry {
103
    pub argv: Vec<String>,
104
    #[serde(default)]
105
    pub env: Vec<String>,
106
}
107
95 108
#[derive(Debug, Clone)]
96 109
pub struct PolicyConfig {
97 110
    pub tier: Tier,
98 111
    pub roots: Vec<PathBuf>,
99 112
    pub pre_approved: Vec<String>,
100 113
    pub curated_execute: Vec<String>,
114
    /// ACP agents the owner declared, by id.
115
    pub agents: BTreeMap<String, AgentEntry>,
116
    /// Whether a forge credential the server delivers with a delegation may be
117
    /// used for a delegated push from this machine.
118
    ///
119
    /// The Computers page has an `Allow scoped forge credentials on this
120
    /// computer` checkbox, and the server withholds the credential entirely
121
    /// unless it is ticked. This is the same decision made again on this side,
122
    /// because the machine is what decides what runs here — and like every
123
    /// other part of this policy it starts closed.
124
    pub scoped_forge_credentials: bool,
101 125
    pub paths: ComputerPaths,
102 126
}
103 127

@@ -110,6 +134,8 @@ impl PolicyConfig {

110 134
            roots: Vec::new(),
111 135
            pre_approved: Vec::new(),
112 136
            curated_execute: default_curated_execute(),
137
            agents: BTreeMap::new(),
138
            scoped_forge_credentials: false,
113 139
            paths,
114 140
        }
115 141
    }

@@ -138,7 +164,9 @@ struct StoredConfiguration {

138 164
    #[serde(skip_serializing_if = "Option::is_none")]
139 165
    registry_agents: Option<bool>,
140 166
    #[serde(skip_serializing_if = "Option::is_none")]
141
    agents: Option<serde_json::Value>,
167
    agents: Option<BTreeMap<String, AgentEntry>>,
168
    #[serde(skip_serializing_if = "Option::is_none")]
169
    scoped_forge_credentials: Option<bool>,
142 170
}
143 171
144 172
const MAXIMUM_CONFIGURATION_BYTES: u64 = 16_384;

@@ -204,11 +232,22 @@ pub fn load_config(paths: &ComputerPaths) -> Result<PolicyConfig, String> {

204 232
            break;
205 233
        }
206 234
    }
235
    let mut agents: BTreeMap<String, AgentEntry> = BTreeMap::new();
236
    for (id, entry) in stored.agents.unwrap_or_default() {
237
        // A declared agent with no command names nothing. Dropping it is not a
238
        // silent narrowing: it never widened anything to begin with.
239
        if id.is_empty() || entry.argv.is_empty() || agents.len() >= 32 {
240
            continue;
241
        }
242
        agents.insert(id, entry);
243
    }
207 244
    Ok(PolicyConfig {
208 245
        tier,
209 246
        roots: resolve_roots(&stored.roots.unwrap_or_default()),
210 247
        pre_approved,
211 248
        curated_execute,
249
        agents,
250
        scoped_forge_credentials: stored.scoped_forge_credentials.unwrap_or(false),
212 251
        paths: paths.clone(),
213 252
    })
214 253
}

@@ -226,7 +265,8 @@ pub fn write_config(config: &PolicyConfig) -> Result<(), String> {

226 265
        pre_approved: Some(config.pre_approved.clone()),
227 266
        curated_execute: Some(config.curated_execute.clone()),
228 267
        registry_agents: Some(false),
229
        agents: Some(serde_json::json!({})),
268
        agents: Some(config.agents.clone()),
269
        scoped_forge_credentials: Some(config.scoped_forge_credentials),
230 270
    };
231 271
    let encoded = serde_json::to_string_pretty(&stored)
232 272
        .map_err(|error| format!("the Computer configuration could not be encoded: {error}"))?;

@@ -1276,6 +1316,600 @@ fn spawn_reader<R: Read + Send + 'static>(

1276 1316
    })
1277 1317
}
1278 1318
1319
// ---------------------------------------------------------------------------
1320
// ACP delegation
1321
// ---------------------------------------------------------------------------
1322
1323
/// The longest prompt a delegation may carry, matching the TypeScript
1324
/// controller and the tool's own input ceiling.
1325
pub const MAXIMUM_PROMPT_LENGTH: usize = 32_768;
1326
/// What a delegation gets when the server names no timeout.
1327
pub const AGENT_DEFAULT_TIMEOUT_MS: u64 = 300_000;
1328
/// The most a delegation may ask for. `OpenAgents.Computer` waits an hour at
1329
/// most, so a longer local run would only outlive the caller.
1330
pub const AGENT_MAXIMUM_TIMEOUT_MS: u64 = 3_600_000;
1331
/// The most streamed output one delegation may send. `OpenAgents.Computer`
1332
/// collects 64 KiB and drops the rest, so this is where the truncation is
1333
/// decided rather than discovered.
1334
pub const AGENT_MAXIMUM_OUTPUT_BYTES: usize = 64 * 1024;
1335
pub const MAXIMUM_SESSION_ID_LENGTH: usize = 128;
1336
1337
/// How a known coding agent is put into ACP mode.
1338
///
1339
/// Every one of these is a binary the probe already looks for. An agent that is
1340
/// installed but has no ACP mode this build knows is not delegable by name; the
1341
/// owner declares it in `computer.json` instead, which is the honest way to
1342
/// widen what this machine runs.
1343
pub fn acp_invocation(agent_id: &str) -> Option<Vec<String>> {
1344
    let argv: &[&str] = match agent_id {
1345
        "devin" => &["devin", "acp"],
1346
        "opencode" => &["opencode", "acp"],
1347
        "gemini" => &["gemini", "--experimental-acp"],
1348
        _ => return None,
1349
    };
1350
    Some(argv.iter().map(|part| part.to_string()).collect())
1351
}
1352
1353
/// An agent this machine can actually run, and how.
1354
#[derive(Debug, Clone, PartialEq, Eq)]
1355
pub struct ResolvedAgent {
1356
    pub id: String,
1357
    pub argv: Vec<String>,
1358
    /// Environment variable names passed through to the child on top of the
1359
    /// scrubbed set. Only what the owner declared.
1360
    pub env: Vec<String>,
1361
    /// `configured` when the owner declared it, `local` when the probe found it.
1362
    pub source: &'static str,
1363
}
1364
1365
/// Which agents this machine will delegate to.
1366
///
1367
/// Two sources, and neither is the server: what the owner declared in
1368
/// `computer.json`, and what the probe actually found installed. An agent that
1369
/// is not on this list is refused by name — the alternative is spawning
1370
/// whatever string the server sent.
1371
pub fn agent_catalog(config: &PolicyConfig, installed: &[ToolReport]) -> Vec<ResolvedAgent> {
1372
    let mut catalog: Vec<ResolvedAgent> = Vec::new();
1373
    for tool in installed {
1374
        if !tool.present {
1375
            continue;
1376
        }
1377
        if let Some(argv) = acp_invocation(&tool.name) {
1378
            catalog.push(ResolvedAgent {
1379
                id: tool.name.clone(),
1380
                argv,
1381
                env: Vec::new(),
1382
                source: "local",
1383
            });
1384
        }
1385
    }
1386
    // A declared agent wins over a discovered one of the same id: the owner
1387
    // said how to run it.
1388
    for (id, entry) in &config.agents {
1389
        catalog.retain(|found| &found.id != id);
1390
        catalog.push(ResolvedAgent {
1391
            id: id.clone(),
1392
            argv: entry.argv.clone(),
1393
            env: entry.env.clone(),
1394
            source: "configured",
1395
        });
1396
    }
1397
    catalog.sort_by(|left, right| left.id.cmp(&right.id));
1398
    catalog
1399
}
1400
1401
/// Resolve one requested agent, or say what this machine does have.
1402
pub fn resolve_agent(catalog: &[ResolvedAgent], requested: &str) -> Result<ResolvedAgent, String> {
1403
    if let Some(found) = catalog.iter().find(|entry| entry.id == requested) {
1404
        // A declared `argv` is still an argv this machine runs. The same
1405
        // metacharacter rule every other command gets applies to it, so a
1406
        // configuration cannot become a shell.
1407
        if found.argv.iter().any(|part| has_shell_metacharacter(part)) {
1408
            return Err(format!(
1409
                "the declared command for agent {requested} contains shell metacharacters"
1410
            ));
1411
        }
1412
        return Ok(found.clone());
1413
    }
1414
    let available: Vec<&str> = catalog.iter().map(|entry| entry.id.as_str()).collect();
1415
    Err(format!(
1416
        "agent {requested} is unavailable; available agents: {}",
1417
        if available.is_empty() {
1418
            "(none)".to_string()
1419
        } else {
1420
            available.join(", ")
1421
        }
1422
    ))
1423
}
1424
1425
/// Every string in a JSON value, so a policy decision reads the whole tool
1426
/// input rather than the keys it happened to expect.
1427
fn strings_within(value: &serde_json::Value, depth: usize, found: &mut Vec<String>) {
1428
    if depth > 6 || found.len() > 64 {
1429
        return;
1430
    }
1431
    match value {
1432
        serde_json::Value::String(text) => found.push(text.clone()),
1433
        serde_json::Value::Array(items) => {
1434
            for item in items {
1435
                strings_within(item, depth + 1, found);
1436
            }
1437
        }
1438
        serde_json::Value::Object(fields) => {
1439
            for item in fields.values() {
1440
                strings_within(item, depth + 1, found);
1441
            }
1442
        }
1443
        _ => {}
1444
    }
1445
}
1446
1447
fn first_string(value: &serde_json::Value, names: &[&str]) -> Option<String> {
1448
    for name in names {
1449
        if let Some(found) = value.get(*name).and_then(|found| found.as_str()) {
1450
            if !found.is_empty() {
1451
                return Some(found.to_string());
1452
            }
1453
        }
1454
    }
1455
    None
1456
}
1457
1458
fn mentions_word(haystack: &str, word: &str) -> bool {
1459
    let bytes = haystack.as_bytes();
1460
    let mut from = 0usize;
1461
    while let Some(offset) = haystack[from..].find(word) {
1462
        let start = from + offset;
1463
        let end = start + word.len();
1464
        let before_ok = start == 0 || !(bytes[start - 1] as char).is_ascii_alphanumeric();
1465
        let after_ok = end == bytes.len() || !(bytes[end] as char).is_ascii_alphanumeric();
1466
        if before_ok && after_ok {
1467
            return true;
1468
        }
1469
        from = start + 1;
1470
    }
1471
    false
1472
}
1473
1474
/// Metacharacters that defeat a per-segment allowlist rather than separate
1475
/// segments. `ls $(curl http://x)` has `ls` as its first word and runs `curl`;
1476
/// `cat > /etc/hosts` has `cat` as its first word and writes a protected file.
1477
/// Neither is decidable by splitting, so both are refused outright.
1478
fn has_substitution_or_redirection(command: &str) -> bool {
1479
    command.contains('`')
1480
        || command.contains("$(")
1481
        || command.contains("${")
1482
        || command.contains('>')
1483
        || command.contains('<')
1484
        || command.contains('\\')
1485
        || command.contains('\n')
1486
        || command.contains('\r')
1487
}
1488
1489
/// The shell segments a command runs, split on the operators that chain them.
1490
fn command_segments(command: &str) -> Vec<String> {
1491
    let mut segments: Vec<String> = Vec::new();
1492
    let mut current = String::new();
1493
    let mut rest = command;
1494
    while let Some(index) = rest.find(['&', '|', ';']) {
1495
        current.push_str(&rest[..index]);
1496
        segments.push(std::mem::take(&mut current));
1497
        let tail = &rest[index..];
1498
        let skip = if tail.starts_with("&&") || tail.starts_with("||") {
1499
            2
1500
        } else {
1501
            1
1502
        };
1503
        rest = &tail[skip..];
1504
    }
1505
    current.push_str(rest);
1506
    segments.push(current);
1507
    segments
1508
}
1509
1510
/// Decide one thing a delegated agent asked permission to do.
1511
///
1512
/// This is the same policy the `run` path applies, read through the agent's
1513
/// own vocabulary: the tier ceiling decides whether anything at all is
1514
/// permitted, a denied binary or a protected path is refused before the tier
1515
/// is consulted, an edit must land inside a declared root, and an execute must
1516
/// be an allowlisted binary in every segment it chains. A delegated agent is
1517
/// not a way around any of it.
1518
pub fn agent_permission(config: &PolicyConfig, cwd: &Path, query: &PermissionQuery) -> Decision {
1519
    let mut material: Vec<String> = vec![query.title.clone()];
1520
    strings_within(&query.raw_input, 0, &mut material);
1521
1522
    for text in &material {
1523
        let lowered = text.to_ascii_lowercase();
1524
        if let Some(denied) = DENIED_COMMANDS
1525
            .iter()
1526
            .find(|candidate| mentions_word(&lowered, candidate))
1527
        {
1528
            return refuse(
1529
                RefusalReason::DeniedCommand,
1530
                &format!("{denied} is denied on this machine"),
1531
            );
1532
        }
1533
        if let Some(fragment) = DENIED_PATH_FRAGMENTS
1534
            .iter()
1535
            .chain(std::iter::once(&DENIED_PATH_FRAGMENT_KEYCHAINS))
1536
            .find(|candidate| text.contains(**candidate))
1537
        {
1538
            return refuse(
1539
                RefusalReason::DeniedArgument,
1540
                &format!("the request references a protected path: {fragment}"),
1541
            );
1542
        }
1543
    }
1544
1545
    if !tier_allows(config.tier, Tier::Curated) {
1546
        return refuse(
1547
            RefusalReason::TierInsufficient,
1548
            "probe tier permits fixed discovery only",
1549
        );
1550
    }
1551
    if tier_allows(config.tier, Tier::Shell) {
1552
        return Decision::Allowed {
1553
            needs_confirmation: false,
1554
        };
1555
    }
1556
1557
    let within_declared = |candidate: &str| {
1558
        let resolved = if candidate.starts_with('~') {
1559
            resolve_root(candidate)
1560
        } else if Path::new(candidate).is_absolute() {
1561
            normalize_path(Path::new(candidate))
1562
        } else {
1563
            normalize_path(&cwd.join(candidate))
1564
        };
1565
        !config.roots.is_empty() && config.roots.iter().any(|root| within_root(&resolved, root))
1566
    };
1567
1568
    match query.kind.as_str() {
1569
        "read" | "search" | "fetch" | "think" => Decision::Allowed {
1570
            needs_confirmation: false,
1571
        },
1572
        "edit" | "write" | "delete" | "move" => {
1573
            let Some(path) =
1574
                first_string(&query.raw_input, &["path", "file_path", "filePath", "file"])
1575
            else {
1576
                return refuse(
1577
                    RefusalReason::RootNotDeclared,
1578
                    "the agent named no path to write, so it cannot be placed inside a root",
1579
                );
1580
            };
1581
            if within_declared(&path) {
1582
                Decision::Allowed {
1583
                    needs_confirmation: false,
1584
                }
1585
            } else {
1586
                refuse(
1587
                    RefusalReason::RootNotDeclared,
1588
                    "the path is outside every declared root",
1589
                )
1590
            }
1591
        }
1592
        "execute" => {
1593
            let command = first_string(&query.raw_input, &["command", "cmd", "commandLine"])
1594
                .unwrap_or_else(|| query.title.clone());
1595
            if command.trim().is_empty() {
1596
                return refuse(
1597
                    RefusalReason::EmptyCommand,
1598
                    "the agent named no command to run",
1599
                );
1600
            }
1601
            if has_substitution_or_redirection(&command) {
1602
                return refuse(
1603
                    RefusalReason::ShellMetacharacter,
1604
                    "the command uses substitution or redirection, which no allowlist can bound",
1605
                );
1606
            }
1607
            for segment in command_segments(&command) {
1608
                let mut words = segment.split_whitespace();
1609
                let Some(first) = words.next() else {
1610
                    // An empty segment is what a trailing `&&` leaves. It runs
1611
                    // nothing, so it decides nothing.
1612
                    continue;
1613
                };
1614
                let name = command_name(first);
1615
                if name == "cd" {
1616
                    // `cd` is permitted only where the policy already reaches.
1617
                    // Otherwise it is the first half of an escape from every
1618
                    // declared root.
1619
                    let target = words.next().unwrap_or("");
1620
                    if target.is_empty() || !within_declared(target) {
1621
                        return refuse(
1622
                            RefusalReason::RootNotDeclared,
1623
                            "the command changes directory outside every declared root",
1624
                        );
1625
                    }
1626
                    continue;
1627
                }
1628
                if !config.curated_execute.contains(&name) {
1629
                    return refuse(
1630
                        RefusalReason::NotAllowlisted,
1631
                        &format!("{name} is not in the curated allowlist"),
1632
                    );
1633
                }
1634
            }
1635
            Decision::Allowed {
1636
                needs_confirmation: false,
1637
            }
1638
        }
1639
        other => refuse(
1640
            RefusalReason::NotAllowlisted,
1641
            &format!(
1642
                "{} is not a permitted action at the curated tier",
1643
                if other.is_empty() {
1644
                    "an unnamed action"
1645
                } else {
1646
                    other
1647
                }
1648
            ),
1649
        ),
1650
    }
1651
}
1652
1653
// ---------------------------------------------------------------------------
1654
// delegated push
1655
// ---------------------------------------------------------------------------
1656
1657
/// A forge credential the server delivered with one delegation.
1658
///
1659
/// It is scoped to a single repository and a single branch, and it lives only
1660
/// as long as the delegation. It never reaches the child's environment, the
1661
/// journal, or the wire: the only thing that ever reads it is the credential
1662
/// helper this machine writes for one `git push`.
1663
#[derive(Clone)]
1664
pub struct ForgeCredentials {
1665
    pub token: crate::auth::Secret,
1666
    pub repository: String,
1667
    pub branch: String,
1668
}
1669
1670
impl std::fmt::Debug for ForgeCredentials {
1671
    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1672
        f.debug_struct("ForgeCredentials")
1673
            .field("repository", &self.repository)
1674
            .field("branch", &self.branch)
1675
            .finish_non_exhaustive()
1676
    }
1677
}
1678
1679
/// Read the credential out of a delegation payload, if it carries a whole one.
1680
///
1681
/// A token without the repository and branch it is scoped to is not usable:
1682
/// there would be nothing to check the push against. That is reported as
1683
/// incomplete rather than quietly ignored.
1684
pub fn forge_credentials(payload: &serde_json::Value) -> Option<ForgeCredentials> {
1685
    let raw = payload
1686
        .get("assignment_credential")
1687
        .or_else(|| payload.get("forge_credentials"))?;
1688
    let (token, repository, branch) = if let Some(text) = raw.as_str() {
1689
        (
1690
            text.to_string(),
1691
            first_string(payload, &["assignment_repository", "repository"])?,
1692
            first_string(payload, &["assignment_branch", "branch"])?,
1693
        )
1694
    } else {
1695
        let token = first_string(raw, &["token", "value", "password", "access_token"])?;
1696
        let repository = first_string(raw, &["repository"])
1697
            .or_else(|| first_string(payload, &["assignment_repository", "repository"]))?;
1698
        let branch = first_string(raw, &["branch"])
1699
            .or_else(|| first_string(payload, &["assignment_branch", "branch"]))?;
1700
        (token, repository, branch)
1701
    };
1702
    if token.trim().is_empty() || repository.is_empty() || branch.is_empty() {
1703
        return None;
1704
    }
1705
    Some(ForgeCredentials {
1706
        token: crate::auth::Secret::new(token),
1707
        repository,
1708
        branch,
1709
    })
1710
}
1711
1712
fn canonical_branch(branch: &str) -> String {
1713
    if branch.starts_with("refs/heads/") {
1714
        branch.to_string()
1715
    } else {
1716
        format!("refs/heads/{branch}")
1717
    }
1718
}
1719
1720
/// A refspec is the assigned branch, pushed forward, and nothing else.
1721
///
1722
/// A scoped credential that could push any ref would not be scoped. Force,
1723
/// deletion, multi-ref, and any other branch are all refused here rather than
1724
/// at the forge, so this machine is not the thing that tried.
1725
pub fn validate_refspec(refspec: &str, branch: &str) -> Result<(), String> {
1726
    if refspec.is_empty() {
1727
        return Err("the refspec is empty".to_string());
1728
    }
1729
    if refspec.chars().any(|c| c.is_whitespace() || c == ',') {
1730
        return Err("a multi-ref push is not allowed".to_string());
1731
    }
1732
    if refspec.starts_with('+') || refspec.starts_with('-') {
1733
        return Err("a force or option refspec is not allowed".to_string());
1734
    }
1735
    let target = canonical_branch(branch);
1736
    let matches = |value: &str| value == branch || value == target;
1737
    match refspec.split_once(':') {
1738
        Some((source, destination)) => {
1739
            if destination.is_empty() {
1740
                return Err("a refspec with an empty destination is not allowed".to_string());
1741
            }
1742
            if !matches(source) || !matches(destination) {
1743
                return Err(format!("the refspec is not the assigned branch {target}"));
1744
            }
1745
        }
1746
        None => {
1747
            if !matches(refspec) {
1748
                return Err(format!("the refspec is not the assigned branch {target}"));
1749
            }
1750
        }
1751
    }
1752
    Ok(())
1753
}
1754
1755
/// Push the assigned branch with the delivered credential.
1756
///
1757
/// The token never becomes an argument, an environment variable, or part of a
1758
/// URL. It is written to a file only this user can read, inside a directory
1759
/// only this user can enter, and a helper script hands it over only when git
1760
/// asks for exactly the host and path of the assigned repository. Everything
1761
/// is removed when the push ends, whichever way it ends.
1762
pub fn push_delegated(
1763
    directory: &Path,
1764
    remote: &str,
1765
    refspec: &str,
1766
    credentials: &ForgeCredentials,
1767
    origin: &str,
1768
) -> Result<(), String> {
1769
    validate_refspec(refspec, &credentials.branch)?;
1770
    let remote = crate::repo::validate_remote_name(remote).map_err(|error| error.to_string())?;
1771
    let listed = Command::new("git")
1772
        .args(["remote", "get-url", "--", &remote])
1773
        .current_dir(directory)
1774
        .stdin(Stdio::null())
1775
        .stderr(Stdio::null())
1776
        .output()
1777
        .map_err(|error| format!("git remote get-url could not start: {error}"))?;
1778
    if !listed.status.success() {
1779
        return Err(format!("this checkout has no {remote} remote"));
1780
    }
1781
    let url = String::from_utf8_lossy(&listed.stdout).trim().to_string();
1782
    let actual =
1783
        crate::repo::repository_from_remote_url(origin, &url).map_err(|error| error.to_string())?;
1784
    if actual != credentials.repository {
1785
        return Err(format!(
1786
            "the remote repository is {actual}, not the assigned {}",
1787
            credentials.repository
1788
        ));
1789
    }
1790
    let parsed =
1791
        reqwest::Url::parse(&url).map_err(|_| "that remote URL cannot be read".to_string())?;
1792
    let host = parsed.host_str().unwrap_or_default().to_string();
1793
    let host = match parsed.port() {
1794
        Some(port) => format!("{host}:{port}"),
1795
        None => host,
1796
    };
1797
    let path = parsed.path().trim_start_matches('/').to_string();
1798
    let url_origin = format!("{}://{host}", parsed.scheme());
1799
1800
    let workspace = private_temporary_directory()?;
1801
    let outcome = (|| -> Result<(), String> {
1802
        let helper = write_credential_helper(&workspace, credentials.token.expose(), &host, &path)?;
1803
        let mut command = Command::new("git");
1804
        command
1805
            .args([
1806
                "-c",
1807
                "credential.helper=",
1808
                "-c",
1809
                &format!("credential.{url_origin}.helper=!{}", helper.display()),
1810
                "push",
1811
                "--",
1812
                &remote,
1813
                refspec,
1814
            ])
1815
            .current_dir(directory)
1816
            .env_clear()
1817
            .envs(scrubbed_environment())
1818
            .env("GIT_TERMINAL_PROMPT", "0")
1819
            .stdin(Stdio::null())
1820
            .stdout(Stdio::null())
1821
            .stderr(Stdio::piped());
1822
        let output = command
1823
            .output()
1824
            .map_err(|error| format!("git push could not start: {error}"))?;
1825
        if output.status.success() {
1826
            return Ok(());
1827
        }
1828
        let stderr = redact(&String::from_utf8_lossy(&output.stderr));
1829
        Err(format!("git push failed: {}", bounded(stderr.trim(), 400)))
1830
    })();
1831
    let _ = std::fs::remove_dir_all(&workspace);
1832
    outcome
1833
}
1834
1835
fn private_temporary_directory() -> Result<PathBuf, String> {
1836
    let base = std::env::temp_dir();
1837
    let unique = format!(
1838
        "oa-delegated-push-{}-{}",
1839
        std::process::id(),
1840
        SystemTime::now()
1841
            .duration_since(UNIX_EPOCH)
1842
            .map(|value| value.as_nanos())
1843
            .unwrap_or_default()
1844
    );
1845
    let directory = base.join(unique);
1846
    std::fs::create_dir(&directory)
1847
        .map_err(|error| format!("could not create a private working directory: {error}"))?;
1848
    #[cfg(unix)]
1849
    {
1850
        use std::os::unix::fs::PermissionsExt;
1851
        std::fs::set_permissions(&directory, std::fs::Permissions::from_mode(0o700))
1852
            .map_err(|error| format!("could not secure the private working directory: {error}"))?;
1853
    }
1854
    Ok(directory)
1855
}
1856
1857
/// Stage the token and the helper that hands it over.
1858
///
1859
/// Public because the helper is a security boundary rather than an
1860
/// implementation detail: what it answers for, and what it stays silent for,
1861
/// is asserted directly by running it.
1862
pub fn write_credential_helper(
1863
    workspace: &Path,
1864
    token: &str,
1865
    host: &str,
1866
    path: &str,
1867
) -> Result<PathBuf, String> {
1868
    let token_path = workspace.join("token");
1869
    let helper_path = workspace.join("helper");
1870
    std::fs::write(&token_path, format!("{token}\n"))
1871
        .map_err(|error| format!("could not stage the delegated credential: {error}"))?;
1872
    let script = format!(
1873
        r#"#!/bin/sh
1874
if [ "$1" != "get" ]; then
1875
  exit 0
1876
fi
1877
host=""
1878
path=""
1879
while IFS= read -r line; do
1880
  [ -z "$line" ] && break
1881
  case "$line" in
1882
    host=*) host="${{line#host=}}" ;;
1883
    path=*) path="${{line#path=}}" ;;
1884
  esac
1885
done
1886
if [ "$host" != {host} ] || [ "$path" != {path} ]; then
1887
  exit 0
1888
fi
1889
PASSWORD=$(tr -d '\n' < {token_path})
1890
printf 'username=openagents\npassword=%s\n\n' "$PASSWORD"
1891
"#,
1892
        host = shell_quote(host),
1893
        path = shell_quote(path),
1894
        token_path = shell_quote(&token_path.display().to_string()),
1895
    );
1896
    std::fs::write(&helper_path, script)
1897
        .map_err(|error| format!("could not stage the credential helper: {error}"))?;
1898
    #[cfg(unix)]
1899
    {
1900
        use std::os::unix::fs::PermissionsExt;
1901
        std::fs::set_permissions(&token_path, std::fs::Permissions::from_mode(0o600))
1902
            .map_err(|error| format!("could not secure the delegated credential: {error}"))?;
1903
        std::fs::set_permissions(&helper_path, std::fs::Permissions::from_mode(0o700))
1904
            .map_err(|error| format!("could not secure the credential helper: {error}"))?;
1905
    }
1906
    Ok(helper_path)
1907
}
1908
1909
fn shell_quote(value: &str) -> String {
1910
    format!("'{}'", value.replace('\'', "'\"'\"'"))
1911
}
1912
1279 1913
// ---------------------------------------------------------------------------
1280 1914
// probe
1281 1915
// ---------------------------------------------------------------------------

@@ -1307,6 +1941,18 @@ pub struct WorktreeReport {

1307 1941
    pub git: bool,
1308 1942
}
1309 1943
1944
/// One delegable ACP agent, as the server records it.
1945
///
1946
/// `OpenAgents.ComputerAgentJobs.start/4` refuses any `agent_id` that is not in
1947
/// `last_probe["acp_agents"]`, so a report without this list is a machine the
1948
/// server will not delegate to at all — whatever is installed on it.
1949
#[derive(Debug, Clone, Serialize, Deserialize)]
1950
pub struct AcpAgentReport {
1951
    pub id: String,
1952
    pub source: String,
1953
    pub version: String,
1954
}
1955
1310 1956
#[derive(Debug, Clone, Serialize, Deserialize)]
1311 1957
pub struct ProbeReport {
1312 1958
    pub schema: String,

@@ -1316,6 +1962,7 @@ pub struct ProbeReport {

1316 1962
    pub toolchains: Vec<ToolReport>,
1317 1963
    pub roots: Vec<String>,
1318 1964
    pub worktrees: Vec<WorktreeReport>,
1965
    pub acp_agents: Vec<AcpAgentReport>,
1319 1966
}
1320 1967
1321 1968
pub const CODING_AGENT_CATALOG: [(&str, &str); 11] = [

@@ -1507,9 +2154,40 @@ pub fn probe(roots: &[PathBuf]) -> ProbeReport {

1507 2154
            .map(|root| root.display().to_string())
1508 2155
            .collect(),
1509 2156
        worktrees: roots.iter().map(|root| worktree_report(root)).collect(),
2157
        // Filled in by `probe_for`, which is the only caller that knows what
2158
        // the owner declared. A probe with no policy in hand reports no
2159
        // delegable agents rather than guessing at the catalog.
2160
        acp_agents: Vec::new(),
1510 2161
    }
1511 2162
}
1512 2163
2164
/// The same probe, carrying the delegation catalog this machine will honour.
2165
///
2166
/// The server refuses an `agent_id` that is not in this list, so what it says
2167
/// is what can be delegated — and it is built from the same two sources the
2168
/// controller resolves against, not from a hardcoded roster.
2169
pub fn probe_for(config: &PolicyConfig) -> ProbeReport {
2170
    let mut report = probe(&config.roots);
2171
    let versions: BTreeMap<&str, &str> = report
2172
        .coding_agents
2173
        .iter()
2174
        .map(|tool| (tool.name.as_str(), tool.version.as_str()))
2175
        .collect();
2176
    report.acp_agents = agent_catalog(config, &report.coding_agents)
2177
        .into_iter()
2178
        .map(|entry| AcpAgentReport {
2179
            version: versions
2180
                .get(entry.id.as_str())
2181
                .copied()
2182
                .unwrap_or_default()
2183
                .to_string(),
2184
            source: entry.source.to_string(),
2185
            id: entry.id,
2186
        })
2187
        .collect();
2188
    report
2189
}
2190
1513 2191
/// The bare host facts, kept for callers that only want the machine shape.
1514 2192
#[derive(Debug, Clone, Serialize, Deserialize)]
1515 2193
pub struct ComputerProbeResult {

@@ -2065,6 +2743,7 @@ pub fn reconnectable_transport_reason(reason: &str) -> bool {

2065 2743
/// Everything the server asks for goes through [`decide`] first and lands in the
2066 2744
/// journal either way. A frame this build has no handler for is refused as
2067 2745
/// `unsupported` rather than silently ignored.
2746
#[allow(clippy::too_many_arguments)]
2068 2747
fn serve_connection(
2069 2748
    origin: &str,
2070 2749
    token: &crate::auth::Secret,

@@ -2072,6 +2751,7 @@ fn serve_connection(

2072 2751
    hello: &serde_json::Value,
2073 2752
    config: &PolicyConfig,
2074 2753
    journal: &Journal,
2754
    catalog: &[ResolvedAgent],
2075 2755
    mut on_event: impl FnMut(&str),
2076 2756
) -> ConnectionEnd {
2077 2757
    use tungstenite::{client::IntoClientRequest, Message};

@@ -2110,11 +2790,13 @@ fn serve_connection(

2110 2790
    let mut heartbeat_pending = false;
2111 2791
    let mut heartbeat_ref = String::new();
2112 2792
    let mut joined = false;
2793
    let mut hello_ref = String::new();
2113 2794
2114 2795
    let (sender, receiver): (Sender<Outgoing>, Receiver<Outgoing>) = std::sync::mpsc::channel();
2115 2796
    let active = Arc::new(std::sync::atomic::AtomicUsize::new(0));
2116 2797
    let cancellations: Arc<Mutex<BTreeMap<String, Cancellation>>> =
2117 2798
        Arc::new(Mutex::new(BTreeMap::new()));
2799
    let agent_jobs: Arc<Mutex<BTreeMap<String, AgentJob>>> = Arc::new(Mutex::new(BTreeMap::new()));
2118 2800
2119 2801
    let send_join = phoenix_frame(
2120 2802
        Some(join_ref),

@@ -2232,6 +2914,43 @@ fn serve_connection(

2232 2914
            continue;
2233 2915
        }
2234 2916
        if event == "phx_reply" {
2917
            // The server's answer to `hello` carries whether it accepted this
2918
            // machine's probe report — the inventory it later decides
2919
            // delegation against. A rejected hello used to be invisible here,
2920
            // which made a machine that had announced nothing look identical
2921
            // to one that had announced everything.
2922
            if !hello_ref.is_empty() && response_ref == hello_ref {
2923
                let accepted = payload.get("status").and_then(|v| v.as_str()) == Some("ok");
2924
                let detail = if accepted {
2925
                    "hello accepted".to_string()
2926
                } else {
2927
                    format!(
2928
                        "hello refused: {}",
2929
                        payload
2930
                            .get("response")
2931
                            .and_then(|value| value.get("reason"))
2932
                            .and_then(|value| value.as_str())
2933
                            .unwrap_or("unknown")
2934
                    )
2935
                };
2936
                on_event(if accepted {
2937
                    "hello_ok"
2938
                } else {
2939
                    "hello_refused"
2940
                });
2941
                let announcement = CommandRequest {
2942
                    argv: vec!["<hello>".to_string()],
2943
                    cwd: String::new(),
2944
                };
2945
                let _ = journal.append(
2946
                    "connection",
2947
                    &announcement,
2948
                    "transport",
2949
                    if accepted { "accepted" } else { "refused" },
2950
                    &detail,
2951
                );
2952
                continue;
2953
            }
2235 2954
            if response_ref != join_ref {
2236 2955
                continue;
2237 2956
            }

@@ -2239,13 +2958,8 @@ fn serve_connection(

2239 2958
                joined = true;
2240 2959
                on_event("joined");
2241 2960
                reference += 1;
2242
                let frame = phoenix_frame(
2243
                    Some(join_ref),
2244
                    &reference.to_string(),
2245
                    &topic,
2246
                    "hello",
2247
                    hello,
2248
                );
2961
                hello_ref = reference.to_string();
2962
                let frame = phoenix_frame(Some(join_ref), &hello_ref, &topic, "hello", hello);
2249 2963
                let _ = socket.send(Message::Text(frame.into()));
2250 2964
            } else {
2251 2965
                let refusal = payload

@@ -2297,7 +3011,7 @@ fn serve_connection(

2297 3011
                    "pending",
2298 3012
                    "read-only probe requested",
2299 3013
                );
2300
                let report = probe(&config.roots);
3014
                let report = probe_for(config);
2301 3015
                let _ = journal.append(
2302 3016
                    &request_id,
2303 3017
                    &request,

@@ -2329,49 +3043,36 @@ fn serve_connection(

2329 3043
                    &cancellations,
2330 3044
                );
2331 3045
            }
2332
            // ACP delegation is a separate subsystem this build does not carry,
2333
            // and `devin` is a second delegation kind it does not carry either.
2334
            // Saying so is the honest answer; pretending to accept either would
2335
            // leave the server waiting for output that never comes.
2336
            //
2337 3046
            // `OpenAgentsWeb.ComputerChannel` pushes a request by the name of
2338 3047
            // its kind — `handle_info({:computer_request, kind, …})` for `kind
2339 3048
            // in [:run, :devin, :agent]` does `push(socket,
2340 3049
            // Atom.to_string(kind), …)` — so every one of those names arrives
2341 3050
            // here as an event carrying a `request_id` the server is tracking.
2342
            // `devin` used to fall through to the catch-all below and be
2343
            // dropped without a frame or a journal line, which is the exact
2344
            // failure this arm was written to prevent, one kind over.
3051
            // `devin` once fell through to the catch-all below and was dropped
3052
            // without a frame or a journal line, leaving the server blocked on
3053
            // a request this side had already thrown away. Both names are
3054
            // served, and both end in a terminal frame.
2345 3055
            "agent" | "devin" => {
2346
                let request = CommandRequest {
2347
                    argv: vec![format!("<{event}>")],
2348
                    cwd: String::new(),
2349
                };
2350
                let _ = journal.append(
3056
                handle_agent(
3057
                    &event,
2351 3058
                    &request_id,
2352
                    &request,
2353
                    "unsupported",
2354
                    "refused",
2355
                    "ACP delegation is unavailable",
2356
                );
2357
                reference += 1;
2358
                let frame = phoenix_frame(
2359
                    Some(join_ref),
2360
                    &reference.to_string(),
2361
                    &topic,
2362
                    "refused",
2363
                    &serde_json::json!({
2364
                        "request_id": request_id,
2365
                        "reason": "unsupported",
2366
                        "detail": "ACP delegation is unavailable",
2367
                    }),
3059
                    &payload,
3060
                    origin,
3061
                    config,
3062
                    journal,
3063
                    &sender,
3064
                    &active,
3065
                    &agent_jobs,
3066
                    catalog,
2368 3067
                );
2369
                let _ = socket.send(Message::Text(frame.into()));
2370 3068
            }
2371 3069
            "cancel" => {
2372 3070
                if let Some(cancellation) = cancellations.lock().unwrap().get(&request_id) {
2373 3071
                    cancellation.cancel();
2374 3072
                }
3073
                if let Some(job) = agent_jobs.lock().unwrap().get(&request_id) {
3074
                    let _ = job.cancel.send(true);
3075
                }
2375 3076
                let request = CommandRequest {
2376 3077
                    argv: vec!["<cancel>".to_string()],
2377 3078
                    cwd: String::new(),

@@ -2396,6 +3097,12 @@ fn serve_connection(

2396 3097
    for cancellation in cancellations.lock().unwrap().values() {
2397 3098
        cancellation.cancel();
2398 3099
    }
3100
    // A delegation whose channel is gone has nowhere to report. Stopping the
3101
    // agent is what keeps a lost connection from leaving a coding agent running
3102
    // in the owner's checkout with nothing listening.
3103
    for job in agent_jobs.lock().unwrap().values() {
3104
        let _ = job.cancel.send(true);
3105
    }
2399 3106
    let _ = socket.close(None);
2400 3107
    end
2401 3108
}

@@ -2620,6 +3327,618 @@ fn handle_run(

2620 3327
    });
2621 3328
}
2622 3329
3330
// ---------------------------------------------------------------------------
3331
// serving one delegation
3332
// ---------------------------------------------------------------------------
3333
3334
/// One live ACP delegation.
3335
///
3336
/// `route` is the request the channel currently answers on, which a reattach
3337
/// moves: the delegation outlives the request that started it, and a caller
3338
/// that comes back after a reconnect gets the same session's output on its own
3339
/// `request_id` rather than a second agent.
3340
struct AgentJob {
3341
    session: Arc<Mutex<String>>,
3342
    route: Arc<Mutex<String>>,
3343
    cancel: tokio::sync::watch::Sender<bool>,
3344
}
3345
3346
/// What the delegated agent may be told about this machine.
3347
///
3348
/// The scrubbed set every command gets, plus exactly the variable names the
3349
/// owner declared for this agent. This process's own credentials are not in
3350
/// either list, and neither is the machine token.
3351
fn agent_environment(entry: &ResolvedAgent) -> Vec<(String, String)> {
3352
    let mut environment = scrubbed_environment();
3353
    for name in &entry.env {
3354
        if ENVIRONMENT_NAMES.contains(&name.as_str()) {
3355
            continue;
3356
        }
3357
        if let Ok(value) = std::env::var(name) {
3358
            environment.push((name.clone(), value));
3359
        }
3360
    }
3361
    environment
3362
}
3363
3364
/// The coding agents this host actually has, for the delegation catalog.
3365
pub fn installed_coding_agents(roots: &[PathBuf]) -> Vec<ToolReport> {
3366
    let cwd = roots
3367
        .first()
3368
        .filter(|root| root.is_dir())
3369
        .cloned()
3370
        .unwrap_or_else(|| std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")));
3371
    probe_catalog(&CODING_AGENT_CATALOG, &cwd)
3372
}
3373
3374
/// Serve one `agent` (or legacy `devin`) request.
3375
///
3376
/// Every path through this function ends in exactly one terminal frame —
3377
/// `refused` or `exit` — carrying the `request_id` the server is waiting on,
3378
/// and every refusal is journaled with the reason that produced it. A request
3379
/// that reached here and got neither is the defect this shape exists to
3380
/// prevent.
3381
#[allow(clippy::too_many_arguments)]
3382
fn handle_agent(
3383
    event: &str,
3384
    request_id: &str,
3385
    payload: &serde_json::Value,
3386
    origin: &str,
3387
    config: &PolicyConfig,
3388
    journal: &Journal,
3389
    sender: &Sender<Outgoing>,
3390
    active: &Arc<std::sync::atomic::AtomicUsize>,
3391
    agents: &Arc<Mutex<BTreeMap<String, AgentJob>>>,
3392
    catalog: &[ResolvedAgent],
3393
) {
3394
    use std::sync::atomic::Ordering;
3395
3396
    // `OpenAgents.Computer.request_devin/3` now sets `agent_id` itself, but the
3397
    // legacy shape reached this machine as the `devin` event with a
3398
    // `session_id` and no agent named. Reading both is what keeps an older
3399
    // caller from being answered `invalid_request` for asking the old way.
3400
    let agent_id = payload
3401
        .get("agent_id")
3402
        .and_then(|value| value.as_str())
3403
        .map(str::to_string)
3404
        .unwrap_or_else(|| {
3405
            if event == "devin" {
3406
                "devin".to_string()
3407
            } else {
3408
                String::new()
3409
            }
3410
        });
3411
    let prompt = payload
3412
        .get("prompt")
3413
        .and_then(|value| value.as_str())
3414
        .unwrap_or_default()
3415
        .to_string();
3416
    let requested_cwd = payload
3417
        .get("cwd")
3418
        .and_then(|value| value.as_str())
3419
        .unwrap_or_default()
3420
        .to_string();
3421
    let resume = payload
3422
        .get("resume_session_id")
3423
        .or_else(|| payload.get("session_id"))
3424
        .and_then(|value| value.as_str())
3425
        .filter(|value| !value.is_empty())
3426
        .map(|value| {
3427
            value
3428
                .chars()
3429
                .take(MAXIMUM_SESSION_ID_LENGTH)
3430
                .collect::<String>()
3431
        });
3432
3433
    let request = CommandRequest {
3434
        argv: vec![
3435
            format!("<{event}>"),
3436
            agent_id.chars().take(64).collect::<String>(),
3437
        ],
3438
        cwd: requested_cwd.clone(),
3439
    };
3440
    let _ = journal.append(
3441
        request_id,
3442
        &request,
3443
        "received",
3444
        "pending",
3445
        "ACP delegation received",
3446
    );
3447
3448
    let refuse_now = |reason: &str, detail: &str| {
3449
        let _ = journal.append(request_id, &request, reason, "refused", detail);
3450
        let _ = sender.send(Outgoing::Frame {
3451
            event: "refused".to_string(),
3452
            payload: serde_json::json!({
3453
                "request_id": request_id,
3454
                "reason": reason,
3455
                "detail": detail,
3456
            }),
3457
        });
3458
    };
3459
3460
    // The credential is read before anything can refuse, so its delivery is
3461
    // recorded even on a request that never runs. The token itself is never
3462
    // written anywhere: only whether one arrived whole.
3463
    let delivered = forge_credentials(payload);
3464
    let credentials = match (&delivered, config.scoped_forge_credentials) {
3465
        (Some(found), true) => {
3466
            let _ = journal.append(
3467
                request_id,
3468
                &request,
3469
                "credentials_delivered",
3470
                "configured",
3471
                &format!(
3472
                    "scoped forge credentials for {} on {}",
3473
                    found.repository, found.branch
3474
                ),
3475
            );
3476
            delivered.clone()
3477
        }
3478
        (Some(_), false) => {
3479
            // The server only sends one when the owner ticked the box on the
3480
            // Computers page. This machine has not been told the same thing,
3481
            // and the machine is what decides what runs here.
3482
            let _ = journal.append(
3483
                request_id,
3484
                &request,
3485
                "credentials_refused",
3486
                "refused",
3487
                "scoped forge credentials are not enabled in the local Computer configuration",
3488
            );
3489
            None
3490
        }
3491
        (None, _) => {
3492
            if payload.get("assignment_credential").is_some()
3493
                || payload.get("forge_credentials").is_some()
3494
            {
3495
                let _ = journal.append(
3496
                    request_id,
3497
                    &request,
3498
                    "credentials_delivered",
3499
                    "incomplete",
3500
                    "a forge credential arrived without the repository and branch it is scoped to",
3501
                );
3502
            }
3503
            None
3504
        }
3505
    };
3506
3507
    if agent_id.is_empty() || prompt.trim().is_empty() || prompt.len() > MAXIMUM_PROMPT_LENGTH {
3508
        refuse_now(
3509
            "invalid_request",
3510
            "agent_id, prompt, and cwd are required and must be bounded",
3511
        );
3512
        return;
3513
    }
3514
    if agent_id.len() > 64
3515
        || has_shell_metacharacter(&agent_id)
3516
        || agent_id.contains('/')
3517
        || agent_id.contains('\\')
3518
    {
3519
        refuse_now(
3520
            "invalid_request",
3521
            "the agent id is not a name this machine can resolve",
3522
        );
3523
        return;
3524
    }
3525
    if requested_cwd.is_empty() || requested_cwd.len() > 4_096 {
3526
        refuse_now(
3527
            "invalid_request",
3528
            "agent_id, prompt, and cwd are required and must be bounded",
3529
        );
3530
        return;
3531
    }
3532
3533
    // The tier the server asked for cannot exceed the local ceiling, and the
3534
    // ceiling itself has to reach past `probe` before anything is delegated at
3535
    // all: a probe-tier machine answers fixed discovery and nothing else.
3536
    if let Some(requested) = payload.get("tier").and_then(|value| value.as_str()) {
3537
        if let Some(requested) = Tier::parse(requested) {
3538
            if !tier_allows(config.tier, requested) {
3539
                refuse_now(
3540
                    "tier_insufficient",
3541
                    "the requested tier exceeds the local ceiling",
3542
                );
3543
                return;
3544
            }
3545
        }
3546
    }
3547
    if !tier_allows(config.tier, Tier::Curated) {
3548
        refuse_now(
3549
            "tier_insufficient",
3550
            "probe tier permits fixed discovery only",
3551
        );
3552
        return;
3553
    }
3554
3555
    let cwd = if requested_cwd.starts_with('~') {
3556
        resolve_root(&requested_cwd)
3557
    } else {
3558
        normalize_path(Path::new(&requested_cwd))
3559
    };
3560
    if config.roots.is_empty() || !config.roots.iter().any(|root| within_root(&cwd, root)) {
3561
        refuse_now(
3562
            "root_not_declared",
3563
            "the working directory is outside every declared root",
3564
        );
3565
        return;
3566
    }
3567
    if !cwd.is_dir() {
3568
        refuse_now("root_not_declared", "the working directory does not exist");
3569
        return;
3570
    }
3571
3572
    let entry = match resolve_agent(catalog, &agent_id) {
3573
        Ok(entry) => entry,
3574
        Err(detail) => {
3575
            refuse_now("agent_unavailable", &detail);
3576
            return;
3577
        }
3578
    };
3579
3580
    // A resume that names a session still running here rebinds the live
3581
    // delegation onto this request rather than starting a second agent in the
3582
    // same checkout. The old request id is dropped from the map first: its
3583
    // caller is gone, and a cancel arriving late on a dead request must not
3584
    // stop the delegation that replaced it.
3585
    if let Some(resume) = &resume {
3586
        let mut live = agents.lock().unwrap();
3587
        let existing = live
3588
            .iter()
3589
            .find(|(_, job)| job.session.lock().unwrap().as_str() == resume.as_str())
3590
            .map(|(key, _)| key.clone());
3591
        if let Some(previous) = existing {
3592
            let job = live.remove(&previous).expect("the job was just found");
3593
            *job.route.lock().unwrap() = request_id.to_string();
3594
            let session = job.session.lock().unwrap().clone();
3595
            live.insert(request_id.to_string(), job);
3596
            drop(live);
3597
            let _ = journal.append(
3598
                request_id,
3599
                &request,
3600
                "reattached",
3601
                "running",
3602
                "reattached to the live ACP session",
3603
            );
3604
            let _ = sender.send(Outgoing::Frame {
3605
                event: "session".to_string(),
3606
                payload: serde_json::json!({
3607
                    "request_id": request_id,
3608
                    "session_id": session,
3609
                }),
3610
            });
3611
            return;
3612
        }
3613
    }
3614
3615
    if active.load(Ordering::SeqCst) >= MAXIMUM_CONCURRENCY {
3616
        let _ = journal.append(
3617
            request_id,
3618
            &request,
3619
            "allowed",
3620
            "refused",
3621
            "local delegation concurrency limit reached",
3622
        );
3623
        let _ = sender.send(Outgoing::Frame {
3624
            event: "refused".to_string(),
3625
            payload: serde_json::json!({
3626
                "request_id": request_id,
3627
                "reason": "busy",
3628
                "detail": "the local delegation limit is reached",
3629
            }),
3630
        });
3631
        return;
3632
    }
3633
3634
    let timeout = Duration::from_millis(bounded_number(
3635
        payload,
3636
        &["timeout_ms", "timeout"],
3637
        AGENT_DEFAULT_TIMEOUT_MS,
3638
        AGENT_MAXIMUM_TIMEOUT_MS,
3639
    ));
3640
    let output_ceiling = bounded_number(
3641
        payload,
3642
        &[
3643
            "maximum_output_bytes",
3644
            "max_output_bytes",
3645
            "output_max_bytes",
3646
        ],
3647
        AGENT_MAXIMUM_OUTPUT_BYTES as u64,
3648
        AGENT_MAXIMUM_OUTPUT_BYTES as u64,
3649
    ) as usize;
3650
3651
    let (cancel_sender, cancel_receiver) = tokio::sync::watch::channel(false);
3652
    let route = Arc::new(Mutex::new(request_id.to_string()));
3653
    let session = Arc::new(Mutex::new(resume.clone().unwrap_or_default()));
3654
    agents.lock().unwrap().insert(
3655
        request_id.to_string(),
3656
        AgentJob {
3657
            session: Arc::clone(&session),
3658
            route: Arc::clone(&route),
3659
            cancel: cancel_sender.clone(),
3660
        },
3661
    );
3662
    active.fetch_add(1, Ordering::SeqCst);
3663
    let _ = journal.append(
3664
        request_id,
3665
        &request,
3666
        "allowed",
3667
        "running",
3668
        &format!("agent={} source={}", entry.id, entry.source),
3669
    );
3670
3671
    let gate_journal = journal.clone();
3672
    let gate_config = config.clone();
3673
    let gate_request = request.clone();
3674
    let gate_id = request_id.to_string();
3675
    let gate_cwd = cwd.clone();
3676
    let permission = Arc::new(move |query: &PermissionQuery| {
3677
        let decision = agent_permission(&gate_config, &gate_cwd, query);
3678
        let (label, outcome, detail) = match &decision {
3679
            Decision::Allowed { .. } => (
3680
                "permission_granted",
3681
                "running",
3682
                format!("{}: {}", query.kind, query.title),
3683
            ),
3684
            Decision::Refused { reason, detail } => {
3685
                (reason.label(), "permission_refused", detail.clone())
3686
            }
3687
        };
3688
        // Both answers are journaled. A delegated agent that was stopped from
3689
        // doing something is exactly what the owner needs to be able to read
3690
        // back, and a granted one is how they see what it did.
3691
        let _ = gate_journal.append(&gate_id, &gate_request, label, outcome, &detail);
3692
        decision.allowed()
3693
    });
3694
3695
    let push_journal = journal.clone();
3696
    let push_request = request.clone();
3697
    let push_id = request_id.to_string();
3698
    let push_cwd = cwd.clone();
3699
    let push_origin = origin.to_string();
3700
    let on_request: Option<crate::acp::ReverseHandler> = match credentials {
3701
        None => None,
3702
        Some(credentials) => Some(Arc::new(move |method: &str, params: &serde_json::Value| {
3703
            if method != "git/push" {
3704
                return None;
3705
            }
3706
            let remote = first_string(params, &["remote"]).unwrap_or_else(|| "origin".to_string());
3707
            let Some(refspec) = first_string(params, &["refspec", "branch", "ref"]) else {
3708
                let _ = push_journal.append(
3709
                    &push_id,
3710
                    &push_request,
3711
                    "push_refused",
3712
                    "refused",
3713
                    "a delegated push named no refspec",
3714
                );
3715
                return Some(serde_json::json!({
3716
                    "ok": false,
3717
                    "error": "a delegated push requires a refspec",
3718
                }));
3719
            };
3720
            match push_delegated(&push_cwd, &remote, &refspec, &credentials, &push_origin) {
3721
                Ok(()) => {
3722
                    let _ = push_journal.append(
3723
                        &push_id,
3724
                        &push_request,
3725
                        "push_completed",
3726
                        "completed",
3727
                        &format!("{} to {}", credentials.repository, credentials.branch),
3728
                    );
3729
                    Some(serde_json::json!({"ok": true}))
3730
                }
3731
                Err(detail) => {
3732
                    let detail = redact(&detail);
3733
                    let _ = push_journal.append(
3734
                        &push_id,
3735
                        &push_request,
3736
                        "push_refused",
3737
                        "refused",
3738
                        &detail,
3739
                    );
3740
                    Some(serde_json::json!({"ok": false, "error": detail}))
3741
                }
3742
            }
3743
        })),
3744
    };
3745
3746
    let harness = AcpHarness {
3747
        command: entry.argv[0].clone(),
3748
        args: entry.argv[1..].to_vec(),
3749
        // Ask, so the gate below gets to answer. An agent left in its own
3750
        // default mode may be in a bypass mode that never sends
3751
        // `session/request_permission` at all, and a gate nothing consults
3752
        // decides nothing. Best effort — a build that does not know this mode
3753
        // keeps its own, and what still holds either way is where the agent
3754
        // runs: the cwd was checked against the declared roots before the
3755
        // child was started.
3756
        mode: Some(crate::acp::PermissionMode::Prompt),
3757
        permission: Some(permission),
3758
        on_request,
3759
        resume_session_id: resume.clone(),
3760
        env: Some(agent_environment(&entry)),
3761
    };
3762
3763
    let sender = sender.clone();
3764
    let journal = journal.clone();
3765
    let active = Arc::clone(active);
3766
    let agents = Arc::clone(agents);
3767
    let owning_id = request_id.to_string();
3768
    std::thread::spawn(move || {
3769
        let started = Instant::now();
3770
        let runtime = match tokio::runtime::Builder::new_multi_thread()
3771
            .worker_threads(2)
3772
            .enable_all()
3773
            .build()
3774
        {
3775
            Ok(runtime) => runtime,
3776
            Err(error) => {
3777
                // Even this ends in a terminal frame. A server waiting on a
3778
                // request the controller silently dropped is the failure this
3779
                // whole path is shaped to avoid.
3780
                active.fetch_sub(1, Ordering::SeqCst);
3781
                agents.lock().unwrap().remove(&owning_id);
3782
                let detail = format!("the delegation runtime could not start: {error}");
3783
                let _ = journal.append(&owning_id, &request, "failed", "failed", &detail);
3784
                let _ = sender.send(Outgoing::Frame {
3785
                    event: "exit".to_string(),
3786
                    payload: serde_json::json!({
3787
                        "request_id": *route.lock().unwrap(),
3788
                        "status": "failed",
3789
                        "session_id": "",
3790
                        "detail": detail,
3791
                        "truncated": false,
3792
                        "duration_ms": 0,
3793
                    }),
3794
                });
3795
                return;
3796
            }
3797
        };
3798
3799
        let streamed = Arc::new(Mutex::new(0usize));
3800
        let truncated = Arc::new(std::sync::atomic::AtomicBool::new(false));
3801
        let outcome = runtime.block_on(async {
3802
            let mut cancel = cancel_receiver;
3803
            let chunk_sender = sender.clone();
3804
            let chunk_route = Arc::clone(&route);
3805
            let chunk_session = Arc::clone(&session);
3806
            let chunk_bytes = Arc::clone(&streamed);
3807
            let chunk_truncated = Arc::clone(&truncated);
3808
            let run = harness.run_detailed(
3809
                &prompt,
3810
                &cwd,
3811
                move |event| {
3812
                    let text = match event {
3813
                        AcpEvent::Session { id } => {
3814
                            *chunk_session.lock().unwrap() = id.clone();
3815
                            let _ = chunk_sender.send(Outgoing::Frame {
3816
                                event: "session".to_string(),
3817
                                payload: serde_json::json!({
3818
                                    "request_id": *chunk_route.lock().unwrap(),
3819
                                    "session_id": id,
3820
                                }),
3821
                            });
3822
                            return;
3823
                        }
3824
                        AcpEvent::Text { chunk } => chunk,
3825
                        AcpEvent::Tool { kind, title } => format!("[{kind}] {title}\n"),
3826
                        AcpEvent::Tokens { input, output } => {
3827
                            format!("[{input} in / {output} out tokens]\n")
3828
                        }
3829
                    };
3830
                    // Redacted before it leaves this machine, and bounded, so
3831
                    // a talkative agent cannot become an unbounded upload.
3832
                    let text = redact(&text);
3833
                    let mut sent = chunk_bytes.lock().unwrap();
3834
                    if *sent >= output_ceiling {
3835
                        chunk_truncated.store(true, Ordering::SeqCst);
3836
                        return;
3837
                    }
3838
                    let remaining = output_ceiling - *sent;
3839
                    let mut end = remaining.min(text.len());
3840
                    while end > 0 && !text.is_char_boundary(end) {
3841
                        end -= 1;
3842
                    }
3843
                    if end < text.len() {
3844
                        chunk_truncated.store(true, Ordering::SeqCst);
3845
                    }
3846
                    *sent += end;
3847
                    drop(sent);
3848
                    if end == 0 {
3849
                        return;
3850
                    }
3851
                    let _ = chunk_sender.send(Outgoing::Frame {
3852
                        event: "chunk".to_string(),
3853
                        payload: serde_json::json!({
3854
                            "request_id": *chunk_route.lock().unwrap(),
3855
                            "text": &text[..end],
3856
                        }),
3857
                    });
3858
                },
3859
                &mut cancel,
3860
            );
3861
            match tokio::time::timeout(timeout, run).await {
3862
                Ok(result) => result,
3863
                Err(_elapsed) => {
3864
                    // Stop the child, then report the timeout as the reason
3865
                    // rather than whatever the cancellation looked like.
3866
                    let _ = cancel_sender.send(true);
3867
                    Err(AcpFailure::Refused(format!(
3868
                        "the delegation did not finish within {}s",
3869
                        timeout.as_secs()
3870
                    )))
3871
                }
3872
            }
3873
        });
3874
3875
        active.fetch_sub(1, Ordering::SeqCst);
3876
        // By the route, not by the request this started on: a reattach moved
3877
        // the job to the resuming request's id, and removing the id it no
3878
        // longer lives under would leave a finished delegation in the map for
3879
        // as long as the connection lasts. The route is read and released
3880
        // before the map is locked — the reattach path locks the map first, so
3881
        // taking them in the other order here is a deadlock.
3882
        let current = route.lock().unwrap().clone();
3883
        let cancelled = agents
3884
            .lock()
3885
            .unwrap()
3886
            .remove(&current)
3887
            .map(|job| *job.cancel.borrow())
3888
            .unwrap_or(false);
3889
        let session_id = session.lock().unwrap().clone();
3890
        let truncated = truncated.load(Ordering::SeqCst);
3891
        let (status, stop_reason, detail) = match &outcome {
3892
            Ok(finished) => {
3893
                let status = match finished.stop_reason.as_str() {
3894
                    "cancelled" => "cancelled",
3895
                    "refusal" => "refused",
3896
                    _ if truncated => "truncated",
3897
                    _ => "completed",
3898
                };
3899
                (
3900
                    status,
3901
                    finished.stop_reason.clone(),
3902
                    if truncated {
3903
                        "output truncated".to_string()
3904
                    } else {
3905
                        String::new()
3906
                    },
3907
                )
3908
            }
3909
            Err(AcpFailure::Cancelled) => (
3910
                "cancelled",
3911
                String::new(),
3912
                "the delegation was stopped".to_string(),
3913
            ),
3914
            Err(AcpFailure::Unstartable(why)) => ("unavailable", String::new(), redact(why)),
3915
            Err(AcpFailure::Refused(why)) => {
3916
                let status = if cancelled {
3917
                    "cancelled"
3918
                } else if why.starts_with("the delegation did not finish within") {
3919
                    "timeout"
3920
                } else {
3921
                    "failed"
3922
                };
3923
                (status, String::new(), redact(why))
3924
            }
3925
        };
3926
        let _ = journal.append(&owning_id, &request, "allowed", status, &detail);
3927
        let _ = sender.send(Outgoing::Frame {
3928
            event: "exit".to_string(),
3929
            payload: serde_json::json!({
3930
                "request_id": current,
3931
                "status": status,
3932
                "session_id": session_id,
3933
                "stop_reason": stop_reason,
3934
                "truncated": truncated,
3935
                "detail": bounded(&detail, 400),
3936
                "duration_ms": started.elapsed().as_millis() as u64,
3937
            }),
3938
        });
3939
    });
3940
}
3941
2623 3942
fn request_fields(payload: &serde_json::Value) -> Option<CommandRequest> {
2624 3943
    let argv = payload.get("argv")?.as_array()?;
2625 3944
    if argv.is_empty() || argv.len() > MAXIMUM_ARGV_LENGTH {

@@ -2665,6 +3984,10 @@ pub fn serve(

2665 3984
    journal: &Journal,
2666 3985
    mut on_event: impl FnMut(&str),
2667 3986
) -> String {
3987
    // Which agents this machine will delegate to is decided once, here, from
3988
    // what the owner declared and what is actually installed. It is not read
3989
    // from the request, and a reconnect does not widen it.
3990
    let catalog = agent_catalog(config, &installed_coding_agents(&config.roots));
2668 3991
    let mut attempts: u32 = 0;
2669 3992
    loop {
2670 3993
        let end = serve_connection(

@@ -2674,6 +3997,7 @@ pub fn serve(

2674 3997
            hello,
2675 3998
            config,
2676 3999
            journal,
4000
            &catalog,
2677 4001
            &mut on_event,
2678 4002
        );
2679 4003
        if !end.retryable || attempts >= MAXIMUM_RECONNECT_ATTEMPTS {

@@ -2773,7 +4097,10 @@ pub async fn run(args: ComputerArgs, endpoint: &crate::auth::Endpoint, json: boo

2773 4097
    match args.action {
2774 4098
        ComputerAction::Probe { root } => {
2775 4099
            let roots = roots_for(&config, &root);
2776
            let report = probe(&roots);
4100
            let report = probe_for(&PolicyConfig {
4101
                roots: roots.clone(),
4102
                ..config.clone()
4103
            });
2777 4104
            if json {
2778 4105
                println!(
2779 4106
                    "{}",

@@ -2801,12 +4128,18 @@ pub async fn run(args: ComputerArgs, endpoint: &crate::auth::Endpoint, json: boo

2801 4128
        }
2802 4129
        ComputerAction::Policy { root } => {
2803 4130
            let roots = roots_for(&config, &root);
4131
            let catalog = agent_catalog(&config, &installed_coding_agents(&roots));
2804 4132
            if json {
2805 4133
                let value = serde_json::json!({
2806 4134
                    "schema": "openagents.computer_policy.v1",
2807 4135
                    "tier": config.tier.label(),
2808 4136
                    "roots": roots.iter().map(|r| r.display().to_string()).collect::<Vec<_>>(),
2809 4137
                    "pre_approved": config.pre_approved,
4138
                    "delegable_agents": catalog
4139
                        .iter()
4140
                        .map(|entry| serde_json::json!({"id": entry.id, "source": entry.source}))
4141
                        .collect::<Vec<_>>(),
4142
                    "scoped_forge_credentials": config.scoped_forge_credentials,
2810 4143
                    "authority": "local_machine",
2811 4144
                    "paths": {
2812 4145
                        "config": paths.config.display().to_string(),

@@ -2831,6 +4164,30 @@ pub async fn run(args: ComputerArgs, endpoint: &crate::auth::Endpoint, json: boo

2831 4164
            for line in format_allowlist() {
2832 4165
                println!("  {line}");
2833 4166
            }
4167
            println!(
4168
                "Delegable ACP agents: {}",
4169
                if catalog.is_empty() {
4170
                    "(none)".to_string()
4171
                } else {
4172
                    catalog
4173
                        .iter()
4174
                        .map(|entry| format!("{} ({})", entry.id, entry.source))
4175
                        .collect::<Vec<_>>()
4176
                        .join(", ")
4177
                }
4178
            );
4179
            println!(
4180
                "A delegated agent runs under this same policy: the tier ceiling, the declared \
4181
                 roots, and the curated allowlist decide every action it asks to take."
4182
            );
4183
            println!(
4184
                "Scoped forge credentials: {}",
4185
                if config.scoped_forge_credentials {
4186
                    "allowed for delegated pushes"
4187
                } else {
4188
                    "not allowed; a delivered credential is refused and journaled"
4189
                }
4190
            );
2834 4191
            println!("Configuration: {}", paths.config.display());
2835 4192
            println!("No account, pairing, or network is needed for this command.");
2836 4193
        }

@@ -2940,7 +4297,7 @@ pub async fn run(args: ComputerArgs, endpoint: &crate::auth::Endpoint, json: boo

2940 4297
                )),
2941 4298
                Err(reason) => fail(&reason),
2942 4299
            };
2943
            let initial = probe(&config.roots);
4300
            let initial = probe_for(&config);
2944 4301
            let hello = serde_json::json!({
2945 4302
                "agent_version": crate::VERSION,
2946 4303
                "tier": config.tier.label(),
crates/openagents-cli/tests/acp_test.rs modified +11 -6

@@ -85,6 +85,7 @@ fn harness(name: &str, body: &str) -> AcpHarness {

85 85
        command: stand_in(name, body).to_string_lossy().to_string(),
86 86
        args: Vec::new(),
87 87
        mode: Some(PermissionMode::Dangerous),
88
        ..AcpHarness::default()
88 89
    }
89 90
}
90 91

@@ -112,7 +113,10 @@ async fn a_turn_over_acp_streams_and_answers() {

112 113
113 114
    // The permission answer is in the text, so the answer proves the client
114 115
    // both replied to the request and picked the option that allows.
115
    assert_eq!(answer, "the answer in two pieces, permitted by allow-always");
116
    assert_eq!(
117
        answer,
118
        "the answer in two pieces, permitted by allow-always"
119
    );
116 120
117 121
    let events = seen.lock().unwrap();
118 122
    let session = events

@@ -134,7 +138,10 @@ async fn a_turn_over_acp_streams_and_answers() {

134 138
    assert!(
135 139
        events.iter().any(|(_, event)| matches!(
136 140
            event,
137
            AcpEvent::Tokens { input: 120, output: 34 }
141
            AcpEvent::Tokens {
142
                input: 120,
143
                output: 34
144
            }
138 145
        )),
139 146
        "the token counts were not reported"
140 147
    );

@@ -164,6 +171,7 @@ async fn a_missing_agent_is_reported_as_missing() {

164 171
        command: "/nonexistent/no-such-agent".to_string(),
165 172
        args: Vec::new(),
166 173
        mode: None,
174
        ..AcpHarness::default()
167 175
    };
168 176
    let (_stop, mut cancel) = watch::channel(false);
169 177
    let failure = harness

@@ -179,10 +187,7 @@ async fn a_missing_agent_is_reported_as_missing() {

179 187
/// An agent that exits without answering is a failure, not an empty answer.
180 188
#[tokio::test]
181 189
async fn an_agent_that_exits_early_is_a_failure() {
182
    let harness = harness(
183
        "acp-quitter",
184
        "#!/bin/sh\necho 'not json'\nexit 0\n",
185
    );
190
    let harness = harness("acp-quitter", "#!/bin/sh\necho 'not json'\nexit 0\n");
186 191
    let (_stop, mut cancel) = watch::channel(false);
187 192
    let failure = harness
188 193
        .run("hello", &std::env::temp_dir(), |_| {}, &mut cancel)
crates/openagents-cli/tests/computer_agent_test.rs added +1407

@@ -0,0 +1,1407 @@

1
//! ACP delegation on the Computer controller (issue 113).
2
//!
3
//! The `agent` frame drives a real ACP child here, so these tests stand up a
4
//! real one: a Python ACP server that asks for exactly what a test tells it to
5
//! ask for, and reports back what answer it got. That is the only way to
6
//! assert the thing that matters — that a delegated agent is run *under this
7
//! machine's policy* rather than around it.
8
//!
9
//! Two shapes of assertion are deliberately absent. Nothing asserts
10
//! `x.is_empty() || !x.is_empty()`, and nothing asserts merely that "a frame
11
//! arrived": every case below names the reason the policy produced, the
12
//! journal line it wrote, and — where the agent can observe it — the answer the
13
//! agent was given.
14
15
use std::collections::BTreeMap;
16
use std::net::TcpListener;
17
use std::path::{Path, PathBuf};
18
use std::sync::mpsc::{channel, Receiver};
19
use std::time::Duration;
20
21
use openagents_cli::acp::PermissionQuery;
22
use openagents_cli::computer::{
23
    agent_catalog, agent_permission, forge_credentials, push_delegated, resolve_agent, serve,
24
    validate_refspec, write_credential_helper, AgentEntry, ComputerPaths, Decision,
25
    ForgeCredentials, Journal, JournalEntry, PolicyConfig, RefusalReason, ResolvedAgent, Tier,
26
    ToolReport,
27
};
28
29
// ---------------------------------------------------------------------------
30
// the stand-in agent
31
// ---------------------------------------------------------------------------
32
33
/// An ACP server that does what one test told it to do.
34
///
35
/// The plan is a file named in its own argv, not an environment variable: the
36
/// tests run in one process, and a shared variable would make two concurrent
37
/// delegations decide each other's behaviour.
38
const STUB_AGENT: &str = r#"#!/usr/bin/env python3
39
import json, sys, time
40
41
with open(sys.argv[1]) as handle:
42
    plan = json.load(handle)
43
44
def send(obj):
45
    sys.stdout.write(json.dumps(obj) + "\n")
46
    sys.stdout.flush()
47
48
if plan.get("fail") == "exit":
49
    sys.exit(3)
50
51
notes = []
52
for line in sys.stdin:
53
    line = line.strip()
54
    if not line:
55
        continue
56
    message = json.loads(line)
57
    method = message.get("method")
58
    if method == "initialize":
59
        send({"jsonrpc": "2.0", "id": message["id"], "result": {
60
            "protocolVersion": 1,
61
            "agentCapabilities": {"loadSession": bool(plan.get("load"))},
62
        }})
63
    elif method == "session/new":
64
        send({"jsonrpc": "2.0", "id": message["id"],
65
              "result": {"sessionId": plan.get("session", "sess-stub")}})
66
    elif method == "session/set_mode":
67
        notes.append("mode:" + message["params"]["modeId"])
68
        send({"jsonrpc": "2.0", "id": message["id"],
69
              "result": {"modeId": message["params"]["modeId"]}})
70
    elif method == "session/load":
71
        notes.append("loaded:" + message["params"]["sessionId"])
72
        send({"jsonrpc": "2.0", "id": message["id"], "result": {}})
73
    elif method == "session/prompt":
74
        sid = message["params"]["sessionId"]
75
        ask = plan.get("permission")
76
        if ask is not None:
77
            send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": sid,
78
                  "update": {"sessionUpdate": "tool_call", "toolCallId": "t1",
79
                             "kind": ask.get("kind", ""), "title": ask.get("title", "")}}})
80
            send({"jsonrpc": "2.0", "id": 9001, "method": "session/request_permission",
81
                  "params": {"sessionId": sid,
82
                             "toolCall": {"kind": ask.get("kind", ""),
83
                                          "title": ask.get("title", ""),
84
                                          "rawInput": ask.get("rawInput", {})},
85
                             "options": [
86
                                 {"optionId": "reject-once", "kind": "reject_once"},
87
                                 {"optionId": "allow-once", "kind": "allow_once"}]}})
88
            answer = json.loads(sys.stdin.readline())
89
            outcome = answer["result"]["outcome"]
90
            notes.append("permission:" + str(outcome.get("optionId") or outcome.get("outcome")))
91
        push = plan.get("push")
92
        if push is not None:
93
            send({"jsonrpc": "2.0", "id": 9002, "method": "git/push", "params": push})
94
            reply = json.loads(sys.stdin.readline())
95
            if "error" in reply:
96
                notes.append("push:method_not_found")
97
            else:
98
                result = reply.get("result", {})
99
                notes.append("push:" + ("ok" if result.get("ok") else "refused"))
100
        if plan.get("delay"):
101
            send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": sid,
102
                  "update": {"sessionUpdate": "agent_message_chunk",
103
                             "content": {"type": "text", "text": "working\n"}}}})
104
            time.sleep(plan["delay"])
105
        for piece in plan.get("chunks", []) + notes:
106
            send({"jsonrpc": "2.0", "method": "session/update", "params": {"sessionId": sid,
107
                  "update": {"sessionUpdate": "agent_message_chunk",
108
                             "content": {"type": "text", "text": piece + "\n"}}}})
109
        send({"jsonrpc": "2.0", "id": message["id"],
110
              "result": {"stopReason": plan.get("stop", "end_turn")}})
111
    else:
112
        send({"jsonrpc": "2.0", "id": message.get("id", 0), "result": {}})
113
"#;
114
115
fn stub_agent_path(directory: &Path, plan: &serde_json::Value) -> (PathBuf, PathBuf) {
116
    let path = directory.join("stub-acp-agent");
117
    let plan_path = directory.join("stub-acp-plan.json");
118
    std::fs::write(&plan_path, plan.to_string()).unwrap();
119
    std::fs::write(&path, STUB_AGENT).unwrap();
120
    #[cfg(unix)]
121
    {
122
        use std::os::unix::fs::PermissionsExt;
123
        std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o755)).unwrap();
124
    }
125
    (path, plan_path)
126
}
127
128
// ---------------------------------------------------------------------------
129
// a controller that pushes one delegation
130
// ---------------------------------------------------------------------------
131
132
struct StubController {
133
    origin: String,
134
    frames: Receiver<serde_json::Value>,
135
}
136
137
fn start_controller(
138
    machine_id: &str,
139
    event: &'static str,
140
    ask: serde_json::Value,
141
) -> StubController {
142
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
143
    let port = listener.local_addr().unwrap().port();
144
    let (sender, frames) = channel();
145
    let topic = format!("computer:{machine_id}");
146
147
    std::thread::spawn(move || {
148
        let Ok((stream, _)) = listener.accept() else {
149
            return;
150
        };
151
        let Ok(mut socket) = tungstenite::accept(stream) else {
152
            return;
153
        };
154
        let _ = socket.read();
155
        let reply =
156
            serde_json::json!(["1", "1", topic, "phx_reply", {"status": "ok", "response": {}}]);
157
        let _ = socket.send(tungstenite::Message::Text(reply.to_string().into()));
158
        let _ = socket.read();
159
        let push = serde_json::json!([serde_json::Value::Null, "9", topic, event, ask]);
160
        let _ = socket.send(tungstenite::Message::Text(push.to_string().into()));
161
162
        let deadline = std::time::Instant::now() + Duration::from_secs(60);
163
        while std::time::Instant::now() < deadline {
164
            match socket.read() {
165
                Ok(tungstenite::Message::Text(text)) => {
166
                    if let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) {
167
                        let terminal = value
168
                            .get(3)
169
                            .and_then(|event| event.as_str())
170
                            .map(|event| event == "refused" || event == "exit")
171
                            .unwrap_or(false);
172
                        if sender.send(value).is_err() {
173
                            return;
174
                        }
175
                        if terminal {
176
                            break;
177
                        }
178
                    }
179
                }
180
                Ok(_) => {}
181
                Err(_) => break,
182
            }
183
        }
184
        let _ = socket.close(None);
185
        while socket.read().is_ok() {}
186
    });
187
188
    StubController {
189
        origin: format!("http://127.0.0.1:{port}"),
190
        frames,
191
    }
192
}
193
194
/// Everything the controller sent, in order, up to and including the terminal
195
/// frame. Reading the whole conversation is what lets a test assert that the
196
/// session id arrived *before* the exit, and that a refused permission still
197
/// produced a completed turn.
198
fn conversation(frames: &Receiver<serde_json::Value>) -> Vec<(String, serde_json::Value)> {
199
    let mut seen = Vec::new();
200
    while let Ok(frame) = frames.recv_timeout(Duration::from_secs(60)) {
201
        let event = frame
202
            .get(3)
203
            .and_then(|value| value.as_str())
204
            .unwrap_or_default()
205
            .to_string();
206
        let payload = frame.get(4).cloned().unwrap_or(serde_json::Value::Null);
207
        let terminal = event == "refused" || event == "exit";
208
        seen.push((event, payload));
209
        if terminal {
210
            break;
211
        }
212
    }
213
    seen
214
}
215
216
fn terminal_of(seen: &[(String, serde_json::Value)]) -> (String, serde_json::Value) {
217
    seen.iter()
218
        .rev()
219
        .find(|(event, _)| event == "refused" || event == "exit")
220
        .cloned()
221
        .expect("the server was left waiting: no refused and no exit ever arrived")
222
}
223
224
fn streamed(seen: &[(String, serde_json::Value)]) -> String {
225
    seen.iter()
226
        .filter(|(event, _)| event == "chunk")
227
        .filter_map(|(_, payload)| payload.get("text").and_then(|value| value.as_str()))
228
        .collect::<Vec<_>>()
229
        .join("")
230
}
231
232
// ---------------------------------------------------------------------------
233
// running one delegation end to end
234
// ---------------------------------------------------------------------------
235
236
struct Delegation {
237
    seen: Vec<(String, serde_json::Value)>,
238
    entries: Vec<JournalEntry>,
239
}
240
241
impl Delegation {
242
    fn journal_line(&self, decision: &str) -> Option<&JournalEntry> {
243
        self.entries.iter().find(|entry| entry.decision == decision)
244
    }
245
}
246
247
struct Setup {
248
    tier: Tier,
249
    declare_root: bool,
250
    scoped_forge_credentials: bool,
251
    plan: serde_json::Value,
252
    event: &'static str,
253
    ask: serde_json::Value,
254
}
255
256
impl Default for Setup {
257
    fn default() -> Self {
258
        Self {
259
            tier: Tier::Curated,
260
            declare_root: true,
261
            scoped_forge_credentials: false,
262
            plan: serde_json::json!({}),
263
            event: "agent",
264
            ask: serde_json::json!({}),
265
        }
266
    }
267
}
268
269
/// Serve one delegation against a live stub controller and a live stub agent,
270
/// and return everything both sides can be asked about afterwards.
271
fn delegate(setup: Setup) -> Delegation {
272
    let directory = tempfile::tempdir().unwrap();
273
    let root = directory.path().join("checkout");
274
    std::fs::create_dir_all(&root).unwrap();
275
    let (agent, plan) = stub_agent_path(directory.path(), &setup.plan);
276
277
    let mut agents = BTreeMap::new();
278
    agents.insert(
279
        "stub".to_string(),
280
        AgentEntry {
281
            argv: vec![agent.display().to_string(), plan.display().to_string()],
282
            env: Vec::new(),
283
        },
284
    );
285
    let config = PolicyConfig {
286
        tier: setup.tier,
287
        roots: if setup.declare_root {
288
            vec![root.clone()]
289
        } else {
290
            Vec::new()
291
        },
292
        agents,
293
        scoped_forge_credentials: setup.scoped_forge_credentials,
294
        ..PolicyConfig::closed(ComputerPaths::in_directory(directory.path()))
295
    };
296
    let journal = Journal::at(directory.path().join("journal.ndjson"));
297
298
    let mut ask = serde_json::json!({
299
        "request_id": "req-agent",
300
        "agent_id": "stub",
301
        "prompt": "do the thing",
302
        "cwd": root.display().to_string(),
303
        "timeout_ms": 30_000,
304
    });
305
    if let (Some(base), Some(extra)) = (ask.as_object_mut(), setup.ask.as_object()) {
306
        for (key, value) in extra {
307
            base.insert(key.clone(), value.clone());
308
        }
309
    }
310
311
    let machine = format!("machine-{}", std::process::id());
312
    let stub = start_controller(&machine, setup.event, ask);
313
314
    serve(
315
        &stub.origin,
316
        &openagents_cli::auth::Secret::new("smct_stub"),
317
        &machine,
318
        &serde_json::json!({"agent_version": "test"}),
319
        &config,
320
        &journal,
321
        |_| {},
322
    );
323
324
    Delegation {
325
        seen: conversation(&stub.frames),
326
        entries: journal.read(200).unwrap(),
327
    }
328
}
329
330
// ---------------------------------------------------------------------------
331
// the delegation itself
332
// ---------------------------------------------------------------------------
333
334
/// The `agent` frame runs a real ACP child and reports what it did.
335
///
336
/// Before this, the answer was `{"reason": "unsupported"}`. The session id
337
/// arrives while the agent is still working — `OpenAgentsWeb.ComputerChannel`
338
/// checkpoints it mid-stream so a survivor can reattach — and the terminal
339
/// `exit` carries the same id, the stop reason, and a duration.
340
#[test]
341
fn test_a_delegation_streams_its_session_output_and_a_terminal_exit() {
342
    let run = delegate(Setup {
343
        plan: serde_json::json!({"session": "sess-live", "chunks": ["hello from the agent"]}),
344
        ..Setup::default()
345
    });
346
347
    let session = run
348
        .seen
349
        .iter()
350
        .find(|(event, _)| event == "session")
351
        .expect("the session id must be reported while the agent is still running");
352
    assert_eq!(
353
        session.1.get("session_id").and_then(|v| v.as_str()),
354
        Some("sess-live")
355
    );
356
    assert_eq!(
357
        session.1.get("request_id").and_then(|v| v.as_str()),
358
        Some("req-agent")
359
    );
360
    assert!(
361
        run.seen.iter().position(|(e, _)| e == "session")
362
            < run.seen.iter().position(|(e, _)| e == "exit"),
363
        "the session id is useless for reattach if it only arrives with the exit"
364
    );
365
366
    assert!(
367
        streamed(&run.seen).contains("hello from the agent"),
368
        "the agent's output must reach the server as it is written: {:?}",
369
        run.seen
370
    );
371
372
    let (kind, exit) = terminal_of(&run.seen);
373
    assert_eq!(kind, "exit");
374
    assert_eq!(
375
        exit.get("status").and_then(|v| v.as_str()),
376
        Some("completed")
377
    );
378
    assert_eq!(
379
        exit.get("session_id").and_then(|v| v.as_str()),
380
        Some("sess-live")
381
    );
382
    assert_eq!(
383
        exit.get("stop_reason").and_then(|v| v.as_str()),
384
        Some("end_turn")
385
    );
386
    assert_eq!(
387
        exit.get("request_id").and_then(|v| v.as_str()),
388
        Some("req-agent")
389
    );
390
391
    let allowed = run
392
        .journal_line("allowed")
393
        .expect("the delegation must be journaled as allowed");
394
    assert_eq!(allowed.argv, vec!["<agent>", "stub"]);
395
    assert!(
396
        run.entries
397
            .iter()
398
            .any(|entry| entry.outcome == "completed" && entry.request_id == "req-agent"),
399
        "the outcome must reach the journal: {:?}",
400
        run.entries
401
    );
402
}
403
404
/// A delegated agent is put into a mode that asks.
405
///
406
/// The gate can only decide what the agent puts to it. An agent left in its
407
/// own default may be in a bypass mode that never sends
408
/// `session/request_permission` at all, and a policy nothing consults decides
409
/// nothing — so the delegation names the asking mode rather than inheriting
410
/// whatever the agent came with.
411
#[test]
412
fn test_a_delegated_agent_is_asked_to_run_in_the_mode_that_asks() {
413
    let run = delegate(Setup {
414
        plan: serde_json::json!({"chunks": []}),
415
        ..Setup::default()
416
    });
417
418
    assert!(
419
        streamed(&run.seen).contains("mode:default"),
420
        "the delegation must set the asking mode: {:?}",
421
        run.seen
422
    );
423
}
424
425
/// The legacy `devin` event is served, not dropped.
426
///
427
/// It once fell through the frame match's catch-all: no frame, no journal
428
/// line, and a server blocked on a request this side had discarded. The kind
429
/// name is not the agent name any more, but an old caller that sends it must
430
/// still be answered on its own `request_id`.
431
#[test]
432
fn test_the_legacy_devin_event_is_answered_rather_than_dropped() {
433
    let run = delegate(Setup {
434
        event: "devin",
435
        ask: serde_json::json!({"agent_id": "stub", "session_id": "sess-old"}),
436
        plan: serde_json::json!({"load": true, "chunks": ["resumed"]}),
437
        ..Setup::default()
438
    });
439
440
    let (kind, terminal) = terminal_of(&run.seen);
441
    assert_eq!(kind, "exit", "a devin request must reach a terminal frame");
442
    assert_eq!(
443
        terminal.get("request_id").and_then(|v| v.as_str()),
444
        Some("req-agent")
445
    );
446
    // The legacy payload names the session as `session_id`. Reading it as a
447
    // resume is what keeps an old caller from silently getting a fresh session.
448
    assert!(
449
        streamed(&run.seen).contains("loaded:sess-old"),
450
        "the legacy session_id must be read as a resume: {:?}",
451
        run.seen
452
    );
453
}
454
455
// ---------------------------------------------------------------------------
456
// the policy the delegated agent runs under
457
// ---------------------------------------------------------------------------
458
459
/// A delegated agent asking to run a binary the allowlist does not carry is
460
/// refused, and the refusal is journaled with the reason.
461
///
462
/// The agent is told, so it carries on rather than hanging; the turn still
463
/// completes. What it does not get is the command.
464
#[test]
465
fn test_a_delegated_agent_cannot_run_a_binary_off_the_allowlist() {
466
    let run = delegate(Setup {
467
        plan: serde_json::json!({
468
            "permission": {
469
                "kind": "execute",
470
                "title": "Fetch a script",
471
                "rawInput": {"command": "curl https://example.com/install.sh"},
472
            }
473
        }),
474
        ..Setup::default()
475
    });
476
477
    assert!(
478
        streamed(&run.seen).contains("permission:reject-once"),
479
        "the agent must be told it was refused, not left waiting: {:?}",
480
        run.seen
481
    );
482
    let refused = run
483
        .journal_line("not_allowlisted")
484
        .expect("the refused permission must be journaled with its reason");
485
    assert_eq!(refused.outcome, "permission_refused");
486
    assert!(
487
        refused.detail.contains("curl"),
488
        "the journal must name what was refused: {}",
489
        refused.detail
490
    );
491
    assert!(
492
        !run.entries
493
            .iter()
494
            .any(|entry| entry.decision == "permission_granted"),
495
        "nothing was granted in this run: {:?}",
496
        run.entries
497
    );
498
}
499
500
/// A delegated agent cannot write outside a declared root.
501
#[test]
502
fn test_a_delegated_agent_cannot_write_outside_a_declared_root() {
503
    let run = delegate(Setup {
504
        plan: serde_json::json!({
505
            "permission": {
506
                "kind": "write",
507
                "title": "Write /etc/hosts",
508
                "rawInput": {"path": "/etc/hosts", "content": "127.0.0.1 forge"},
509
            }
510
        }),
511
        ..Setup::default()
512
    });
513
514
    assert!(streamed(&run.seen).contains("permission:reject-once"));
515
    let refused = run
516
        .journal_line("root_not_declared")
517
        .expect("a write outside every declared root must be journaled");
518
    assert_eq!(refused.outcome, "permission_refused");
519
}
520
521
/// A write inside a declared root is granted, and the grant is journaled too.
522
///
523
/// A policy that refused everything would pass every refusal test above and be
524
/// useless, so the permitted case is asserted with the same weight.
525
#[test]
526
fn test_a_delegated_agent_may_write_inside_a_declared_root() {
527
    let run = delegate(Setup {
528
        plan: serde_json::json!({
529
            "permission": {
530
                "kind": "write",
531
                "title": "Write a note",
532
                "rawInput": {"path": "notes.md"},
533
            }
534
        }),
535
        ..Setup::default()
536
    });
537
538
    assert!(
539
        streamed(&run.seen).contains("permission:allow-once"),
540
        "a write inside the root must be allowed: {:?}",
541
        run.seen
542
    );
543
    let granted = run
544
        .journal_line("permission_granted")
545
        .expect("a granted permission must be journaled too");
546
    assert!(
547
        granted.detail.contains("Write a note"),
548
        "{}",
549
        granted.detail
550
    );
551
}
552
553
/// Delegation does not exist below the curated tier.
554
#[test]
555
fn test_a_probe_tier_machine_refuses_delegation_outright() {
556
    let run = delegate(Setup {
557
        tier: Tier::Probe,
558
        ..Setup::default()
559
    });
560
561
    let (kind, refused) = terminal_of(&run.seen);
562
    assert_eq!(kind, "refused");
563
    assert_eq!(
564
        refused.get("reason").and_then(|v| v.as_str()),
565
        Some("tier_insufficient")
566
    );
567
    assert!(run.journal_line("tier_insufficient").is_some());
568
    assert!(
569
        !run.entries.iter().any(|entry| entry.decision == "allowed"),
570
        "nothing may be allowed on a probe-tier machine: {:?}",
571
        run.entries
572
    );
573
}
574
575
/// A working directory outside every declared root is refused before the agent
576
/// is started.
577
#[test]
578
fn test_a_delegation_outside_every_declared_root_is_refused() {
579
    let run = delegate(Setup {
580
        declare_root: false,
581
        ..Setup::default()
582
    });
583
584
    let (kind, refused) = terminal_of(&run.seen);
585
    assert_eq!(kind, "refused");
586
    assert_eq!(
587
        refused.get("reason").and_then(|v| v.as_str()),
588
        Some("root_not_declared")
589
    );
590
    assert!(run.journal_line("root_not_declared").is_some());
591
}
592
593
/// An agent this machine does not have is refused by name, and told what it
594
/// does have.
595
#[test]
596
fn test_an_unknown_agent_is_refused_with_the_available_ones() {
597
    let run = delegate(Setup {
598
        ask: serde_json::json!({"agent_id": "not-installed-here"}),
599
        ..Setup::default()
600
    });
601
602
    let (kind, refused) = terminal_of(&run.seen);
603
    assert_eq!(kind, "refused");
604
    assert_eq!(
605
        refused.get("reason").and_then(|v| v.as_str()),
606
        Some("agent_unavailable")
607
    );
608
    let detail = refused
609
        .get("detail")
610
        .and_then(|v| v.as_str())
611
        .unwrap_or_default();
612
    assert!(
613
        detail.contains("not-installed-here") && detail.contains("stub"),
614
        "the refusal must name both what was asked for and what is here: {detail}"
615
    );
616
}
617
618
/// An agent that cannot be started still ends in a terminal frame.
619
///
620
/// This is the failure mode the whole shape exists to prevent: a server that
621
/// pushed a request and never heard back.
622
#[test]
623
fn test_an_agent_that_dies_immediately_still_answers_the_request() {
624
    let run = delegate(Setup {
625
        plan: serde_json::json!({"fail": "exit"}),
626
        ..Setup::default()
627
    });
628
629
    let (kind, terminal) = terminal_of(&run.seen);
630
    assert_eq!(kind, "exit");
631
    assert_eq!(
632
        terminal.get("request_id").and_then(|v| v.as_str()),
633
        Some("req-agent")
634
    );
635
    let status = terminal
636
        .get("status")
637
        .and_then(|v| v.as_str())
638
        .unwrap_or_default();
639
    assert!(
640
        status == "failed" || status == "unavailable",
641
        "an agent that exited must be reported as such, not as completed: {terminal}"
642
    );
643
    assert!(
644
        !terminal
645
            .get("detail")
646
            .and_then(|v| v.as_str())
647
            .unwrap_or_default()
648
            .is_empty(),
649
        "the failure must say what happened: {terminal}"
650
    );
651
}
652
653
// ---------------------------------------------------------------------------
654
// reattach
655
// ---------------------------------------------------------------------------
656
657
/// A resume asks the agent to load the session rather than opening a new one.
658
#[test]
659
fn test_a_resume_loads_the_named_session() {
660
    let run = delegate(Setup {
661
        ask: serde_json::json!({"resume_session_id": "sess-earlier"}),
662
        plan: serde_json::json!({"load": true}),
663
        ..Setup::default()
664
    });
665
666
    assert!(
667
        streamed(&run.seen).contains("loaded:sess-earlier"),
668
        "the agent must be asked to load the session: {:?}",
669
        run.seen
670
    );
671
    let (_kind, exit) = terminal_of(&run.seen);
672
    assert_eq!(
673
        exit.get("session_id").and_then(|v| v.as_str()),
674
        Some("sess-earlier"),
675
        "a resumed delegation reports the session it resumed"
676
    );
677
}
678
679
/// An agent that cannot load a session says so rather than opening a fresh one.
680
///
681
/// A silent new session looks like a successful resume and loses everything
682
/// the earlier one knew, which is worse than a refusal.
683
#[test]
684
fn test_a_resume_is_refused_when_the_agent_cannot_load_a_session() {
685
    let run = delegate(Setup {
686
        ask: serde_json::json!({"resume_session_id": "sess-earlier"}),
687
        plan: serde_json::json!({"load": false}),
688
        ..Setup::default()
689
    });
690
691
    let (kind, exit) = terminal_of(&run.seen);
692
    assert_eq!(kind, "exit");
693
    assert_eq!(exit.get("status").and_then(|v| v.as_str()), Some("failed"));
694
    assert!(
695
        exit.get("detail")
696
            .and_then(|v| v.as_str())
697
            .unwrap_or_default()
698
            .contains("reattach"),
699
        "the refusal must say the agent cannot reattach: {exit}"
700
    );
701
}
702
703
/// A second request naming a session that is still running here reattaches to
704
/// it instead of starting a second agent in the same checkout.
705
///
706
/// This is what a relocated delegation does after a node loss: the caller is a
707
/// new process on a new `request_id`, and the agent it wants is already
708
/// working. Two agents editing one checkout is the outcome this prevents.
709
#[test]
710
fn test_a_reattach_moves_the_live_session_onto_the_new_request() {
711
    let directory = tempfile::tempdir().unwrap();
712
    let root = directory.path().join("checkout");
713
    std::fs::create_dir_all(&root).unwrap();
714
    let (agent, plan) = stub_agent_path(
715
        directory.path(),
716
        &serde_json::json!({"session": "sess-live", "delay": 4, "chunks": ["finished"]}),
717
    );
718
719
    let mut agents = BTreeMap::new();
720
    agents.insert(
721
        "stub".to_string(),
722
        AgentEntry {
723
            argv: vec![agent.display().to_string(), plan.display().to_string()],
724
            env: Vec::new(),
725
        },
726
    );
727
    let config = PolicyConfig {
728
        tier: Tier::Curated,
729
        roots: vec![root.clone()],
730
        agents,
731
        ..PolicyConfig::closed(ComputerPaths::in_directory(directory.path()))
732
    };
733
    let journal = Journal::at(directory.path().join("journal.ndjson"));
734
735
    let machine = format!("machine-reattach-{}", std::process::id());
736
    let topic = format!("computer:{machine}");
737
    let listener = TcpListener::bind("127.0.0.1:0").unwrap();
738
    let port = listener.local_addr().unwrap().port();
739
    let (sender, frames) = channel();
740
    let first = serde_json::json!({
741
        "request_id": "req-first",
742
        "agent_id": "stub",
743
        "prompt": "start the work",
744
        "cwd": root.display().to_string(),
745
        "timeout_ms": 30_000,
746
    });
747
    let second = serde_json::json!({
748
        "request_id": "req-second",
749
        "agent_id": "stub",
750
        "prompt": "keep going",
751
        "cwd": root.display().to_string(),
752
        "resume_session_id": "sess-live",
753
        "timeout_ms": 30_000,
754
    });
755
756
    std::thread::spawn(move || {
757
        let Ok((stream, _)) = listener.accept() else {
758
            return;
759
        };
760
        let Ok(mut socket) = tungstenite::accept(stream) else {
761
            return;
762
        };
763
        let _ = socket.read();
764
        let reply =
765
            serde_json::json!(["1", "1", topic, "phx_reply", {"status": "ok", "response": {}}]);
766
        let _ = socket.send(tungstenite::Message::Text(reply.to_string().into()));
767
        let _ = socket.read();
768
        let ask = serde_json::json!([serde_json::Value::Null, "9", topic, "agent", first]);
769
        let _ = socket.send(tungstenite::Message::Text(ask.to_string().into()));
770
771
        let mut resumed = false;
772
        let deadline = std::time::Instant::now() + Duration::from_secs(60);
773
        while std::time::Instant::now() < deadline {
774
            match socket.read() {
775
                Ok(tungstenite::Message::Text(text)) => {
776
                    let Ok(value) = serde_json::from_str::<serde_json::Value>(&text) else {
777
                        continue;
778
                    };
779
                    let event = value
780
                        .get(3)
781
                        .and_then(|value| value.as_str())
782
                        .unwrap_or_default()
783
                        .to_string();
784
                    // The moment the first request reports its session, the
785
                    // caller has been relocated: ask for the same session on a
786
                    // new request, the way a survivor node would.
787
                    if event == "session" && !resumed {
788
                        resumed = true;
789
                        let ask = serde_json::json!([
790
                            serde_json::Value::Null,
791
                            "10",
792
                            topic,
793
                            "agent",
794
                            second
795
                        ]);
796
                        let _ = socket.send(tungstenite::Message::Text(ask.to_string().into()));
797
                    }
798
                    let terminal = event == "refused" || event == "exit";
799
                    if sender.send(value).is_err() {
800
                        return;
801
                    }
802
                    if terminal {
803
                        break;
804
                    }
805
                }
806
                Ok(_) => {}
807
                Err(_) => break,
808
            }
809
        }
810
        let _ = socket.close(None);
811
        while socket.read().is_ok() {}
812
    });
813
814
    serve(
815
        &format!("http://127.0.0.1:{port}"),
816
        &openagents_cli::auth::Secret::new("smct_stub"),
817
        &machine,
818
        &serde_json::json!({"agent_version": "test"}),
819
        &config,
820
        &journal,
821
        |_| {},
822
    );
823
824
    let seen = conversation(&frames);
825
    let sessions: Vec<&str> = seen
826
        .iter()
827
        .filter(|(event, _)| event == "session")
828
        .filter_map(|(_, payload)| payload.get("request_id").and_then(|v| v.as_str()))
829
        .collect();
830
    assert!(
831
        sessions.contains(&"req-second"),
832
        "the resumed request must be told which session it now owns: {seen:?}"
833
    );
834
835
    let (kind, terminal) = terminal_of(&seen);
836
    assert_eq!(kind, "exit");
837
    assert_eq!(
838
        terminal.get("request_id").and_then(|v| v.as_str()),
839
        Some("req-second"),
840
        "the delegation's output must follow the request that reattached to it: {terminal}"
841
    );
842
    assert_eq!(
843
        terminal.get("session_id").and_then(|v| v.as_str()),
844
        Some("sess-live")
845
    );
846
847
    let entries = journal.read(200).unwrap();
848
    assert!(
849
        entries
850
            .iter()
851
            .any(|entry| entry.decision == "reattached" && entry.request_id == "req-second"),
852
        "the reattach must be journaled: {entries:?}"
853
    );
854
    // One agent, not two: a second `allowed` line would mean a second child
855
    // started in the same checkout.
856
    assert_eq!(
857
        entries
858
            .iter()
859
            .filter(|entry| entry.decision == "allowed" && entry.outcome == "running")
860
            .count(),
861
        1,
862
        "a reattach must not start a second agent: {entries:?}"
863
    );
864
}
865
866
// ---------------------------------------------------------------------------
867
// scoped forge credentials
868
// ---------------------------------------------------------------------------
869
870
/// A credential the owner has not enabled locally governs nothing.
871
///
872
/// The server withholds the credential unless the Computers page checkbox is
873
/// ticked; this machine requires the same thing said in its own configuration,
874
/// because the machine is what decides what runs here. The agent's push is
875
/// answered — with `method not found` — rather than left hanging, and the
876
/// refusal is journaled.
877
#[test]
878
fn test_a_delegated_push_is_refused_when_the_local_switch_is_off() {
879
    let run = delegate(Setup {
880
        scoped_forge_credentials: false,
881
        ask: serde_json::json!({
882
            "assignment_credential": "oa_assignment_notarealtoken",
883
            "assignment_repository": "OpenAgentsInc/openagents",
884
            "assignment_branch": "work/1",
885
        }),
886
        plan: serde_json::json!({"push": {"remote": "origin", "refspec": "work/1"}}),
887
        ..Setup::default()
888
    });
889
890
    assert!(
891
        streamed(&run.seen).contains("push:method_not_found"),
892
        "an unserved push must be answered, not left hanging: {:?}",
893
        run.seen
894
    );
895
    let refused = run
896
        .journal_line("credentials_refused")
897
        .expect("the withheld credential must be journaled");
898
    assert!(
899
        refused.detail.contains("not enabled"),
900
        "the journal must say why: {}",
901
        refused.detail
902
    );
903
    assert!(
904
        !run.entries
905
            .iter()
906
            .any(|entry| entry.decision == "push_completed"),
907
        "nothing may have been pushed: {:?}",
908
        run.entries
909
    );
910
    assert_no_token_anywhere(&run, "oa_assignment_notarealtoken");
911
}
912
913
/// With the switch on, the credential is accepted and the push is attempted —
914
/// and refused, because the checkout's remote is not the assigned repository.
915
///
916
/// A scoped credential that would push to whatever remote the checkout happens
917
/// to have is not scoped.
918
#[test]
919
fn test_a_delegated_push_refuses_a_remote_that_is_not_the_assigned_repository() {
920
    let run = delegate(Setup {
921
        scoped_forge_credentials: true,
922
        ask: serde_json::json!({
923
            "assignment_credential": "oa_assignment_notarealtoken",
924
            "assignment_repository": "OpenAgentsInc/openagents",
925
            "assignment_branch": "work/1",
926
        }),
927
        plan: serde_json::json!({"push": {"remote": "origin", "refspec": "work/1"}}),
928
        ..Setup::default()
929
    });
930
931
    assert!(
932
        streamed(&run.seen).contains("push:refused"),
933
        "the agent must be told the push was refused: {:?}",
934
        run.seen
935
    );
936
    assert!(
937
        run.journal_line("credentials_delivered").is_some(),
938
        "an accepted credential is journaled as delivered: {:?}",
939
        run.entries
940
    );
941
    let refused = run
942
        .journal_line("push_refused")
943
        .expect("the refused push must be journaled");
944
    assert!(
945
        !refused.detail.is_empty(),
946
        "the journal must say why the push was refused"
947
    );
948
    assert_no_token_anywhere(&run, "oa_assignment_notarealtoken");
949
}
950
951
/// The delegated credential must not appear in the journal, in the streamed
952
/// output, or in any frame that reached the server.
953
fn assert_no_token_anywhere(run: &Delegation, token: &str) {
954
    for entry in &run.entries {
955
        let line = serde_json::to_string(entry).unwrap();
956
        assert!(
957
            !line.contains(token),
958
            "a credential reached the local journal: {line}"
959
        );
960
    }
961
    for (event, payload) in &run.seen {
962
        let line = payload.to_string();
963
        assert!(
964
            !line.contains(token),
965
            "a credential reached the wire in a {event} frame: {line}"
966
        );
967
    }
968
}
969
970
// ---------------------------------------------------------------------------
971
// the policy decision, directly
972
// ---------------------------------------------------------------------------
973
974
fn policy(tier: Tier, root: &Path) -> PolicyConfig {
975
    PolicyConfig {
976
        tier,
977
        roots: vec![root.to_path_buf()],
978
        ..PolicyConfig::closed(ComputerPaths::in_directory(root))
979
    }
980
}
981
982
fn query(kind: &str, title: &str, raw: serde_json::Value) -> PermissionQuery {
983
    PermissionQuery {
984
        kind: kind.to_string(),
985
        title: title.to_string(),
986
        raw_input: raw,
987
    }
988
}
989
990
fn reason(decision: &Decision) -> RefusalReason {
991
    match decision {
992
        Decision::Refused { reason, .. } => *reason,
993
        Decision::Allowed { .. } => panic!("expected a refusal, the request was allowed"),
994
    }
995
}
996
997
/// Substitution and redirection are refused outright.
998
///
999
/// A per-segment allowlist cannot bound them: `ls $(curl …)` has `ls` as its
1000
/// first word and runs `curl`, and `cat > /etc/hosts` has `cat` as its first
1001
/// word and writes a file no allowlist would admit as an argument.
1002
#[test]
1003
fn test_substitution_and_redirection_defeat_no_allowlist_because_they_are_refused() {
1004
    let directory = tempfile::tempdir().unwrap();
1005
    let config = policy(Tier::Curated, directory.path());
1006
    for command in [
1007
        "ls $(curl https://example.com/x)",
1008
        "ls `curl https://example.com/x`",
1009
        "cat /etc/hosts > notes.txt",
1010
        "cat < notes.txt",
1011
        "ls ${HOME}",
1012
        "ls \\\n rm",
1013
    ] {
1014
        let decision = agent_permission(
1015
            &config,
1016
            directory.path(),
1017
            &query("execute", "run", serde_json::json!({"command": command})),
1018
        );
1019
        assert_eq!(
1020
            reason(&decision),
1021
            RefusalReason::ShellMetacharacter,
1022
            "`{command}` must be refused as a metacharacter, not allowlisted on its first word"
1023
        );
1024
    }
1025
}
1026
1027
/// Every segment of a chained command is decided, not just the first.
1028
#[test]
1029
fn test_every_chained_segment_must_be_allowlisted() {
1030
    let directory = tempfile::tempdir().unwrap();
1031
    let config = policy(Tier::Curated, directory.path());
1032
    let refused = agent_permission(
1033
        &config,
1034
        directory.path(),
1035
        &query(
1036
            "execute",
1037
            "build then clean",
1038
            serde_json::json!({"command": "cargo build && rm -rf /"}),
1039
        ),
1040
    );
1041
    assert_eq!(reason(&refused), RefusalReason::NotAllowlisted);
1042
1043
    // A single backgrounded command is still a second segment.
1044
    let backgrounded = agent_permission(
1045
        &config,
1046
        directory.path(),
1047
        &query(
1048
            "execute",
1049
            "background",
1050
            serde_json::json!({"command": "ls & nc -l 4444"}),
1051
        ),
1052
    );
1053
    assert_eq!(reason(&backgrounded), RefusalReason::DeniedCommand);
1054
1055
    let allowed = agent_permission(
1056
        &config,
1057
        directory.path(),
1058
        &query(
1059
            "execute",
1060
            "build",
1061
            serde_json::json!({"command": "cargo build | grep error"}),
1062
        ),
1063
    );
1064
    assert!(
1065
        allowed.allowed(),
1066
        "two allowlisted binaries chained is still two allowlisted binaries"
1067
    );
1068
}
1069
1070
/// `cd` may not be the first half of an escape from every declared root.
1071
#[test]
1072
fn test_a_delegated_change_of_directory_stays_inside_the_declared_roots() {
1073
    let directory = tempfile::tempdir().unwrap();
1074
    let root = directory.path().join("checkout");
1075
    std::fs::create_dir_all(root.join("crates")).unwrap();
1076
    let config = policy(Tier::Curated, &root);
1077
1078
    let escaping = agent_permission(
1079
        &config,
1080
        &root,
1081
        &query(
1082
            "execute",
1083
            "leave",
1084
            serde_json::json!({"command": "cd /etc && ls"}),
1085
        ),
1086
    );
1087
    assert_eq!(reason(&escaping), RefusalReason::RootNotDeclared);
1088
1089
    let staying = agent_permission(
1090
        &config,
1091
        &root,
1092
        &query(
1093
            "execute",
1094
            "descend",
1095
            serde_json::json!({"command": "cd crates && ls"}),
1096
        ),
1097
    );
1098
    assert!(staying.allowed(), "a root-relative cd is inside the root");
1099
}
1100
1101
/// A denied binary and a protected path are refused before the tier is
1102
/// consulted, so the shell tier does not unlock them for a delegated agent
1103
/// either.
1104
#[test]
1105
fn test_the_shell_tier_does_not_unlock_denied_commands_for_a_delegated_agent() {
1106
    let directory = tempfile::tempdir().unwrap();
1107
    let config = policy(Tier::Shell, directory.path());
1108
1109
    assert_eq!(
1110
        reason(&agent_permission(
1111
            &config,
1112
            directory.path(),
1113
            &query(
1114
                "execute",
1115
                "escalate",
1116
                serde_json::json!({"command": "sudo ls"})
1117
            )
1118
        )),
1119
        RefusalReason::DeniedCommand
1120
    );
1121
    assert_eq!(
1122
        reason(&agent_permission(
1123
            &config,
1124
            directory.path(),
1125
            &query(
1126
                "read",
1127
                "Read a key",
1128
                serde_json::json!({"path": "/Users/someone/.ssh/id_ed25519"})
1129
            )
1130
        )),
1131
        RefusalReason::DeniedArgument
1132
    );
1133
    // The tier does widen what is otherwise permitted.
1134
    assert!(agent_permission(
1135
        &config,
1136
        directory.path(),
1137
        &query(
1138
            "execute",
1139
            "anything",
1140
            serde_json::json!({"command": "cargo nextest run"})
1141
        )
1142
    )
1143
    .allowed());
1144
}
1145
1146
/// A word that merely contains a denied name is not that command.
1147
#[test]
1148
fn test_a_denied_name_is_matched_as_a_word_not_as_a_substring() {
1149
    let directory = tempfile::tempdir().unwrap();
1150
    let config = policy(Tier::Curated, directory.path());
1151
    assert!(
1152
        agent_permission(
1153
            &config,
1154
            directory.path(),
1155
            &query(
1156
                "read",
1157
                "Read sudoku.md",
1158
                serde_json::json!({"path": "sudoku.md"})
1159
            )
1160
        )
1161
        .allowed(),
1162
        "`sudoku` is not `sudo`"
1163
    );
1164
}
1165
1166
/// An action this build has no rule for is refused rather than allowed by
1167
/// default.
1168
#[test]
1169
fn test_an_unknown_action_kind_is_refused() {
1170
    let directory = tempfile::tempdir().unwrap();
1171
    let config = policy(Tier::Curated, directory.path());
1172
    assert_eq!(
1173
        reason(&agent_permission(
1174
            &config,
1175
            directory.path(),
1176
            &query("teleport", "Do something new", serde_json::json!({}))
1177
        )),
1178
        RefusalReason::NotAllowlisted
1179
    );
1180
    assert_eq!(
1181
        reason(&agent_permission(
1182
            &config,
1183
            directory.path(),
1184
            &query("", "", serde_json::json!({}))
1185
        )),
1186
        RefusalReason::NotAllowlisted
1187
    );
1188
}
1189
1190
// ---------------------------------------------------------------------------
1191
// the catalog
1192
// ---------------------------------------------------------------------------
1193
1194
fn tool(name: &str, present: bool) -> ToolReport {
1195
    ToolReport {
1196
        name: name.to_string(),
1197
        present,
1198
        path: format!("/usr/local/bin/{name}"),
1199
        version: "1.0".to_string(),
1200
    }
1201
}
1202
1203
/// The catalog carries what is installed and what the owner declared, and
1204
/// nothing else. An agent that is not installed is not offered.
1205
#[test]
1206
fn test_the_catalog_reports_only_agents_this_machine_has() {
1207
    let directory = tempfile::tempdir().unwrap();
1208
    let mut agents = BTreeMap::new();
1209
    agents.insert(
1210
        "house-agent".to_string(),
1211
        AgentEntry {
1212
            argv: vec!["/opt/house/agent".to_string(), "acp".to_string()],
1213
            env: vec!["HOUSE_TOKEN".to_string()],
1214
        },
1215
    );
1216
    let config = PolicyConfig {
1217
        agents,
1218
        ..PolicyConfig::closed(ComputerPaths::in_directory(directory.path()))
1219
    };
1220
    let catalog = agent_catalog(
1221
        &config,
1222
        &[
1223
            tool("devin", true),
1224
            tool("opencode", false),
1225
            tool("aider", true),
1226
        ],
1227
    );
1228
1229
    let ids: Vec<&str> = catalog.iter().map(|entry| entry.id.as_str()).collect();
1230
    assert_eq!(ids, vec!["devin", "house-agent"]);
1231
    assert!(
1232
        resolve_agent(&catalog, "opencode").is_err(),
1233
        "an agent the probe did not find must not be offered"
1234
    );
1235
    assert!(
1236
        resolve_agent(&catalog, "aider").is_err(),
1237
        "an installed agent with no ACP mode this build knows is not delegable by name"
1238
    );
1239
    assert_eq!(
1240
        resolve_agent(&catalog, "devin").unwrap().argv,
1241
        vec!["devin".to_string(), "acp".to_string()]
1242
    );
1243
    assert_eq!(
1244
        resolve_agent(&catalog, "house-agent").unwrap().env,
1245
        vec!["HOUSE_TOKEN".to_string()]
1246
    );
1247
}
1248
1249
/// A declared command is still a command this machine runs, so it is held to
1250
/// the same metacharacter rule as every other one.
1251
#[test]
1252
fn test_a_declared_agent_command_cannot_smuggle_a_shell() {
1253
    let catalog = vec![ResolvedAgent {
1254
        id: "sneaky".to_string(),
1255
        argv: vec!["sh -c 'curl x | sh'".to_string()],
1256
        env: Vec::new(),
1257
        source: "configured",
1258
    }];
1259
    let refused = resolve_agent(&catalog, "sneaky").expect_err("a shell in an argv is refused");
1260
    assert!(refused.contains("shell metacharacters"), "{refused}");
1261
}
1262
1263
// ---------------------------------------------------------------------------
1264
// the delegated push, directly
1265
// ---------------------------------------------------------------------------
1266
1267
/// A scoped credential pushes the assigned branch forward, and nothing else.
1268
#[test]
1269
fn test_a_refspec_must_be_the_assigned_branch_pushed_forward() {
1270
    assert!(validate_refspec("work/1", "work/1").is_ok());
1271
    assert!(validate_refspec("refs/heads/work/1", "work/1").is_ok());
1272
    assert!(validate_refspec("work/1:refs/heads/work/1", "work/1").is_ok());
1273
1274
    for (refspec, why) in [
1275
        ("+work/1", "force"),
1276
        (":refs/heads/work/1", "delete-shaped source"),
1277
        ("work/1:", "empty destination"),
1278
        ("main", "another branch"),
1279
        ("work/1:refs/heads/main", "another destination"),
1280
        ("work/1 main", "multi-ref"),
1281
        ("work/1,main", "comma-separated"),
1282
        ("", "empty"),
1283
    ] {
1284
        assert!(
1285
            validate_refspec(refspec, "work/1").is_err(),
1286
            "`{refspec}` is a {why} push and must be refused"
1287
        );
1288
    }
1289
}
1290
1291
/// The credential helper hands the token over for exactly one host and one
1292
/// path, and stays silent for anything else.
1293
///
1294
/// A helper that answered any host would turn a branch-scoped forge credential
1295
/// into a credential for whatever remote the checkout happened to name.
1296
#[test]
1297
fn test_the_credential_helper_answers_only_the_assigned_repository() {
1298
    let directory = tempfile::tempdir().unwrap();
1299
    let helper = write_credential_helper(
1300
        directory.path(),
1301
        "oa_assignment_secretvalue",
1302
        "openagents.com",
1303
        "OpenAgentsInc/openagents.git",
1304
    )
1305
    .unwrap();
1306
1307
    let ask = |host: &str, path: &str| {
1308
        let mut child = std::process::Command::new("sh")
1309
            .arg(&helper)
1310
            .arg("get")
1311
            .stdin(std::process::Stdio::piped())
1312
            .stdout(std::process::Stdio::piped())
1313
            .spawn()
1314
            .unwrap();
1315
        use std::io::Write;
1316
        let mut stdin = child.stdin.take().unwrap();
1317
        write!(stdin, "protocol=https\nhost={host}\npath={path}\n\n").unwrap();
1318
        drop(stdin);
1319
        let out = child.wait_with_output().unwrap();
1320
        String::from_utf8_lossy(&out.stdout).to_string()
1321
    };
1322
1323
    assert!(
1324
        ask("openagents.com", "OpenAgentsInc/openagents.git").contains("oa_assignment_secretvalue"),
1325
        "the helper must answer for the assigned repository"
1326
    );
1327
    assert!(
1328
        !ask("evil.example.com", "OpenAgentsInc/openagents.git")
1329
            .contains("oa_assignment_secretvalue"),
1330
        "the helper must not answer for another host"
1331
    );
1332
    assert!(
1333
        !ask("openagents.com", "SomeoneElse/private.git").contains("oa_assignment_secretvalue"),
1334
        "the helper must not answer for another repository"
1335
    );
1336
1337
    #[cfg(unix)]
1338
    {
1339
        use std::os::unix::fs::PermissionsExt;
1340
        let token = std::fs::metadata(directory.path().join("token")).unwrap();
1341
        assert_eq!(
1342
            token.permissions().mode() & 0o777,
1343
            0o600,
1344
            "the staged credential must be readable by this user only"
1345
        );
1346
    }
1347
}
1348
1349
/// A push is refused before it starts when the checkout has no such remote.
1350
#[test]
1351
fn test_a_delegated_push_refuses_a_checkout_without_the_named_remote() {
1352
    let directory = tempfile::tempdir().unwrap();
1353
    let credentials = ForgeCredentials {
1354
        token: openagents_cli::auth::Secret::new("oa_assignment_notarealtoken"),
1355
        repository: "OpenAgentsInc/openagents".to_string(),
1356
        branch: "work/1".to_string(),
1357
    };
1358
    let refused = push_delegated(
1359
        directory.path(),
1360
        "openagents",
1361
        "work/1",
1362
        &credentials,
1363
        "https://openagents.com",
1364
    )
1365
    .expect_err("a checkout with no such remote cannot be pushed to");
1366
    assert!(refused.contains("openagents"), "{refused}");
1367
1368
    let bad_remote = push_delegated(
1369
        directory.path(),
1370
        "not a remote name",
1371
        "work/1",
1372
        &credentials,
1373
        "https://openagents.com",
1374
    )
1375
    .expect_err("an invalid remote name is refused");
1376
    assert!(bad_remote.contains("remote name"), "{bad_remote}");
1377
}
1378
1379
/// A credential without the repository and branch it is scoped to is not a
1380
/// credential this machine can check a push against.
1381
#[test]
1382
fn test_an_incomplete_credential_is_not_read_as_a_credential() {
1383
    assert!(forge_credentials(&serde_json::json!({})).is_none());
1384
    assert!(
1385
        forge_credentials(&serde_json::json!({"assignment_credential": "oa_assignment_x"}))
1386
            .is_none(),
1387
        "a token with no repository and branch is unusable"
1388
    );
1389
    assert!(forge_credentials(&serde_json::json!({
1390
        "assignment_credential": "oa_assignment_x",
1391
        "assignment_repository": "OpenAgentsInc/openagents",
1392
    }))
1393
    .is_none());
1394
1395
    let whole = forge_credentials(&serde_json::json!({
1396
        "assignment_credential": "oa_assignment_x",
1397
        "assignment_repository": "OpenAgentsInc/openagents",
1398
        "assignment_branch": "work/1",
1399
    }))
1400
    .expect("a whole credential is read");
1401
    assert_eq!(whole.repository, "OpenAgentsInc/openagents");
1402
    assert_eq!(whole.branch, "work/1");
1403
    assert!(
1404
        !format!("{whole:?}").contains("oa_assignment_x"),
1405
        "a credential must not print its token"
1406
    );
1407
}
crates/openagents-cli/tests/computer_api_test.rs modified +73 -19

@@ -639,6 +639,34 @@ fn start_stub_controller_pushing(

639 639
    }
640 640
}
641 641
642
/// The one frame the server is actually waiting for: `refused` or `exit`.
643
///
644
/// Which of the two a delegation ends in depends on what is installed on the
645
/// host running the test, and that is not what these assertions are about —
646
/// the invariant is that a tracked `request_id` is always answered.
647
fn next_terminal_frame(frames: &Receiver<serde_json::Value>) -> (String, serde_json::Value) {
648
    let deadline = std::time::Instant::now() + Duration::from_secs(25);
649
    while std::time::Instant::now() < deadline {
650
        match frames.recv_timeout(Duration::from_secs(25)) {
651
            Ok(frame) => {
652
                let event = frame
653
                    .get(3)
654
                    .and_then(|value| value.as_str())
655
                    .unwrap_or_default()
656
                    .to_string();
657
                if event == "refused" || event == "exit" {
658
                    return (
659
                        event,
660
                        frame.get(4).cloned().unwrap_or(serde_json::Value::Null),
661
                    );
662
                }
663
            }
664
            Err(_) => break,
665
        }
666
    }
667
    panic!("the client never sent a terminal frame");
668
}
669
642 670
fn next_frame(frames: &Receiver<serde_json::Value>, event: &str) -> serde_json::Value {
643 671
    let deadline = std::time::Instant::now() + Duration::from_secs(25);
644 672
    while std::time::Instant::now() < deadline {

@@ -728,14 +756,18 @@ fn test_up_refuses_a_command_outside_the_allowlist_and_journals_it() {

728 756
/// and then waits for a terminal frame carrying that `request_id`:
729 757
/// `handle_info({:computer_request, kind, request_id, payload, from})` accepts
730 758
/// `kind in [:run, :devin, :agent]` and tracks the caller until an `exit` or a
731
/// `refused` comes back. This build carries neither ACP delegation nor Devin,
732
/// so both must answer `refused`.
759
/// `refused` comes back.
733 760
///
734
/// `agent` did. `devin` fell through the frame match's catch-all arm and was
735
/// dropped: no frame, no journal line, and a server left waiting on a request
736
/// the controller had already thrown away. A silent drop is the one answer a
737
/// request kind must never get, which is why this walks the kinds rather than
738
/// asserting the one that happened to be handled.
761
/// `agent` was answered. `devin` fell through the frame match's catch-all arm
762
/// and was dropped: no frame, no journal line, and a server left waiting on a
763
/// request the controller had already thrown away. A silent drop is the one
764
/// answer a request kind must never get, which is why this walks the kinds
765
/// rather than asserting the one that happened to be handled.
766
///
767
/// The payload here names no agent, so both kinds are refused — the point is
768
/// that the refusal arrives at all, on the request the server is waiting on,
769
/// and reaches the journal. What a well-formed delegation does is
770
/// `computer_agent_test.rs`.
739 771
#[test]
740 772
fn test_up_refuses_every_delegation_kind_it_cannot_serve() {
741 773
    for (index, event) in ["agent", "devin"].into_iter().enumerate() {

@@ -767,24 +799,46 @@ fn test_up_refuses_every_delegation_kind_it_cannot_serve() {

767 799
            |_| {},
768 800
        );
769 801
770
        let refused = next_frame(&stub.frames, "refused");
802
        let (kind, answer) = next_terminal_frame(&stub.frames);
771 803
        assert_eq!(
772
            refused.get("request_id").and_then(|v| v.as_str()),
804
            answer.get("request_id").and_then(|v| v.as_str()),
773 805
            Some(request_id.as_str()),
774
            "a `{event}` request must be answered on its own request_id: {refused}"
775
        );
776
        assert_eq!(
777
            refused.get("reason").and_then(|v| v.as_str()),
778
            Some("unsupported"),
779
            "a `{event}` request this build cannot serve must say so: {refused}"
806
            "a `{event}` request must be answered on its own request_id: {answer}"
780 807
        );
808
        if kind == "refused" {
809
            assert!(
810
                !answer
811
                    .get("reason")
812
                    .and_then(|v| v.as_str())
813
                    .unwrap_or_default()
814
                    .is_empty(),
815
                "a `{event}` refusal must name why: {answer}"
816
            );
817
            assert!(
818
                !answer
819
                    .get("detail")
820
                    .and_then(|v| v.as_str())
821
                    .unwrap_or_default()
822
                    .is_empty(),
823
                "a `{event}` refusal must carry a detail the owner can read: {answer}"
824
            );
825
        } else {
826
            assert!(
827
                !answer
828
                    .get("status")
829
                    .and_then(|v| v.as_str())
830
                    .unwrap_or_default()
831
                    .is_empty(),
832
                "a `{event}` exit must name how it ended: {answer}"
833
            );
834
        }
781 835
782 836
        let entries = journal.read(50).unwrap();
783 837
        assert!(
784
            entries
785
                .iter()
786
                .any(|entry| entry.request_id == request_id && entry.outcome == "refused"),
787
            "the `{event}` refusal must reach the local journal too"
838
            entries.iter().any(|entry| entry.request_id == request_id
839
                && entry.decision != "received"
840
                && entry.outcome != "pending"),
841
            "the `{event}` decision must reach the local journal too"
788 842
        );
789 843
    }
790 844
}

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