Skip to main content

harness_codex/
s08_owned_child_turn.rs

1// SPDX-License-Identifier: AGPL-3.0-or-later
2
3//! Bounded, content-free S08 owned-child OpenAI subscription probe.
4//!
5//! This is deliberately a thin caller of the reviewed app-server JSONL
6//! protocol. It never accepts prompt content, credentials, or provider URLs.
7//! Both the standalone CLI and Desktop seams create fresh owned children from
8//! their separately pinned app-server executables.
9
10use crate::{
11    AppServerLineTransport, AppServerProcessError, CodexAppServerClient, ReasoningEffort,
12    TerminalStatus,
13};
14use serde::Serialize;
15use sha2::{Digest as _, Sha256};
16
17pub const S08_OWNED_CHILD_TURN_SCHEMA: &str = "steadymux.s08-owned-child-turn.v2";
18pub const S08_OPENAI_MODEL: &str = "gpt-5.6-terra";
19const S08_PROBE_TEXT: &str = "SteadyMux S08 authorized owned-child probe.";
20
21#[derive(Clone, Debug, PartialEq, Eq, Serialize)]
22pub struct S08OwnedChildTurnReceipt {
23    pub schema: &'static str,
24    pub status: &'static str,
25    pub correlation: String,
26    /// Terminal token counters only. They are bound into `receipt_digest` with
27    /// the exact owned-child thread and turn identity; no content is retained.
28    pub input_tokens: u64,
29    pub output_tokens: u64,
30    pub total_tokens: u64,
31    pub receipt_digest: String,
32}
33
34#[derive(Serialize)]
35struct ReceiptDigestInput<'a> {
36    schema: &'static str,
37    correlation: &'a str,
38    thread_id: &'a str,
39    turn_id: &'a str,
40    model: &'a str,
41    effort: ReasoningEffort,
42    input_tokens: u64,
43    output_tokens: u64,
44    total_tokens: u64,
45}
46
47/// Runs exactly one fixed, tool-free OpenAI subscription turn through an
48/// injected app-server transport. The receipt is content-free and its
49/// correlation is the exact client message ID accepted by the protocol.
50pub fn run_s08_owned_child_turn<T: AppServerLineTransport>(
51    client: &mut CodexAppServerClient<T>,
52    correlation: &str,
53) -> Result<S08OwnedChildTurnReceipt, AppServerProcessError> {
54    if !valid_correlation(correlation) {
55        return Err(AppServerProcessError::InvalidConfiguration);
56    }
57    client.initialize()?;
58    let discovery = client.discover()?;
59    if discovery.rate_limits.reached_type.is_some()
60        || !discovery.account.requires_openai_auth
61        || !discovery
62            .model_pages
63            .iter()
64            .flat_map(|page| &page.models)
65            .any(|model| {
66                model.id == S08_OPENAI_MODEL
67                    && model.supported_efforts.contains(&ReasoningEffort::Medium)
68            })
69    {
70        return Err(AppServerProcessError::UnsupportedExecutableVersion);
71    }
72    let turn = client.start_turn(
73        correlation,
74        S08_PROBE_TEXT,
75        S08_OPENAI_MODEL,
76        ReasoningEffort::Medium,
77    )?;
78    let run = client.wait_for_terminal(&turn)?;
79    let shutdown = client.shutdown()?;
80    if !shutdown.success
81        || run.completion.status != TerminalStatus::Completed
82        || run.completion.selected_model != S08_OPENAI_MODEL
83        || run.completion.selected_effort != ReasoningEffort::Medium
84        || run.completion.output.is_none()
85    {
86        return Err(AppServerProcessError::UnexpectedTerminalStatus);
87    }
88    let (Some(input_tokens), Some(output_tokens)) =
89        (run.completion.input_tokens, run.completion.output_tokens)
90    else {
91        return Err(AppServerProcessError::UnexpectedTerminalStatus);
92    };
93    let total_tokens = input_tokens
94        .checked_add(output_tokens)
95        .ok_or(AppServerProcessError::UnexpectedTerminalStatus)?;
96    let digest_input = ReceiptDigestInput {
97        schema: S08_OWNED_CHILD_TURN_SCHEMA,
98        correlation,
99        thread_id: &turn.thread_id,
100        turn_id: &turn.turn_id,
101        model: S08_OPENAI_MODEL,
102        effort: ReasoningEffort::Medium,
103        input_tokens,
104        output_tokens,
105        total_tokens,
106    };
107    let bytes =
108        serde_json::to_vec(&digest_input).map_err(|_| AppServerProcessError::TransportFailed)?;
109    Ok(S08OwnedChildTurnReceipt {
110        schema: S08_OWNED_CHILD_TURN_SCHEMA,
111        status: "completed",
112        correlation: correlation.to_owned(),
113        input_tokens,
114        output_tokens,
115        total_tokens,
116        receipt_digest: hex_sha256(&bytes),
117    })
118}
119
120fn valid_correlation(value: &str) -> bool {
121    value.len() <= 128
122        && (value.starts_with("s08-openai-") || value.starts_with("s08-desktop-"))
123        && value
124            .bytes()
125            .all(|byte| byte.is_ascii_alphanumeric() || matches!(byte, b'-' | b'_' | b'.'))
126}
127
128fn hex_sha256(bytes: &[u8]) -> String {
129    let digest = Sha256::digest(bytes);
130    let mut output = String::with_capacity(64);
131    for byte in digest {
132        use std::fmt::Write as _;
133        let _ = write!(&mut output, "{byte:02x}");
134    }
135    output
136}
137
138#[cfg(test)]
139mod tests {
140    use super::*;
141    use crate::{AppServerLineTransport, ShutdownReceipt};
142    use serde_json::{Value, json};
143    use std::collections::VecDeque;
144    use std::time::Duration;
145
146    struct TranscriptTransport {
147        inbound: VecDeque<Vec<u8>>,
148        outbound: Vec<Value>,
149        stopped: bool,
150        rate_limit_reached: bool,
151        token_usage: (u64, u64),
152    }
153
154    impl TranscriptTransport {
155        fn new() -> Self {
156            Self {
157                inbound: VecDeque::new(),
158                outbound: Vec::new(),
159                stopped: false,
160                rate_limit_reached: false,
161                token_usage: (2, 1),
162            }
163        }
164
165        fn rate_limited() -> Self {
166            Self {
167                rate_limit_reached: true,
168                ..Self::new()
169            }
170        }
171
172        fn overflowing_usage() -> Self {
173            Self {
174                token_usage: (u64::MAX, 1),
175                ..Self::new()
176            }
177        }
178
179        fn with_usage(input_tokens: u64, output_tokens: u64) -> Self {
180            Self {
181                token_usage: (input_tokens, output_tokens),
182                ..Self::new()
183            }
184        }
185
186        fn enqueue(&mut self, value: Value) {
187            self.inbound
188                .push_back(serde_json::to_vec(&value).expect("fixture JSON"));
189        }
190    }
191
192    impl AppServerLineTransport for TranscriptTransport {
193        fn send_line(&mut self, line: &str) -> Result<(), AppServerProcessError> {
194            let request: Value =
195                serde_json::from_str(line).map_err(|_| AppServerProcessError::TransportFailed)?;
196            let method = request["method"]
197                .as_str()
198                .ok_or(AppServerProcessError::TransportFailed)?;
199            self.outbound.push(request.clone());
200            let id = request.get("id").cloned();
201            match method {
202                "initialize" => self.enqueue(json!({"jsonrpc":"2.0","id":id,"result":{"userAgent":format!("steadymux/{}", crate::CURRENT_STANDALONE_CLI_VERSION)}})),
203                "initialized" => {}
204                "account/read" => self.enqueue(json!({"jsonrpc":"2.0","id":id,"result":{"account":{"type":"chatgpt","planType":"pro"},"requiresOpenaiAuth":true}})),
205                "model/list" => self.enqueue(json!({"jsonrpc":"2.0","id":id,"result":{"data":[{"id":"gpt-5.6-terra","displayName":"Terra","hidden":false,"supportedReasoningEfforts":[{"reasoningEffort":"medium"}]}],"nextCursor":null}})),
206                "account/rateLimits/read" => {
207                    let reached = if self.rate_limit_reached {
208                        Value::String("primary".to_owned())
209                    } else {
210                        Value::Null
211                    };
212                    self.enqueue(json!({"jsonrpc":"2.0","id":id,"result":{"rateLimits":{"primary":null,"secondary":null,"rateLimitReachedType":reached}}}));
213                }
214                "thread/start" => self.enqueue(json!({"jsonrpc":"2.0","id":id,"result":{"thread":{"id":"thr-s08"}}})),
215                "turn/start" => {
216                    let (input_tokens, output_tokens) = self.token_usage;
217                    self.enqueue(json!({"jsonrpc":"2.0","id":id,"result":{"turn":{"id":"turn-s08","status":"inProgress","items":[],"error":null}}}));
218                    self.enqueue(json!({"jsonrpc":"2.0","method":"turn/started","params":{"threadId":"thr-s08","turn":{"id":"turn-s08","status":"inProgress","items":[],"error":null}}}));
219                    self.enqueue(json!({"jsonrpc":"2.0","method":"item/started","params":{"threadId":"thr-s08","turnId":"turn-s08","item":{"type":"agentMessage","id":"item-s08","text":""}}}));
220                    self.enqueue(json!({"jsonrpc":"2.0","method":"item/completed","params":{"threadId":"thr-s08","turnId":"turn-s08","item":{"type":"agentMessage","id":"item-s08","text":"fixed output"}}}));
221                    let total_tokens = input_tokens.saturating_add(output_tokens);
222                    self.enqueue(json!({"jsonrpc":"2.0","method":"thread/tokenUsage/updated","params":{"threadId":"thr-s08","turnId":"turn-s08","tokenUsage":{"last":{"cachedInputTokens":0,"inputTokens":input_tokens,"outputTokens":output_tokens,"reasoningOutputTokens":0,"totalTokens":total_tokens},"total":{"cachedInputTokens":0,"inputTokens":input_tokens,"outputTokens":output_tokens,"reasoningOutputTokens":0,"totalTokens":total_tokens},"modelContextWindow":128000}}}));
223                    self.enqueue(json!({"jsonrpc":"2.0","method":"turn/completed","params":{"threadId":"thr-s08","turn":{"id":"turn-s08","status":"completed","items":[],"error":null}}}));
224                }
225                _ => return Err(AppServerProcessError::TransportFailed),
226            }
227            Ok(())
228        }
229
230        fn recv_line(&mut self, _timeout: Duration) -> Result<Vec<u8>, AppServerProcessError> {
231            self.inbound
232                .pop_front()
233                .ok_or(AppServerProcessError::UnexpectedEof)
234        }
235
236        fn shutdown(&mut self) -> Result<ShutdownReceipt, AppServerProcessError> {
237            self.stopped = true;
238            Ok(ShutdownReceipt {
239                forced: false,
240                success: true,
241            })
242        }
243    }
244
245    #[test]
246    fn transcript_turn_keeps_actual_correlation_and_emits_bound_usage_receipt() {
247        let transport = TranscriptTransport::new();
248        let mut client =
249            CodexAppServerClient::new(transport, Duration::from_secs(1)).expect("bounded client");
250        client.require_reviewed_cli_version();
251        let receipt =
252            run_s08_owned_child_turn(&mut client, "s08-desktop-01").expect("transcript turn");
253        assert_eq!(receipt.correlation, "s08-desktop-01");
254        assert_eq!(receipt.status, "completed");
255        assert_eq!(receipt.input_tokens, 2);
256        assert_eq!(receipt.output_tokens, 1);
257        assert_eq!(receipt.total_tokens, 3);
258        assert_eq!(receipt.receipt_digest.len(), 64);
259        assert!(!format!("{receipt:?}").contains("fixed output"));
260        let transport = client.into_transport();
261        assert!(transport.stopped);
262        let turn = transport
263            .outbound
264            .iter()
265            .find(|line| line["method"] == "turn/start")
266            .expect("turn start");
267        assert_eq!(turn["params"]["input"][0]["text"], S08_PROBE_TEXT);
268    }
269
270    #[test]
271    fn reached_rate_limit_refuses_before_thread_or_turn_start() {
272        let transport = TranscriptTransport::rate_limited();
273        let mut client =
274            CodexAppServerClient::new(transport, Duration::from_secs(1)).expect("bounded client");
275        client.require_reviewed_cli_version();
276        assert_eq!(
277            run_s08_owned_child_turn(&mut client, "s08-openai-rate-limit"),
278            Err(AppServerProcessError::UnsupportedExecutableVersion)
279        );
280        let transport = client.into_transport();
281        assert!(
282            transport
283                .outbound
284                .iter()
285                .all(|line| line["method"] != "thread/start" && line["method"] != "turn/start")
286        );
287    }
288
289    #[test]
290    fn terminal_usage_overflow_refuses_without_emitting_a_receipt() {
291        let transport = TranscriptTransport::overflowing_usage();
292        let mut client =
293            CodexAppServerClient::new(transport, Duration::from_secs(1)).expect("bounded client");
294        client.require_reviewed_cli_version();
295        assert!(run_s08_owned_child_turn(&mut client, "s08-openai-overflow").is_err());
296    }
297
298    #[test]
299    fn receipt_digest_commits_terminal_usage_for_the_exact_correlation() {
300        let mut first = CodexAppServerClient::new(
301            TranscriptTransport::with_usage(2, 1),
302            Duration::from_secs(1),
303        )
304        .expect("bounded client");
305        first.require_reviewed_cli_version();
306        let first = run_s08_owned_child_turn(&mut first, "s08-openai-usage").expect("receipt");
307
308        let mut second = CodexAppServerClient::new(
309            TranscriptTransport::with_usage(2, 2),
310            Duration::from_secs(1),
311        )
312        .expect("bounded client");
313        second.require_reviewed_cli_version();
314        let second = run_s08_owned_child_turn(&mut second, "s08-openai-usage").expect("receipt");
315
316        assert_ne!(first.receipt_digest, second.receipt_digest);
317        assert_eq!(first.total_tokens, 3);
318        assert_eq!(second.total_tokens, 4);
319    }
320}