1use bamboo_domain::{ProjectId, SessionActivationPolicy, SessionMessageEnvelope};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
12pub struct AgentRecord {
13 pub agent_id: String,
14 pub role: String,
15 #[serde(default)]
16 pub labels: Vec<String>,
17 pub endpoint: String,
19 pub pid: u32,
20 #[serde(default)]
21 pub version: String,
22 pub started_at: DateTime<Utc>,
23 pub lease_expires_at: DateTime<Utc>,
25}
26
27#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct RunSpec {
30 pub assignment: String,
31 #[serde(default, skip_serializing_if = "Option::is_none")]
35 pub logical_session: Option<LogicalSessionIdentity>,
36 #[serde(default, skip_serializing_if = "Option::is_none")]
39 pub project_id: Option<ProjectId>,
40 #[serde(default, skip_serializing_if = "Option::is_none")]
41 pub reasoning_effort: Option<String>,
42 #[serde(default, skip_serializing_if = "Option::is_none")]
47 pub permission_policy: Option<PermissionPolicyContext>,
48 #[serde(default, skip_serializing_if = "Vec::is_empty")]
54 pub messages: Vec<serde_json::Value>,
55 #[serde(default, skip_serializing_if = "Option::is_none")]
59 pub activation_run_id: Option<String>,
60 #[serde(default, skip_serializing_if = "Vec::is_empty")]
64 pub initial_session_messages: Vec<SessionMessageDelivery>,
65 #[serde(default, skip_serializing_if = "RunSecrets::is_empty")]
68 pub secrets: RunSecrets,
69}
70
71#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
73pub struct LogicalSessionIdentity {
74 pub session_id: String,
75 #[serde(default, skip_serializing_if = "Option::is_none")]
76 pub parent_session_id: Option<String>,
77 pub root_session_id: String,
78}
79
80#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
84pub struct SessionMessageDelivery {
85 pub target_session_id: String,
86 pub envelope: SessionMessageEnvelope,
87 pub canonical_claim_generation: u64,
88 pub activation_run_id: String,
89 #[serde(default)]
92 pub activation_policy: SessionActivationPolicy,
93}
94
95#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
99pub struct SessionMessageAdmissionConfirmation {
100 pub target_session_id: String,
101 pub envelope_id: String,
102 pub canonical_claim_generation: u64,
103 pub activation_run_id: String,
104}
105
106#[derive(Debug, Clone, Default, PartialEq, Serialize, Deserialize)]
109pub struct RunSecrets {
110 #[serde(default, skip_serializing_if = "Option::is_none")]
111 pub codex_provider_token: Option<SecretValue>,
112}
113
114impl RunSecrets {
115 pub fn is_empty(&self) -> bool {
116 self.codex_provider_token.is_none()
117 }
118}
119
120#[derive(Clone, PartialEq, Eq, Serialize, Deserialize)]
122#[serde(transparent)]
123pub struct SecretValue(String);
124
125impl SecretValue {
126 pub fn new(value: impl Into<String>) -> Self {
127 Self(value.into())
128 }
129
130 pub fn expose(&self) -> &str {
131 &self.0
132 }
133}
134
135impl std::fmt::Debug for SecretValue {
136 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
137 formatter.write_str("SecretValue([REDACTED])")
138 }
139}
140
141#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct PermissionPolicyContext {
145 pub revision: u64,
146 #[serde(default)]
149 pub requested_mode: String,
150 #[serde(default)]
153 pub effective_mode: String,
154 pub bypass_permissions: bool,
155 #[serde(default)]
156 pub auto_approve_permissions: bool,
157 pub session_id: String,
158 #[serde(default, skip_serializing_if = "Option::is_none")]
159 pub workspace_path: Option<String>,
160 #[serde(default)]
163 pub inherit_session_grants: bool,
164 pub policy: serde_json::Value,
165}
166
167impl PermissionPolicyContext {
168 pub fn resolved_modes(
172 &self,
173 ) -> Result<
174 (
175 bamboo_domain::SessionPermissionMode,
176 bamboo_domain::PermissionMode,
177 ),
178 String,
179 > {
180 if self.auto_approve_permissions && self.bypass_permissions {
181 return Err(
182 "permission_policy auto_approve_permissions and bypass_permissions are mutually exclusive"
183 .to_string(),
184 );
185 }
186 let has_requested_mode = !self.requested_mode.is_empty();
187 let has_effective_mode = !self.effective_mode.is_empty();
188 if has_requested_mode != has_effective_mode {
189 return Err(
190 "permission_policy requested_mode and effective_mode must be provided together"
191 .to_string(),
192 );
193 }
194 let requested = if self.requested_mode.is_empty() {
195 if self.auto_approve_permissions {
196 bamboo_domain::SessionPermissionMode::Auto
197 } else if self.bypass_permissions {
198 bamboo_domain::SessionPermissionMode::Bypass
199 } else {
200 bamboo_domain::SessionPermissionMode::Default
201 }
202 } else {
203 match self.requested_mode.as_str() {
204 "default" => bamboo_domain::SessionPermissionMode::Default,
205 "bypass" => bamboo_domain::SessionPermissionMode::Bypass,
206 "auto" => bamboo_domain::SessionPermissionMode::Auto,
207 other => return Err(format!("invalid requested permission mode '{other}'")),
208 }
209 };
210 let effective = if self.effective_mode.is_empty() {
211 bamboo_domain::resolve_permission_mode(
212 requested,
213 bamboo_domain::PermissionMode::Default,
214 )
215 .effective
216 } else {
217 bamboo_domain::PermissionMode::from_audit_str(&self.effective_mode).ok_or_else(
218 || {
219 format!(
220 "invalid effective permission mode '{}'",
221 self.effective_mode
222 )
223 },
224 )?
225 };
226 let resolution = bamboo_domain::PermissionModeResolution {
227 requested,
228 effective,
229 };
230 if !resolution.is_consistent() {
231 return Err("permission_policy requested/effective modes are inconsistent".into());
232 }
233 if has_requested_mode {
234 if self.bypass_permissions != resolution.bypass_permissions() {
235 return Err("permission_policy bypass flag disagrees with effective mode".into());
236 }
237 if self.auto_approve_permissions != resolution.suppress_approval_prompts() {
238 return Err(
239 "permission_policy auto flag disagrees with no-prompt resolution".into(),
240 );
241 }
242 }
243 Ok((requested, effective))
244 }
245}
246
247#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
249#[serde(tag = "kind", rename_all = "snake_case")]
250pub enum ParentFrame {
251 Run(RunSpec),
252 Cancel,
253 Message {
254 text: String,
255 },
256 SessionMessage {
257 delivery: SessionMessageDelivery,
258 },
259 ApprovalReply {
265 id: String,
266 approved: bool,
267 },
268}
269
270#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272#[serde(tag = "kind", rename_all = "snake_case")]
273pub enum ChildFrame {
274 Event { event: serde_json::Value },
276 ApprovalRequest { id: String, body: serde_json::Value },
282 SessionMessageAdmitted {
285 confirmation: SessionMessageAdmissionConfirmation,
286 },
287 Terminal {
288 status: TerminalStatus,
289 #[serde(default, skip_serializing_if = "Option::is_none")]
290 result: Option<String>,
291 #[serde(default, skip_serializing_if = "Option::is_none")]
292 error: Option<String>,
293 #[serde(default, skip_serializing_if = "Vec::is_empty")]
297 transcript: Vec<serde_json::Value>,
298 },
299}
300
301#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
302#[serde(rename_all = "snake_case")]
303pub enum TerminalStatus {
304 Completed,
305 Error,
306 Cancelled,
307 Suspended,
311}
312
313impl ParentFrame {
314 pub fn to_text(&self) -> String {
315 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
316 }
317 pub fn from_text(s: &str) -> serde_json::Result<Self> {
318 serde_json::from_str(s)
319 }
320}
321
322impl ChildFrame {
323 pub fn to_text(&self) -> String {
324 serde_json::to_string(self).unwrap_or_else(|_| "{}".to_string())
325 }
326 pub fn from_text(s: &str) -> serde_json::Result<Self> {
327 serde_json::from_str(s)
328 }
329}
330
331#[cfg(test)]
332mod tests {
333 use super::*;
334
335 #[test]
336 fn parent_frames_round_trip() {
337 for f in [
338 ParentFrame::Run(RunSpec {
339 assignment: "do x".into(),
340 logical_session: None,
341 project_id: None,
342 reasoning_effort: None,
343 permission_policy: None,
344 messages: Vec::new(),
345 activation_run_id: None,
346 initial_session_messages: Vec::new(),
347 secrets: Default::default(),
348 }),
349 ParentFrame::Cancel,
350 ParentFrame::Message { text: "hi".into() },
351 ] {
352 assert_eq!(ParentFrame::from_text(&f.to_text()).unwrap(), f);
353 }
354 }
355
356 #[test]
357 fn child_frames_round_trip() {
358 let e = ChildFrame::Event {
359 event: serde_json::json!({"type":"token","content":"hi"}),
360 };
361 assert_eq!(ChildFrame::from_text(&e.to_text()).unwrap(), e);
362 let t = ChildFrame::Terminal {
363 status: TerminalStatus::Completed,
364 result: Some("done".into()),
365 error: None,
366 transcript: Vec::new(),
367 };
368 assert_eq!(ChildFrame::from_text(&t.to_text()).unwrap(), t);
369
370 let s = ChildFrame::Terminal {
372 status: TerminalStatus::Suspended,
373 result: None,
374 error: None,
375 transcript: vec![serde_json::json!({"role":"assistant","content":"x"})],
376 };
377 assert_eq!(ChildFrame::from_text(&s.to_text()).unwrap(), s);
378
379 let areq = ChildFrame::ApprovalRequest {
381 id: "a1".into(),
382 body: serde_json::json!({
383 "tool_name": "Write",
384 "permission_type": "WriteFile",
385 "resource": "/tmp/x",
386 "question": "approve?",
387 }),
388 };
389 assert_eq!(ChildFrame::from_text(&areq.to_text()).unwrap(), areq);
390 let areply = ParentFrame::ApprovalReply {
391 id: "a1".into(),
392 approved: true,
393 };
394 assert_eq!(ParentFrame::from_text(&areply.to_text()).unwrap(), areply);
395 }
396
397 #[test]
398 fn run_frame_tag_is_stable() {
399 let f = ParentFrame::Run(RunSpec {
400 assignment: "a".into(),
401 logical_session: None,
402 project_id: None,
403 reasoning_effort: Some("high".into()),
404 permission_policy: None,
405 messages: Vec::new(),
406 activation_run_id: None,
407 initial_session_messages: Vec::new(),
408 secrets: Default::default(),
409 });
410 let v: serde_json::Value = serde_json::from_str(&f.to_text()).unwrap();
411 assert_eq!(v["kind"], "run");
412 assert_eq!(v["assignment"], "a");
413 assert!(v.get("secrets").is_none());
414 }
415
416 #[test]
417 fn run_secret_round_trips_but_debug_output_is_redacted() {
418 let secret = SecretValue::new("bcx1_secret-570");
419 assert_eq!(format!("{secret:?}"), "SecretValue([REDACTED])");
420 assert!(!format!(
421 "{:?}",
422 RunSecrets {
423 codex_provider_token: Some(secret.clone()),
424 }
425 )
426 .contains("secret-570"));
427
428 let frame = ParentFrame::Run(RunSpec {
429 assignment: "a".into(),
430 logical_session: None,
431 project_id: None,
432 reasoning_effort: None,
433 permission_policy: None,
434 messages: Vec::new(),
435 activation_run_id: None,
436 initial_session_messages: Vec::new(),
437 secrets: RunSecrets {
438 codex_provider_token: Some(secret),
439 },
440 });
441 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
442 assert_eq!(decoded, frame);
443 }
444
445 #[test]
446 fn permission_policy_context_round_trips_at_run_boundary() {
447 let context = PermissionPolicyContext {
448 revision: 9,
449 requested_mode: "bypass".into(),
450 effective_mode: "bypass".into(),
451 bypass_permissions: true,
452 auto_approve_permissions: false,
453 session_id: "child-1".into(),
454 workspace_path: Some("/workspace/project".into()),
455 inherit_session_grants: false,
456 policy: serde_json::json!({"enabled":true,"durable_rules":[]}),
457 };
458 let frame = ParentFrame::Run(RunSpec {
459 assignment: "work".into(),
460 logical_session: None,
461 project_id: None,
462 reasoning_effort: None,
463 permission_policy: Some(context.clone()),
464 messages: Vec::new(),
465 activation_run_id: None,
466 initial_session_messages: Vec::new(),
467 secrets: Default::default(),
468 });
469 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
470 assert_eq!(decoded, frame);
471 let ParentFrame::Run(run) = decoded else {
472 panic!("expected run frame");
473 };
474 assert_eq!(run.permission_policy, Some(context));
475 }
476
477 #[test]
478 fn legacy_permission_policy_context_defaults_auto_to_false() {
479 let frame = ParentFrame::from_text(
480 r#"{"kind":"run","assignment":"work","permission_policy":{"revision":8,"bypass_permissions":true,"session_id":"legacy-child","inherit_session_grants":false,"policy":{}}}"#,
481 )
482 .unwrap();
483 let ParentFrame::Run(run) = frame else {
484 panic!("expected run frame");
485 };
486 let context = run.permission_policy.expect("permission policy");
487 assert!(context.bypass_permissions);
488 assert!(!context.auto_approve_permissions);
489 assert_eq!(
490 context.resolved_modes().unwrap(),
491 (
492 bamboo_domain::SessionPermissionMode::Bypass,
493 bamboo_domain::PermissionMode::BypassPermissions,
494 )
495 );
496 }
497
498 #[test]
499 fn permission_policy_rejects_partial_typed_mode_pairs() {
500 let context = PermissionPolicyContext {
501 revision: 1,
502 requested_mode: "auto".to_string(),
503 effective_mode: String::new(),
504 bypass_permissions: false,
505 auto_approve_permissions: true,
506 session_id: "partial-policy".to_string(),
507 workspace_path: None,
508 inherit_session_grants: false,
509 policy: serde_json::json!({}),
510 };
511 assert!(context
512 .resolved_modes()
513 .unwrap_err()
514 .contains("provided together"));
515
516 let effective_only = PermissionPolicyContext {
517 requested_mode: String::new(),
518 effective_mode: "auto".to_string(),
519 ..context
520 };
521 assert!(effective_only
522 .resolved_modes()
523 .unwrap_err()
524 .contains("provided together"));
525 }
526
527 #[test]
528 fn run_frame_without_messages_parses_backward_compat() {
529 let parsed = ParentFrame::from_text(r#"{"kind":"run","assignment":"x"}"#).unwrap();
531 match parsed {
532 ParentFrame::Run(spec) => {
533 assert_eq!(spec.assignment, "x");
534 assert!(spec.messages.is_empty());
535 }
536 other => panic!("expected run frame, got {other:?}"),
537 }
538 }
539
540 #[test]
541 fn run_frame_round_trips_typed_project_identity() {
542 let frame = ParentFrame::Run(RunSpec {
543 assignment: "work".into(),
544 logical_session: None,
545 project_id: Some(ProjectId::parse("project-1").unwrap()),
546 reasoning_effort: None,
547 permission_policy: None,
548 messages: Vec::new(),
549 activation_run_id: None,
550 initial_session_messages: Vec::new(),
551 secrets: Default::default(),
552 });
553
554 let decoded = ParentFrame::from_text(&frame.to_text()).unwrap();
555 assert_eq!(decoded, frame);
556 }
557
558 #[test]
559 fn run_frame_rejects_unsafe_project_identity() {
560 let error =
561 ParentFrame::from_text(r#"{"kind":"run","assignment":"x","project_id":"../other"}"#)
562 .unwrap_err();
563
564 assert!(error.to_string().contains("invalid project id"));
565 }
566}