Skip to main content

bamboo_subagent/
proto.rs

1//! Wire protocol: discovery record + parent/child WebSocket frames.
2//!
3//! The session/event payloads are kept opaque (`serde_json::Value`) so this crate stays a leaf;
4//! the real `AgentEvent` serializes into [`ChildFrame::Event`] verbatim (design §6, zero mapping).
5
6use bamboo_domain::{ProjectId, SessionActivationPolicy, SessionMessageEnvelope};
7use chrono::{DateTime, Utc};
8use serde::{Deserialize, Serialize};
9
10/// Tier-1 discovery record an actor publishes into the file fabric so others can find it.
11#[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    /// `ws://127.0.0.1:<port>` reachable endpoint.
18    pub endpoint: String,
19    pub pid: u32,
20    #[serde(default)]
21    pub version: String,
22    pub started_at: DateTime<Utc>,
23    /// Lease: a reader treats the record as stale once `now > lease_expires_at`.
24    pub lease_expires_at: DateTime<Utc>,
25}
26
27/// A unit of work a parent assigns to an actor.
28#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
29pub struct RunSpec {
30    pub assignment: String,
31    /// Stable domain identity for the session being activated. Actor process,
32    /// mailbox, and pooled-worker ids are transport details and must never
33    /// replace these values in worker persistence or message routing.
34    #[serde(default, skip_serializing_if = "Option::is_none")]
35    pub logical_session: Option<LogicalSessionIdentity>,
36    /// Stable Project identity inherited from the parent session. The typed
37    /// wire value rejects unsafe/invalid identifiers during deserialization.
38    #[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    /// Effective permission policy captured by the host at this activation
43    /// boundary. Keeping it on `RunSpec` (rather than only provisioning) lets
44    /// warm, broker and remote workers observe policy revisions and bypass
45    /// changes on their next activation.
46    #[serde(default, skip_serializing_if = "Option::is_none")]
47    pub permission_policy: Option<PermissionPolicyContext>,
48    /// Full prior conversation (serialized domain `Message`s, oldest first),
49    /// INCLUDING the assignment's user message when present. The actor's
50    /// durable state lives in the parent's store; each activation rehydrates
51    /// from here — this is what makes send_message/update/rerun carry context
52    /// across one-shot actor processes. Empty = first activation, no history.
53    #[serde(default, skip_serializing_if = "Vec::is_empty")]
54    pub messages: Vec<serde_json::Value>,
55    /// Independently authoritative id of the host activation whose execution
56    /// this RunSpec starts. Initial and mid-run typed deliveries must match it;
57    /// a delivery's own run-id field is never accepted as self-authentication.
58    #[serde(default, skip_serializing_if = "Option::is_none")]
59    pub activation_run_id: Option<String>,
60    /// Canonical logical-session deliveries that caused this idle actor
61    /// activation. The worker durably enqueues these before entering its first
62    /// provider boundary, then confirms admission over the child frame stream.
63    #[serde(default, skip_serializing_if = "Vec::is_empty")]
64    pub initial_session_messages: Vec<SessionMessageDelivery>,
65    /// Secrets minted for this activation only. They are delivered in-memory
66    /// over the actor transport and must never be persisted by the worker.
67    #[serde(default, skip_serializing_if = "RunSecrets::is_empty")]
68    pub secrets: RunSecrets,
69}
70
71/// Logical session ancestry carried across every actor placement.
72#[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/// One canonical inbox claim forwarded to an active actor. The activation run
81/// id and claim generation make the worker's confirmation unambiguous even if
82/// a stale connection delivers a late frame after a successor has taken over.
83#[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    /// Durable host policy associated with the authorized claim prefix. The
90    /// worker mirrors it onto its local receipt before the safe-turn boundary.
91    #[serde(default)]
92    pub activation_policy: SessionActivationPolicy,
93}
94
95/// Worker proof that its local safe-turn path durably checkpointed and acked a
96/// forwarded envelope. The host still has to checkpoint the canonical logical
97/// transcript before it may ack the canonical claim.
98#[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/// Per-activation secret envelope. A Bamboo-routed Codex token lives here so a
107/// warm worker never reuses a credential from an earlier run.
108#[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/// Serializable secret whose debug representation is always redacted.
121#[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/// Host-computed permission state for one actor activation. The policy payload
142/// is opaque here so `bamboo-subagent` remains a transport leaf.
143#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
144pub struct PermissionPolicyContext {
145    pub revision: u64,
146    /// Exact typed session request (`default`, `bypass`, or `auto`). Empty is a
147    /// rolling-upgrade legacy payload and is derived from the booleans below.
148    #[serde(default)]
149    pub requested_mode: String,
150    /// Host-resolved effective mode, including Plan/read-only hard overlays.
151    /// Empty is accepted only for legacy payloads.
152    #[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    /// Session grants are deliberately not inherited across an actor boundary;
161    /// a future opt-in protocol can set this and carry explicit scoped grants.
162    #[serde(default)]
163    pub inherit_session_grants: bool,
164    pub policy: serde_json::Value,
165}
166
167impl PermissionPolicyContext {
168    /// Validate a newly-produced wire posture and decode rolling-upgrade
169    /// payloads. Dual permissive flags are always contradictory: Auto never
170    /// borrows Bypass semantics.
171    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/// Parent → child control/in-band frames.
248#[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    /// Reply to a [`ChildFrame::ApprovalRequest`] — the host's human/policy
260    /// decision on a gated tool the worker proxied back (Phase 2 child→parent
261    /// approval delegation). `id` correlates to the request. When
262    /// `approved == true` the worker records the grant locally and proceeds;
263    /// `false` denies the tool.
264    ApprovalReply {
265        id: String,
266        approved: bool,
267    },
268}
269
270/// Child → parent event/terminal frames.
271#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
272#[serde(tag = "kind", rename_all = "snake_case")]
273pub enum ChildFrame {
274    /// One agent event, serialized verbatim (the real `AgentEvent` lands here as JSON).
275    Event { event: serde_json::Value },
276    /// The worker hit a tool needing human approval (Phase 2 child→parent
277    /// approval delegation). Proxied to the host — which surfaces it to the
278    /// human via the parent session's pending-question / notification path. The
279    /// host answers with [`ParentFrame::ApprovalReply`] carrying the same `id`.
280    /// `body` carries `{tool_name, permission_type, resource, question}`.
281    ApprovalRequest { id: String, body: serde_json::Value },
282    /// Emitted only after the worker's local SessionInbox transcript + cursor
283    /// checkpoint and admitted receipt are durable.
284    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        /// Full worker transcript (serialized domain `Message`s) shipped on
294        /// suspend so the host can persist it onto the child session and
295        /// rehydrate the worker on resume. Empty for non-suspend terminals.
296        #[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    /// The worker's loop suspended (it spawned its own sub-agents and is waiting
308    /// on them). Non-terminal to the host: the completion coordinator resumes
309    /// the worker (re-dispatch) once its children finish.
310    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        // Suspend terminal carries the worker transcript.
371        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        // Phase 2 approval request/reply round-trip over the per-child WS.
380        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        // An old-style frame (no `messages` field) must still parse.
530        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}