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
15#[cfg(test)]
16use std::sync::{Mutex, MutexGuard, OnceLock};
17
18use serde::{Deserialize, Serialize};
19
20const RECEIPT_SCHEMA: &str = "supercode.live-runtime.v1";
21const ENDPOINT_PREFIX: &str = "supercode-live://";
22
23#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct LiveRuntimeSource {
26 pub harness: String,
28 pub session_id: String,
30 pub workspace: PathBuf,
32}
33
34#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LiveRuntimeMetadata {
39 pub profile: Option<String>,
41 pub persistence_location: Option<PathBuf>,
44 pub endpoint_capabilities: Vec<String>,
46 pub supervisor: Option<LiveRuntimeSupervisor>,
49}
50
51#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54pub enum LiveRuntimeSupervisor {
55 Tmux {
57 session_name: String,
59 },
60}
61
62#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct LiveRuntimeRecord {
65 pub endpoint: LiveRuntimeEndpoint,
67 pub runtime_session_id: String,
69 pub source: LiveRuntimeSource,
71 pub pid: u32,
73 pub created_at_ms: u128,
75 pub metadata: LiveRuntimeMetadata,
77}
78
79#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct LiveRuntimeEndpoint(String);
82
83impl LiveRuntimeEndpoint {
84 pub fn parse(value: &str) -> Result<Self, LiveRuntimeReceiptError> {
86 let id = value
87 .strip_prefix(ENDPOINT_PREFIX)
88 .filter(|id| !id.is_empty() && id.bytes().all(|byte| byte.is_ascii_hexdigit()))
89 .ok_or(LiveRuntimeReceiptError::InvalidEndpoint)?;
90 Ok(Self(format!("{ENDPOINT_PREFIX}{id}")))
91 }
92
93 pub fn as_str(&self) -> &str {
95 &self.0
96 }
97
98 fn receipt_id(&self) -> &str {
99 self.0
100 .strip_prefix(ENDPOINT_PREFIX)
101 .expect("LiveRuntimeEndpoint is validated at construction")
102 }
103}
104
105impl std::fmt::Display for LiveRuntimeEndpoint {
106 fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
107 formatter.write_str(&self.0)
108 }
109}
110
111pub struct ResolvedLiveRuntime {
116 pub endpoint: LiveRuntimeEndpoint,
118 pub runtime_session_id: String,
120 pub source: LiveRuntimeSource,
122 pub base_url: String,
124 pub token: String,
126 pub pid: u32,
128}
129
130pub struct LiveRuntimeRegistration {
133 endpoint: LiveRuntimeEndpoint,
134 path: PathBuf,
135}
136
137impl LiveRuntimeRegistration {
138 pub fn endpoint(&self) -> &LiveRuntimeEndpoint {
140 &self.endpoint
141 }
142}
143
144impl Drop for LiveRuntimeRegistration {
145 fn drop(&mut self) {
146 let Ok(bytes) = fs::read(&self.path) else {
147 return;
148 };
149 let Ok(receipt) = serde_json::from_slice::<Receipt>(&bytes) else {
150 return;
151 };
152 if receipt.receipt_id == self.endpoint.receipt_id() {
153 let _ = fs::remove_file(&self.path);
154 }
155 }
156}
157
158#[derive(Debug, thiserror::Error)]
160pub enum LiveRuntimeReceiptError {
161 #[error("invalid Supercode live-runtime endpoint")]
163 InvalidEndpoint,
164 #[error("Supercode live runtime is no longer available")]
166 NotLive,
167 #[error("Supercode live-runtime receipt does not match the requested session")]
169 IdentityMismatch,
170 #[error("Supercode live-runtime receipt I/O failed: {0}")]
172 Io(#[from] std::io::Error),
173 #[error("Supercode live-runtime receipt is invalid: {0}")]
175 InvalidReceipt(String),
176 #[error("multiple live runtimes share id `{0}`")]
178 AmbiguousRuntime(String),
179}
180
181#[derive(Serialize, Deserialize)]
182struct Receipt {
183 schema: String,
184 receipt_id: String,
185 runtime_session_id: String,
186 source: LiveRuntimeSource,
187 base_url: String,
188 token: String,
189 pid: u32,
190 created_at_ms: u128,
191 #[serde(default)]
192 metadata: LiveRuntimeMetadata,
193}
194
195pub fn register_live_runtime(
197 runtime_session_id: impl Into<String>,
198 source: LiveRuntimeSource,
199 base_url: impl Into<String>,
200 token: impl Into<String>,
201) -> Result<LiveRuntimeRegistration, LiveRuntimeReceiptError> {
202 register_live_runtime_with_metadata(
203 runtime_session_id,
204 source,
205 base_url,
206 token,
207 LiveRuntimeMetadata {
208 endpoint_capabilities: vec!["http".into(), "acp".into()],
209 ..LiveRuntimeMetadata::default()
210 },
211 )
212}
213
214pub fn register_live_runtime_with_metadata(
216 runtime_session_id: impl Into<String>,
217 source: LiveRuntimeSource,
218 base_url: impl Into<String>,
219 token: impl Into<String>,
220 metadata: LiveRuntimeMetadata,
221) -> Result<LiveRuntimeRegistration, LiveRuntimeReceiptError> {
222 let runtime_session_id = runtime_session_id.into();
223 let base_url = base_url.into();
224 let token = token.into();
225 if runtime_session_id.trim().is_empty()
226 || source.harness.trim().is_empty()
227 || source.session_id.trim().is_empty()
228 || token.is_empty()
229 || !is_loopback_http(&base_url)
230 {
231 return Err(LiveRuntimeReceiptError::InvalidReceipt(
232 "missing identity/token or non-loopback HTTP address".into(),
233 ));
234 }
235
236 let mut random = [0_u8; 16];
237 getrandom::getrandom(&mut random).map_err(|error| {
238 LiveRuntimeReceiptError::InvalidReceipt(format!("OS randomness unavailable: {error}"))
239 })?;
240 let receipt_id = random.iter().map(|byte| format!("{byte:02x}")).collect();
241 let endpoint = LiveRuntimeEndpoint(format!("{ENDPOINT_PREFIX}{receipt_id}"));
242 let receipt = Receipt {
243 schema: RECEIPT_SCHEMA.into(),
244 receipt_id,
245 runtime_session_id,
246 source: LiveRuntimeSource {
247 workspace: normalized_path(&source.workspace),
248 ..source
249 },
250 base_url,
251 token,
252 pid: std::process::id(),
253 created_at_ms: now_ms(),
254 metadata,
255 };
256 let directory = receipt_directory();
257 fs::create_dir_all(&directory)?;
258 #[cfg(unix)]
259 fs::set_permissions(&directory, fs::Permissions::from_mode(0o700))?;
260 let path = directory.join(format!("{}.json", endpoint.receipt_id()));
261 let temporary = directory.join(format!(
262 ".{}.{}.tmp",
263 endpoint.receipt_id(),
264 std::process::id()
265 ));
266 let bytes = serde_json::to_vec(&receipt)
267 .map_err(|error| LiveRuntimeReceiptError::InvalidReceipt(error.to_string()))?;
268 let mut options = OpenOptions::new();
269 options.write(true).create_new(true);
270 #[cfg(unix)]
271 {
272 use std::os::unix::fs::OpenOptionsExt;
273 options.mode(0o600);
274 }
275 let mut file = options.open(&temporary)?;
276 file.write_all(&bytes)?;
277 file.sync_all()?;
278 fs::rename(&temporary, &path)?;
279 Ok(LiveRuntimeRegistration { endpoint, path })
280}
281
282pub fn list_live_runtimes() -> Result<Vec<LiveRuntimeRecord>, LiveRuntimeReceiptError> {
285 let mut records = read_receipts()?
286 .into_iter()
287 .map(|receipt| LiveRuntimeRecord {
288 endpoint: LiveRuntimeEndpoint(format!("{ENDPOINT_PREFIX}{}", receipt.receipt_id)),
289 runtime_session_id: receipt.runtime_session_id,
290 source: receipt.source,
291 pid: receipt.pid,
292 created_at_ms: receipt.created_at_ms,
293 metadata: receipt.metadata,
294 })
295 .collect::<Vec<_>>();
296 records.sort_by(|left, right| {
297 right
298 .created_at_ms
299 .cmp(&left.created_at_ms)
300 .then_with(|| left.runtime_session_id.cmp(&right.runtime_session_id))
301 });
302 Ok(records)
303}
304
305pub fn find_live_runtime(
308 runtime_session_id: &str,
309) -> Result<Option<LiveRuntimeRecord>, LiveRuntimeReceiptError> {
310 let mut matches = list_live_runtimes()?
311 .into_iter()
312 .filter(|record| record.runtime_session_id == runtime_session_id)
313 .collect::<Vec<_>>();
314 match matches.len() {
315 0 => Ok(None),
316 1 => Ok(matches.pop()),
317 _ => Err(LiveRuntimeReceiptError::AmbiguousRuntime(
318 runtime_session_id.into(),
319 )),
320 }
321}
322
323pub fn forget_live_runtime(endpoint: &LiveRuntimeEndpoint) -> Result<(), LiveRuntimeReceiptError> {
326 let path = receipt_directory().join(format!("{}.json", endpoint.receipt_id()));
327 match fs::remove_file(path) {
328 Ok(()) => Ok(()),
329 Err(error) if error.kind() == std::io::ErrorKind::NotFound => Ok(()),
330 Err(error) => Err(error.into()),
331 }
332}
333
334pub fn discover_live_runtime(
336 source: &LiveRuntimeSource,
337) -> Result<Option<LiveRuntimeEndpoint>, LiveRuntimeReceiptError> {
338 let mut matches = read_receipts()?
339 .into_iter()
340 .filter(|receipt| source_matches(&receipt.source, source))
341 .collect::<Vec<_>>();
342 matches.sort_by_key(|receipt| std::cmp::Reverse(receipt.created_at_ms));
343 Ok(matches
344 .first()
345 .map(|receipt| LiveRuntimeEndpoint(format!("{ENDPOINT_PREFIX}{}", receipt.receipt_id))))
346}
347
348pub fn resolve_live_runtime(
350 endpoint: &LiveRuntimeEndpoint,
351 expected: &LiveRuntimeSource,
352) -> Result<ResolvedLiveRuntime, LiveRuntimeReceiptError> {
353 let path = receipt_directory().join(format!("{}.json", endpoint.receipt_id()));
354 let receipt = read_receipt(&path)?.ok_or(LiveRuntimeReceiptError::NotLive)?;
355 if receipt.receipt_id != endpoint.receipt_id() || !source_matches(&receipt.source, expected) {
356 return Err(LiveRuntimeReceiptError::IdentityMismatch);
357 }
358 Ok(ResolvedLiveRuntime {
359 endpoint: endpoint.clone(),
360 runtime_session_id: receipt.runtime_session_id,
361 source: receipt.source,
362 base_url: receipt.base_url,
363 token: receipt.token,
364 pid: receipt.pid,
365 })
366}
367
368fn read_receipts() -> Result<Vec<Receipt>, LiveRuntimeReceiptError> {
369 let directory = receipt_directory();
370 let Ok(entries) = fs::read_dir(&directory) else {
371 return Ok(Vec::new());
372 };
373 let mut receipts = Vec::new();
374 for entry in entries.flatten() {
375 let path = entry.path();
376 if path.extension().and_then(|value| value.to_str()) != Some("json") {
377 continue;
378 }
379 if let Ok(Some(receipt)) = read_receipt(&path) {
383 receipts.push(receipt);
384 }
385 }
386 Ok(receipts)
387}
388
389fn read_receipt(path: &Path) -> Result<Option<Receipt>, LiveRuntimeReceiptError> {
390 let bytes = match fs::read(path) {
391 Ok(bytes) => bytes,
392 Err(error) if error.kind() == std::io::ErrorKind::NotFound => return Ok(None),
393 Err(error) => return Err(error.into()),
394 };
395 let receipt: Receipt = serde_json::from_slice(&bytes)
396 .map_err(|error| LiveRuntimeReceiptError::InvalidReceipt(error.to_string()))?;
397 if receipt.schema != RECEIPT_SCHEMA || !is_loopback_http(&receipt.base_url) {
398 return Err(LiveRuntimeReceiptError::InvalidReceipt(
399 "unsupported schema or non-loopback address".into(),
400 ));
401 }
402 if !process_is_live(receipt.pid) {
403 let _ = fs::remove_file(path);
404 return Ok(None);
405 }
406 Ok(Some(receipt))
407}
408
409fn receipt_directory() -> PathBuf {
410 crate::agent::global_instructions_dir().join("live-runtimes")
411}
412
413#[cfg(test)]
414pub(crate) fn test_environment_lock() -> MutexGuard<'static, ()> {
415 static LOCK: OnceLock<Mutex<()>> = OnceLock::new();
416 LOCK.get_or_init(|| Mutex::new(()))
417 .lock()
418 .unwrap_or_else(std::sync::PoisonError::into_inner)
419}
420
421fn source_matches(left: &LiveRuntimeSource, right: &LiveRuntimeSource) -> bool {
422 left.harness == right.harness
423 && left.session_id == right.session_id
424 && normalized_path(&left.workspace) == normalized_path(&right.workspace)
425}
426
427fn normalized_path(path: &Path) -> PathBuf {
428 fs::canonicalize(path).unwrap_or_else(|_| path.to_path_buf())
429}
430
431fn is_loopback_http(value: &str) -> bool {
432 let Some(authority) = value
433 .strip_prefix("http://")
434 .and_then(|rest| rest.split('/').next())
435 else {
436 return false;
437 };
438 let host = authority
439 .strip_prefix('[')
440 .and_then(|rest| rest.split(']').next())
441 .unwrap_or_else(|| authority.split(':').next().unwrap_or_default());
442 matches!(host, "127.0.0.1" | "localhost" | "::1")
443}
444
445fn now_ms() -> u128 {
446 SystemTime::now()
447 .duration_since(UNIX_EPOCH)
448 .unwrap_or_default()
449 .as_millis()
450}
451
452#[cfg(unix)]
453fn process_is_live(pid: u32) -> bool {
454 let result = unsafe { libc::kill(pid as libc::pid_t, 0) };
456 result == 0 || std::io::Error::last_os_error().raw_os_error() == Some(libc::EPERM)
457}
458
459#[cfg(not(unix))]
460fn process_is_live(pid: u32) -> bool {
461 pid == std::process::id()
464}
465
466#[cfg(test)]
467mod tests {
468 use super::*;
469
470 #[test]
471 fn receipt_is_opaque_private_and_identity_scoped() {
472 let _guard = test_environment_lock();
473 let root = std::env::temp_dir().join(format!(
474 "supercode-live-receipt-{}-{}",
475 std::process::id(),
476 now_ms()
477 ));
478 let workspace = root.join("project");
479 fs::create_dir_all(&workspace).unwrap();
480 let workspace = fs::canonicalize(workspace).unwrap();
481 std::env::set_var("SUPERCODE_HOME", &root);
482 let source = LiveRuntimeSource {
483 harness: "grok".into(),
484 session_id: "source-1".into(),
485 workspace: workspace.clone(),
486 };
487 let registration = register_live_runtime(
488 "runtime-1",
489 source.clone(),
490 "http://127.0.0.1:43123",
491 "secret-token",
492 )
493 .unwrap();
494 assert!(registration
495 .endpoint()
496 .as_str()
497 .starts_with(ENDPOINT_PREFIX));
498 assert!(!registration.endpoint().as_str().contains("secret-token"));
499 assert_eq!(
500 discover_live_runtime(&source).unwrap().as_ref(),
501 Some(registration.endpoint())
502 );
503 let records = list_live_runtimes().unwrap();
504 assert_eq!(records.len(), 1);
505 assert_eq!(records[0].runtime_session_id, "runtime-1");
506 assert_eq!(records[0].source, source);
507 assert_eq!(records[0].metadata.endpoint_capabilities, ["http", "acp"]);
508 assert_eq!(
509 find_live_runtime("runtime-1").unwrap().as_ref(),
510 records.first()
511 );
512 let resolved = resolve_live_runtime(registration.endpoint(), &source).unwrap();
513 assert_eq!(resolved.runtime_session_id, "runtime-1");
514 assert_eq!(resolved.token, "secret-token");
515 let wrong = LiveRuntimeSource {
516 session_id: "other".into(),
517 ..source.clone()
518 };
519 assert!(matches!(
520 resolve_live_runtime(registration.endpoint(), &wrong),
521 Err(LiveRuntimeReceiptError::IdentityMismatch)
522 ));
523 let receipt_path =
524 receipt_directory().join(format!("{}.json", registration.endpoint().receipt_id()));
525 #[cfg(unix)]
526 {
527 assert_eq!(
528 fs::metadata(&receipt_path).unwrap().permissions().mode() & 0o777,
529 0o600
530 );
531 assert_eq!(
532 fs::metadata(receipt_directory())
533 .unwrap()
534 .permissions()
535 .mode()
536 & 0o777,
537 0o700
538 );
539 }
540 drop(registration);
541 assert!(!receipt_path.exists());
542 std::env::remove_var("SUPERCODE_HOME");
543 fs::remove_dir_all(root).ok();
544 }
545}