|
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
|
+ |
}
|