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