1use 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 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
47pub 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}