1use std::fs::{self, OpenOptions};
9use std::io::Write;
10#[cfg(unix)]
11use std::os::unix::fs::PermissionsExt;
12use std::path::{Path, PathBuf};
13use std::time::{SystemTime, UNIX_EPOCH};
14
15use serde::{Deserialize, Serialize};
16
17const RECEIPT_SCHEMA: &str = "supercode.live-runtime.v1";
18const ENDPOINT_PREFIX: &str = "supercode-live://";
19
20#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct LiveRuntimeSource {
23 pub harness: String,
25 pub session_id: String,
27 pub workspace: PathBuf,
29}
30
31#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(default)]
35pub struct LiveRuntimeMetadata {
36 pub child_pid: Option<u32>,
38 pub profile: Option<String>,
40 pub persistence_location: Option<PathBuf>,
43 pub endpoint_capabilities: Vec<String>,
45 pub supervisor: Option<LiveRuntimeSupervisor>,
48}
49
50#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
52#[serde(tag = "kind", rename_all = "snake_case")]
53pub enum LiveRuntimeSupervisor {
54 Tmux {
56 session_name: String,
58 },
59}
60
61#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
63pub struct LiveRuntimeRecord {
64 pub endpoint: LiveRuntimeEndpoint,
66 pub runtime_session_id: String,
68 pub source: LiveRuntimeSource,
70 pub pid: u32,
72 pub created_at_ms: u128,
74 pub metadata: LiveRuntimeMetadata,
76}
77
78#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
80pub struct LiveRuntimeEndpoint(String);
81
82impl LiveRuntimeEndpoint {
83 pub fn parse(value: &str) -> Result<Self, LiveRuntimeReceiptError> {
85 let id = value
86 .strip_prefix(ENDPOINT_PREFIX)
87 .filter(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_hexdigit()))
88 .ok_or(LiveRuntimeReceiptError::InvalidEndpoint)?;
89 Ok(Self(format!("{ENDPOINT_PREFIX}{id}")))
90 }
91
92 pub fn as_str(&self) -> &str {
94 &self.0
95 }
96
97 fn receipt_id(&self) -> &str {
98 self.0
99 .strip_prefix(ENDPOINT_PREFIX)
100 .expect("LiveRuntimeEndpoint is validated at construction")
101 }
102}
103
104impl std::fmt::Display for LiveRuntimeEndpoint {
105 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
106 formatter.write_str(&self.0)
107 }
108}
109
110pub struct ResolvedLiveRuntime {
115 pub endpoint: LiveRuntimeEndpoint,
117 pub runtime_session_id: String,
119 pub source: LiveRuntimeSource,
121 pub base_url: String,
123 pub token: String,
125 pub pid: u32,
127}
128
129pub struct LiveRuntimeRegistration {
132 endpoint: LiveRuntimeEndpoint,
133 path: PathBuf,
134}
135
136impl LiveRuntimeRegistration {
137 pub fn endpoint(&self) -> &LiveRuntimeEndpoint {
139 &self.endpoint
140 }
141}
142
143impl Drop for LiveRuntimeRegistration {
144 fn drop(&mut self) {
145 let Ok(bytes) = fs::read(&self.path) else {
146 return;
147 };
148 let Ok(receipt) = serde_json::from_slice::<Receipt>(&bytes) else {
149 return;
150 };
151 if receipt.receipt_id == self.endpoint.receipt_id() {
152 let _ = fs::remove_file(&self.path);
153 }
154 }
155}
156
157#[derive(Debug, thiserror::Error)]
159pub enum LiveRuntimeReceiptError {
160 #[error("invalid Volter Harness live-runtime endpoint")]
162 InvalidEndpoint,
163 #[error("Volter Harness live runtime is no longer available")]
165 NotLive,
166 #[error("Volter Harness live-runtime receipt does not match the requested session")]
168 IdentityMismatch,
169 #[error("Volter Harness live-runtime receipt I/O failed: {0}")]
171 Io(#[from] std::io::Error),
172 #[error("Volter Harness live-runtime receipt is invalid: {0}")]
174 InvalidReceipt(String),
175 #[error("multiple live runtimes share id `{0}`")]
177 AmbiguousRuntime(String),
178}
179
180#[derive(Serialize, Deserialize)]
181struct Receipt {
182 schema: String,
183 receipt_id: String,
184 runtime_session_id: String,
185 source: LiveRuntimeSource,
186 base_url: String,
187 token: String,
188 pid: u32,
189 created_at_ms: u128,
190 #[serde(default)]
191 metadata: LiveRuntimeMetadata,
192}
193
194pub fn register_live_runtime(
196 runtime_session_id: impl Into<String>,
197 source: LiveRuntimeSource,
198 base_url: impl Into<String>,
199 token: impl Into<String>,
200) -> Result<LiveRuntimeRegistration, LiveRuntimeReceiptError> {
201 register_live_runtime_with_metadata(
202 runtime_session_id,
203 source,
204 base_url,
205 token,
206 LiveRuntimeMetadata {
207 endpoint_capabilities: vec!["http".into(), "acp".into()],
208 ..LiveRuntimeMetadata::default()
209 },
210 )
211}
212
213pub fn register_live_runtime_with_metadata(
215 runtime_session_id: impl Into<String>,
216 source: LiveRuntimeSource,
217 base_url: impl Into<String>,
218 token: impl Into<String>,
219 metadata: LiveRuntimeMetadata,
220) -> Result<LiveRuntimeRegistration, LiveRuntimeReceiptError> {
221 let runtime_session_id = runtime_session_id.into();
222 let base_url = base_url.into();
223 let token = token.into();
224 if runtime_session_id.trim().is_empty()
225 || source.harness.trim().is_empty()
226 || source.session_id.trim().is_empty()
227 || token.is_empty()
228 || !is_loopback_http(&base_url)
229 {
230 return Err(LiveRuntimeReceiptError::InvalidReceipt(
231 "missing identity/token or non-loopback HTTP address".into(),
232 ));
233 }
234
235 let mut random = [0_u8; 16];
236 getrandom::getrandom(&mut random).map_err(|error| {
237 LiveRuntimeReceiptError::InvalidReceipt(format!("OS randomness unavailable: {error}"))
238 })?;
239 let receipt_id = random.iter().map(|byte| format!("{byte:02x}")).collect();
240 let endpoint = LiveRuntimeEndpoint(format!("{ENDPOINT_PREFIX}{receipt_id}"));
241 let receipt = Receipt {
242 schema: RECEIPT_SCHEMA.into(),
243 receipt_id,
244 runtime_session_id,
245 source: LiveRuntimeSource {
246 workspace: normalized_path(&source.workspace),
247 ..source
248 },
249 base_url,
250 token,
251 pid: metadata.child_pid.unwrap_or_else(std::process::id),
252 created_at_ms: now_ms(),
253 metadata,
254 };
255 let directory = receipt_directory();
256 fs::create_dir_all(&directory)?;
257 #[cfg(unix)]
258 fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?;
259 let path = directory.join(format!("{}.json", endpoint.receipt_id()));
260 let temporary = directory.join(format!(
261 ".{}.{}.tmp",
262 endpoint.receipt_id(),
263 std::process::id()
264 ));
265 let bytes = serde_json::to_vec(&receipt)
266 .map_err(|error| LiveRuntimeReceiptError::InvalidReceipt(error.to_string()))?;
267 let mut options = OpenOptions::new();
268 options.write(true).create_new(true);
269 #[cfg(unix)]
270 {
271 use std::os::unix::fs::OpenOptionsExt;
272 options.mode(0o600);
273 }
274 let mut file = options.open(&temporary)?;
275 file.write_all(&bytes)?;
276 file.sync_all()?;
277 fs::rename(&temporary, &path)?;
278 Ok(LiveRuntimeRegistration { endpoint, path })
279}
280
281pub fn list_live_runtimes() -> Result<Vec<LiveRuntimeRecord>, LiveRuntimeReceiptError> {
284 let mut records = read_receipts()?
285 .into_iter()
286 .map(|receipt| LiveRuntimeRecord {
287 endpoint: LiveRuntimeEndpoint(format!("{ENDPOINT_PREFIX}{}", receipt.receipt_id)),
288 runtime_session_id: receipt.runtime_session_id,
289 source: receipt.source,
290 pid: receipt.pid,
291 created_at_ms: receipt.created_at_ms,
292 metadata: receipt.metadata,
293 })
294 .collect::<Vec<_>>();
295 records.sort_by(|left, right| {
296 right
297 .created_at_ms
298 .cmp(&left.created_at_ms)
299 .then_with(|| left.runtime_session_id.cmp(&right.runtime_session_id))
300 });
301 Ok(records)
302}
303
304pub fn find_live_runtime(
307 runtime_session_id: &str,
308) -> Result<Option<LiveRuntimeRecord>, LiveRuntimeReceiptError> {
309 let mut matches = list_live_runtimes()?
310 .into_iter()
311 .filter(|record| record.runtime_session_id == runtime_session_id)
312 .collect::<Vec<_>>();
313 match matches.len() {
314 0 => Ok(None),
315 1 => Ok(matches.pop()),
316 _ => Err(LiveRuntimeReceiptError::AmbiguousRuntime(
317 runtime_session_id.into(),
318 )),
319 }
320}
321
322pub fn forget_live_runtime(endpoint: &LiveRuntimeEndpoint) -> Result<(), LiveRuntimeReceiptError> {
325 let path = receipt_directory().join(format!("{}.json", endpoint.receipt_id()));
326 match fs::remove_file(path) {
327 Ok(()) => Ok(()),
328 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
329 Err(error) => Err(error.into()),
330 }
331}
332
333pub fn discover_live_runtime(
335 source: &LiveRuntimeSource,
336) -> Result<Option<LiveRuntimeEndpoint>, LiveRuntimeReceiptError> {
337 let mut matches = read_receipts()?
338 .into_iter()
339 .filter(|receipt| source_matches(&receipt.source, source))
340 .collect::<Vec<_>>();
341 matches.sort_by_key(|receipt| std::cmp::Reverse(receipt.created_at_ms));
342 Ok(matches
343 .first()
344 .map(|receipt| LiveRuntimeEndpoint(format!("{ENDPOINT_PREFIX}{}", receipt.receipt_id))))
345}
346
347pub fn resolve_live_runtime(
349 endpoint: &LiveRuntimeEndpoint,
350 expected: &LiveRuntimeSource,
351) -> Result<ResolvedLiveRuntime, LiveRuntimeReceiptError> {
352 let path = receipt_directory().join(format!("{}.json", endpoint.receipt_id()));
353 let receipt = read_receipt(&path)?.ok_or(LiveRuntimeReceiptError::NotLive)?;
354 if receipt.receipt_id != endpoint.receipt_id() || !source_matches(&receipt.source, expected) {
355 return Err(LiveRuntimeReceiptError::IdentityMismatch);
356 }
357 Ok(ResolvedLiveRuntime {
358 endpoint: endpoint.clone(),
359 runtime_session_id: receipt.runtime_session_id,
360 source: receipt.source,
361 base_url: receipt.base_url,
362 token: receipt.token,
363 pid: receipt.pid,
364 })
365}
366
367fn read_receipts() -> Result<Vec<Receipt>, LiveRuntimeReceiptError> {
368 let directory = receipt_directory();
369 let Ok(entries) = fs::read_dir(&directory) else {
370 return Ok(Vec::new());
371 };
372 let mut receipts = Vec::new();
373 for entry in entries.flatten() {
374 let path = entry.path();
375 if path.extension().and_then(|value| value.to_str()) != Some("json") {
376 continue;
377 }
378 if let Ok(Some(receipt)) = read_receipt(&path) {
382 receipts.push(receipt);
383 }
384 }
385 Ok(receipts)
386}
387
388fn read_receipt(path: &Path) -> Result<Option<Receipt>, LiveRuntimeReceiptError> {
389 let bytes = match fs::read(path) {
390 Ok(bytes) => bytes,
391 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
392 Err(error) => return Err(error.into()),
393 };
394 let receipt: Receipt = serde_json::from_slice(&bytes)
395 .map_err(|error| LiveRuntimeReceiptError::InvalidReceipt(error.to_string()))?;
396 if receipt.schema != RECEIPT_SCHEMA || !is_loopback_http(&receipt.base_url) {
397 return Err(LiveRuntimeReceiptError::InvalidReceipt(
398 "unsupported schema or non-loopback address".into(),
399 ));
400 }
401 if !crate::claude_peer::process_is_live(receipt.pid) {
402 let _ = fs::remove_file(path);
403 return Ok(None);
404 }
405 Ok(Some(receipt))
406}
407
408fn receipt_directory() -> PathBuf {
409 crate::agent::global_instructions_dir().join("live-runtimes")
410}
411
412fn source_matches(left: &LiveRuntimeSource, right: &LiveRuntimeSource) -> bool {
413 left.harness == right.harness
414 && left.session_id == right.session_id
415 && normalized_path(&left.workspace) == normalized_path(&right.workspace)
416}
417
418fn normalized_path(path: &Path) -> PathBuf {
419 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
420}
421
422fn is_loopback_http(value: &str) -> bool {
423 let Some(authority) = value
424 .strip_prefix("http://")
425 .and_then(|rest| rest.split('/').next())
426 else {
427 return false;
428 };
429 let host = authority
430 .strip_prefix('[')
431 .and_then(|rest| rest.split(']').next())
432 .unwrap_or_else(|| authority.split(':').next().unwrap_or_default());
433 matches!(host, "127.0.0.1" | "localhost" | "::1")
434}
435
436fn now_ms() -> u128 {
437 SystemTime::now()
438 .duration_since(UNIX_EPOCH)
439 .unwrap_or_default()
440 .as_millis()
441}