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
15use serde::{Deserialize, Serialize};
16
17const RECEIPT_SCHEMA: &str = "supercode.live-runtime.v1";
18const ENDPOINT_PREFIX: &str = "supercode-live://";
19
20/// Stable source identity through which a hosted continuation is discovered.
21#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
22pub struct LiveRuntimeSource {
23    /// Harness that owns the persisted source transcript.
24    pub harness: String,
25    /// Harness-native source session identity.
26    pub session_id: String,
27    /// Project directory in which the runtime is operating.
28    pub workspace: PathBuf,
29}
30
31/// Static runtime metadata recorded at registration. Dynamic state, actions,
32/// controller, and observers are queried from the authenticated SDK endpoint.
33#[derive(Debug, Clone, Default, PartialEq, Eq, Serialize, Deserialize)]
34#[serde(default)]
35pub struct LiveRuntimeMetadata {
36    /// Resolved composable emulation profile.
37    pub profile: Option<String>,
38    /// Canonical persistence location owned by Supercode. This is never the
39    /// authority for a source harness's unchanged native transcript.
40    pub persistence_location: Option<PathBuf>,
41    /// Transport names exposed by this endpoint.
42    pub endpoint_capabilities: Vec<String>,
43    /// Optional process supervisor. This is presentation/lifecycle metadata;
44    /// the authenticated SDK endpoint remains the runtime authority.
45    pub supervisor: Option<LiveRuntimeSupervisor>,
46}
47
48/// Optional local process supervisor for an attachable runtime.
49#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
50#[serde(tag = "kind", rename_all = "snake_case")]
51pub enum LiveRuntimeSupervisor {
52    /// A predictably named tmux session containing the owner and frontend.
53    Tmux {
54        /// Exact tmux session name accepted by `tmux attach-session -t`.
55        session_name: String,
56    },
57}
58
59/// Browser-safe inventory record for one reconciled live receipt.
60#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
61pub struct LiveRuntimeRecord {
62    /// Opaque receipt endpoint; it contains no bearer credential.
63    pub endpoint: LiveRuntimeEndpoint,
64    /// Stable SDK runtime/session id.
65    pub runtime_session_id: String,
66    /// Source harness identity.
67    pub source: LiveRuntimeSource,
68    /// Process that owns the runtime.
69    pub pid: u32,
70    /// Registration time in epoch milliseconds.
71    pub created_at_ms: u128,
72    /// Static registration metadata.
73    pub metadata: LiveRuntimeMetadata,
74}
75
76/// Browser-safe reference to a trusted local runtime receipt.
77#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
78pub struct LiveRuntimeEndpoint(String);
79
80impl LiveRuntimeEndpoint {
81    /// Parse an opaque live-runtime endpoint.
82    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    /// Return the opaque endpoint string safe to expose to a local UI.
91    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
108/// Secret host-side information recovered from a verified receipt.
109///
110/// Do not serialize this value into browser-facing discovery results: it
111/// contains the bearer token for the loopback SDK runtime.
112pub struct ResolvedLiveRuntime {
113    /// Opaque endpoint used to locate the receipt.
114    pub endpoint: LiveRuntimeEndpoint,
115    /// SDK runtime's stable session identity.
116    pub runtime_session_id: String,
117    /// Persisted source identity advertised by discovery.
118    pub source: LiveRuntimeSource,
119    /// Loopback HTTP address of the SDK runtime.
120    pub base_url: String,
121    /// Bearer token accepted by that runtime.
122    pub token: String,
123    /// Process that owns the runtime.
124    pub pid: u32,
125}
126
127/// RAII registration for one live runtime. Dropping it removes only the
128/// receipt created by this registration, leaving persisted chat data intact.
129pub struct LiveRuntimeRegistration {
130    endpoint: LiveRuntimeEndpoint,
131    path: PathBuf,
132}
133
134impl LiveRuntimeRegistration {
135    /// Opaque endpoint safe to publish in trusted-host discovery output.
136    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/// Receipt registration or resolution failure.
156#[derive(Debug, thiserror::Error)]
157pub enum LiveRuntimeReceiptError {
158    /// Opaque endpoint has an invalid scheme or identifier.
159    #[error("invalid Volter Harness live-runtime endpoint")]
160    InvalidEndpoint,
161    /// Receipt no longer exists or its owning process is gone.
162    #[error("Volter Harness live runtime is no longer available")]
163    NotLive,
164    /// Receipt exists but belongs to another source session or workspace.
165    #[error("Volter Harness live-runtime receipt does not match the requested session")]
166    IdentityMismatch,
167    /// Receipt storage failed.
168    #[error("Volter Harness live-runtime receipt I/O failed: {0}")]
169    Io(#[from] std::io::Error),
170    /// Receipt data was malformed or from another schema version.
171    #[error("Volter Harness live-runtime receipt is invalid: {0}")]
172    InvalidReceipt(String),
173    /// More than one active receipt has the requested stable runtime id.
174    #[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
192/// Register an authenticated loopback runtime and return its opaque endpoint.
193pub 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
211/// Register a runtime with the static fields used by list/describe output.
212pub 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
279/// List every reconciled live runtime without exposing its bearer token or
280/// loopback address. Dead-process receipts are removed as part of the read.
281pub 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
302/// Resolve one stable runtime id, requiring an explicit choice if stale or
303/// concurrent registrations would otherwise make attachment ambiguous.
304pub 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
320/// Remove only a stale attachment receipt. Canonical, sidecar, source-native,
321/// and exported session data are never addressed by this operation.
322pub 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
331/// Find the newest live receipt matching a discovered source session.
332pub 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
345/// Resolve an opaque endpoint and verify that it belongs to `expected`.
346pub 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        // Discovery is best-effort across concurrently removed, stale, or
377        // malformed receipts. Attachment re-reads and validates the selected
378        // receipt strictly before using any secret it contains.
379        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}