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