Skip to main content

supercode_harness/
live_runtime.rs

1//! Trusted local receipts for attachable Supercode runtimes.
2//!
3//! A browser-facing host may reveal the opaque [`LiveRuntimeEndpoint`], but
4//! never the HTTP bearer token stored in the receipt.  The harness service
5//! resolves that endpoint inside the user's process, checks the source
6//! session/workspace identity, and then authenticates to the runtime.
7
8use 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/// Stable source identity through which a hosted continuation is discovered.
24#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
25pub struct LiveRuntimeSource {
26    /// Harness that owns the persisted source transcript.
27    pub harness: String,
28    /// Harness-native source session identity.
29    pub session_id: String,
30    /// Project directory in which the runtime is operating.
31    pub workspace: PathBuf,
32}
33
34/// Static runtime metadata recorded at registration. Dynamic state, actions,
35/// controller, and observers are queried from the authenticated SDK endpoint.
36#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
37#[serde(default)]
38pub struct LiveRuntimeMetadata {
39    /// Resolved composable emulation profile.
40    pub profile: Option<String>,
41    /// Canonical persistence location owned by Supercode. This is never the
42    /// authority for a source harness's unchanged native transcript.
43    pub persistence_location: Option<PathBuf>,
44    /// Transport names exposed by this endpoint.
45    pub endpoint_capabilities: Vec<String>,
46    /// Optional process supervisor. This is presentation/lifecycle metadata;
47    /// the authenticated SDK endpoint remains the runtime authority.
48    pub supervisor: Option<LiveRuntimeSupervisor>,
49}
50
51/// Optional local process supervisor for an attachable runtime.
52#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
53#[serde(tag = "kind", rename_all = "snake_case")]
54pub enum LiveRuntimeSupervisor {
55    /// A predictably named tmux session containing the owner and frontend.
56    Tmux {
57        /// Exact tmux session name accepted by `tmux attach-session -t`.
58        session_name: String,
59    },
60}
61
62/// Browser-safe inventory record for one reconciled live receipt.
63#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
64pub struct LiveRuntimeRecord {
65    /// Opaque receipt endpoint; it contains no bearer credential.
66    pub endpoint: LiveRuntimeEndpoint,
67    /// Stable SDK runtime/session id.
68    pub runtime_session_id: String,
69    /// Source harness identity.
70    pub source: LiveRuntimeSource,
71    /// Process that owns the runtime.
72    pub pid: u32,
73    /// Registration time in epoch milliseconds.
74    pub created_at_ms: u128,
75    /// Static registration metadata.
76    pub metadata: LiveRuntimeMetadata,
77}
78
79/// Browser-safe reference to a trusted local runtime receipt.
80#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
81pub struct LiveRuntimeEndpoint(String);
82
83impl LiveRuntimeEndpoint {
84    /// Parse an opaque live-runtime endpoint.
85    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    /// Return the opaque endpoint string safe to expose to a local UI.
94    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
111/// Secret host-side information recovered from a verified receipt.
112///
113/// Do not serialize this value into browser-facing discovery results: it
114/// contains the bearer token for the loopback SDK runtime.
115pub struct ResolvedLiveRuntime {
116    /// Opaque endpoint used to locate the receipt.
117    pub endpoint: LiveRuntimeEndpoint,
118    /// SDK runtime's stable session identity.
119    pub runtime_session_id: String,
120    /// Persisted source identity advertised by discovery.
121    pub source: LiveRuntimeSource,
122    /// Loopback HTTP address of the SDK runtime.
123    pub base_url: String,
124    /// Bearer token accepted by that runtime.
125    pub token: String,
126    /// Process that owns the runtime.
127    pub pid: u32,
128}
129
130/// RAII registration for one live runtime. Dropping it removes only the
131/// receipt created by this registration, leaving persisted chat data intact.
132pub struct LiveRuntimeRegistration {
133    endpoint: LiveRuntimeEndpoint,
134    path: PathBuf,
135}
136
137impl LiveRuntimeRegistration {
138    /// Opaque endpoint safe to publish in trusted-host discovery output.
139    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/// Receipt registration or resolution failure.
159#[derive(Debug, thiserror::Error)]
160pub enum LiveRuntimeReceiptError {
161    /// Opaque endpoint has an invalid scheme or identifier.
162    #[error("invalid Supercode live-runtime endpoint")]
163    InvalidEndpoint,
164    /// Receipt no longer exists or its owning process is gone.
165    #[error("Supercode live runtime is no longer available")]
166    NotLive,
167    /// Receipt exists but belongs to another source session or workspace.
168    #[error("Supercode live-runtime receipt does not match the requested session")]
169    IdentityMismatch,
170    /// Receipt storage failed.
171    #[error("Supercode live-runtime receipt I/O failed: {0}")]
172    Io(#[from] std::io::Error),
173    /// Receipt data was malformed or from another schema version.
174    #[error("Supercode live-runtime receipt is invalid: {0}")]
175    InvalidReceipt(String),
176    /// More than one active receipt has the requested stable runtime id.
177    #[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
195/// Register an authenticated loopback runtime and return its opaque endpoint.
196pub 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
214/// Register a runtime with the static fields used by list/describe output.
215pub 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
282/// List every reconciled live runtime without exposing its bearer token or
283/// loopback address. Dead-process receipts are removed as part of the read.
284pub 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
305/// Resolve one stable runtime id, requiring an explicit choice if stale or
306/// concurrent registrations would otherwise make attachment ambiguous.
307pub 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
323/// Remove only a stale attachment receipt. Canonical, sidecar, source-native,
324/// and exported session data are never addressed by this operation.
325pub 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
334/// Find the newest live receipt matching a discovered source session.
335pub 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
348/// Resolve an opaque endpoint and verify that it belongs to `expected`.
349pub 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        // Discovery is best-effort across concurrently removed, stale, or
380        // malformed receipts. Attachment re-reads and validates the selected
381        // receipt strictly before using any secret it contains.
382        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    // SAFETY: signal 0 performs only a liveness/permission check.
455    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    // Windows has no signal-0 equivalent in the current dependency set. A
462    // receipt is still authenticated and identity-checked at HTTP connect.
463    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}