diff --git a/desktop/src-tauri/src/claim.rs b/desktop/src-tauri/src/claim.rs new file mode 100644 index 00000000000..22d0cfcbc24 --- /dev/null +++ b/desktop/src-tauri/src/claim.rs @@ -0,0 +1,289 @@ +//! Recording this installation as the runtime owner, through the bundled CLI. +//! +//! The claim is only valid against the exact answer the consent prompt was approved from, +//! so this is a subprocess with expectations on argv rather than an in-process write: the +//! ownership mutation lease, the subject revalidation and the managing-CLI re-observation +//! all live in the CLI's `recordServiceOwner`, and re-running them here would be a second +//! implementation of a rule that has to be identical. +//! +//! Like `runtime_stop`, the result is a document, not a guess: `ocx service claim --json` +//! puts one summary on stdout and this consumes `ok` and the exit code rather than +//! inferring them. A claim that did not end in exit 0 with `ok:true` is a claim that did +//! not happen — and a takeover that reached here already stopped the foreign runtime, so +//! the caller's failure is a stopped runtime with no owner recorded, which the next launch +//! resolves as an ordinary absence. + +use serde::Deserialize; +use tauri::AppHandle; +use tauri_plugin_shell::ShellExt; +use tokio::time::{timeout_at, Instant}; + +/// The wire version this shell understands. +pub const SCHEMA: &str = "ocx-service-claim/1"; + +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimOwnership { + pub owner: String, + pub install_id: String, + pub consent_generation: u64, +} + +#[derive(Clone, Debug, Deserialize)] +#[serde(rename_all = "camelCase")] +pub struct ClaimSummary { + pub schema: String, + pub ok: bool, + /// Present on success. + pub ownership: Option, + /// Present on failure: the CLI's machine-readable error code. + pub code: Option, + /// Present on failure. + pub message: Option, +} + +/// What the shell concluded. +#[derive(Clone, Debug, PartialEq, Eq)] +pub enum ClaimResult { + /// The CLI recorded the claim and named the generation it landed at. + Recorded(ClaimOwnership), + /// It reported anything else, or the run could not be read at all. + Failed(String), +} + +impl ClaimResult { + #[cfg(test)] + pub fn is_recorded(&self) -> bool { + matches!(self, Self::Recorded(_)) + } +} + +/// The arguments a takeover builds from the resolve answer it was approved against. +/// +/// `Recorded::Unknown` gets no argv: fabricating `--expect-none --expect-revision 0` would claim +/// against a subject nobody approved, so the answer is None and the caller refuses. +pub fn args( + install_id: &str, + recorded: &crate::ownership::Recorded, + token: &str, +) -> Option> { + let mut argv = vec![ + "service".to_owned(), + "claim".to_owned(), + "--owner".to_owned(), + "desktop".to_owned(), + "--install-id".to_owned(), + install_id.to_owned(), + ]; + match recorded { + crate::ownership::Recorded::None { revision } => { + argv.push("--expect-none".to_owned()); + argv.push("--expect-revision".to_owned()); + argv.push(revision.to_string()); + } + crate::ownership::Recorded::Owned { + ownership, + revision, + } => { + argv.extend([ + "--expect-owner".to_owned(), + match ownership.owner { + crate::ownership::Owner::Cli => "cli".to_owned(), + crate::ownership::Owner::Desktop => "desktop".to_owned(), + }, + "--expect-install-id".to_owned(), + ownership.install_id.clone(), + "--expect-generation".to_owned(), + ownership.consent_generation.to_string(), + "--expect-revision".to_owned(), + revision.to_string(), + ]); + } + // A takeover is only offered when the record was read; unknown never reaches here, + // and refusing beats inventing an approval. + crate::ownership::Recorded::Unknown { .. } => return None, + } + argv.extend([ + "--expect-compatibility-token".to_owned(), + token.to_owned(), + "--json".to_owned(), + ]); + Some(argv) +} + +/// Read one claim summary. +/// +/// Exit 0 with `ok:true` is the only success — the claim path uses exit 1 with a +/// machine-readable `code` for subject mismatches and changed compatibility, and both of +/// those are refusals to re-ask from, not partial writes. +pub fn read(exit_code: Option, stdout: &[u8], stderr: &[u8]) -> ClaimResult { + let text = String::from_utf8_lossy(stdout); + let summary: ClaimSummary = match serde_json::from_str(text.trim()) { + Ok(summary) => summary, + Err(error) => { + let detail = String::from_utf8_lossy(stderr); + let detail = detail.trim(); + let code = exit_code + .map(|code| code.to_string()) + .unwrap_or_else(|| "no exit code".to_owned()); + return ClaimResult::Failed(if detail.is_empty() { + format!("the bundled CLI's claim output could not be read (exit {code}: {error})") + } else { + format!("the bundled CLI's claim output could not be read (exit {code}): {detail}") + }); + } + }; + if summary.schema != SCHEMA { + return ClaimResult::Failed(format!( + "the bundled CLI answered with schema {} and this app understands {SCHEMA}", + summary.schema + )); + } + if exit_code != Some(0) || !summary.ok { + return ClaimResult::Failed(summary.message.unwrap_or_else(|| { + format!( + "the claim was refused ({})", + summary.code.unwrap_or_else(|| "no code".to_owned()) + ) + })); + } + match summary.ownership { + Some(ownership) => ClaimResult::Recorded(ownership), + None => ClaimResult::Failed( + "the claim reported success but carried no ownership record".to_owned(), + ), + } +} + +/// Run the bundled `ocx service claim`, under the caller's deadline. +pub async fn run(app: &AppHandle, argv: Vec, deadline: Instant) -> ClaimResult { + let command = match app.shell().sidecar("ocx") { + Ok(command) => command.args(argv), + Err(error) => { + return ClaimResult::Failed(format!("the bundled CLI could not be started ({error})")) + } + }; + match timeout_at(deadline, command.output()).await { + Ok(Ok(output)) => read(output.status.code(), &output.stdout, &output.stderr), + Ok(Err(error)) => { + ClaimResult::Failed(format!("the bundled CLI could not be run ({error})")) + } + Err(_) => ClaimResult::Failed( + "the bundled CLI did not finish the claim before the deadline".to_owned(), + ), + } +} + +#[cfg(test)] +mod tests { + use super::{args, read, ClaimResult}; + use crate::ownership::{Owner, Recorded}; + + fn document(ok: bool, extra: &str) -> String { + format!(r#"{{"schema":"ocx-service-claim/1","ok":{ok}{extra}}}"#) + } + + #[test] + fn the_arguments_carry_the_exact_approved_subject() { + let none = args("install-a", &Recorded::None { revision: 0 }, "tok"); + assert_eq!( + none.expect("argv for a read record"), + vec![ + "service", + "claim", + "--owner", + "desktop", + "--install-id", + "install-a", + "--expect-none", + "--expect-revision", + "0", + "--expect-compatibility-token", + "tok", + "--json", + ] + ); + let owned = Recorded::Owned { + ownership: crate::ownership::Claim { + owner: Owner::Cli, + install_id: "npm-1".to_owned(), + consent_generation: 2, + }, + revision: 9, + }; + let argv = args("install-a", &owned, "tok").expect("a claim against a read record"); + assert!(argv + .windows(2) + .any(|pair| pair == ["--expect-owner", "cli"])); + assert!(argv + .windows(2) + .any(|pair| pair == ["--expect-install-id", "npm-1"])); + assert!(argv + .windows(2) + .any(|pair| pair == ["--expect-generation", "2"])); + assert!(argv + .windows(2) + .any(|pair| pair == ["--expect-revision", "9"])); + } + + #[test] + fn an_unread_record_gets_no_claim_rather_than_a_fabricated_one() { + // Nobody approved a subject the resolve could not read, so there is nothing to claim + // against -- and "expect none, revision 0" would be that approval invented. + assert!(args( + "install-a", + &Recorded::Unknown { + reason: "why".to_owned() + }, + "tok" + ) + .is_none()); + } + + #[test] + fn a_recorded_claim_is_the_only_success() { + let ok = document( + true, + r#","ownership":{"owner":"desktop","installId":"install-a","consentGeneration":1},"revision":3"#, + ); + let result = read(Some(0), ok.as_bytes(), b""); + match result { + ClaimResult::Recorded(ownership) => { + assert_eq!(ownership.install_id, "install-a"); + assert_eq!(ownership.consent_generation, 1); + } + ClaimResult::Failed(reason) => panic!("{reason}"), + } + // Success has to arrive with exit 0 and the record it wrote. + assert!(!read(Some(1), ok.as_bytes(), b"").is_recorded()); + assert!(!read(Some(0), document(true, "").as_bytes(), b"").is_recorded()); + } + + #[test] + fn a_refusal_carries_the_clis_own_message() { + let refused = document( + false, + r#","code":"service-ownership-subject-mismatch","message":"ownership changed""#, + ); + let result = read(Some(1), refused.as_bytes(), b""); + match result { + ClaimResult::Failed(reason) => assert!(reason.contains("ownership changed")), + ClaimResult::Recorded(_) => panic!("a refused claim is not recorded"), + } + } + + #[test] + fn output_that_cannot_be_read_is_a_failure_not_a_claim() { + assert!(!read(Some(0), b"", b"boom").is_recorded()); + assert!(!read(Some(0), b"not json", b"").is_recorded()); + assert!(!read(None, b"", b"").is_recorded()); + let future = document(true, r#","ownership":{"owner":"desktop","installId":"i","consentGeneration":1},"revision":1"#) + .replace("ocx-service-claim/1", "ocx-service-claim/2"); + let result = read(Some(0), future.as_bytes(), b""); + assert!(!result.is_recorded()); + match result { + ClaimResult::Failed(reason) => assert!(reason.contains("ocx-service-claim/2")), + _ => unreachable!(), + } + } +} diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index d24fefba790..18136ed0a82 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -1,4 +1,5 @@ mod auth; +mod claim; mod endpoint; mod exit; mod first_run; @@ -156,6 +157,17 @@ fn retry_startup(app: tauri::AppHandle) { startup::begin(&app); } +/// The user's answer to the takeover prompt the startup sequence is waiting on. +/// +/// The sequence holds a oneshot for exactly the duration of the prompt; a decision arriving +/// with nothing pending is a click after the fact, and it changes nothing. +#[tauri::command] +fn decide_takeover(app: tauri::AppHandle, approved: bool) { + if let Some(startup) = app.try_state::() { + startup.decide_takeover(approved); + } +} + pub fn run() { let builder = tauri::Builder::default() .plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| { @@ -188,7 +200,8 @@ pub fn run() { hide_dashboard, startup_snapshot, startup_phases, - retry_startup + retry_startup, + decide_takeover ]) .setup(|app| { app.manage(AppState::new()); diff --git a/desktop/src-tauri/src/ownership.rs b/desktop/src-tauri/src/ownership.rs index a27bbf6a8e2..5b30eb366f1 100644 --- a/desktop/src-tauri/src/ownership.rs +++ b/desktop/src-tauri/src/ownership.rs @@ -16,7 +16,6 @@ //! contract that lands fills a hole rather than reshaping this file. use serde::Deserialize; -use tauri::AppHandle; /// Who a claim names. #[derive(Clone, Copy, Debug, PartialEq, Eq, Deserialize)] @@ -47,15 +46,26 @@ pub struct Claim { #[serde(tag = "kind", rename_all = "lowercase")] pub enum Recorded { /// No claim. The CLI install that registered the service owns the runtime, which is also what - /// every record written before the field existed says. - None, + /// every record written before the field existed says. The revision is the record's own + /// sequence, and a later `service claim` carries it as `expect-revision`. + None { revision: u64 }, /// A claim, whoever it names. - Owned { ownership: Claim }, + Owned { ownership: Claim, revision: u64 }, /// The claim could not be read for a decision. This is not "nobody owns it": an unreadable /// path, a corrupt anchor record and paths naming different owners all land here. Unknown { reason: String }, } +impl Default for Recorded { + /// A resolve document that carries no ownership field at all did not answer the question — + /// the older bundled CLI predates it — and an unanswered question is not a claim. + fn default() -> Self { + Self::Unknown { + reason: "the bundled CLI did not report ownership".to_owned(), + } + } +} + /// The comparison `ownershipGrantedTo` defines: same owner, same install id. /// /// True means this installation already holds consent. False against a recorded claim means a @@ -83,8 +93,8 @@ pub enum Consent { pub fn consent(recorded: &Recorded, install_id: &str) -> Consent { match recorded { Recorded::Unknown { .. } => Consent::Refuse, - Recorded::None => Consent::AskFirstTime, - Recorded::Owned { ownership } => { + Recorded::None { .. } => Consent::AskFirstTime, + Recorded::Owned { ownership, .. } => { if granted_to(Some(ownership), Owner::Desktop, install_id) { Consent::Held } else { @@ -94,35 +104,19 @@ pub fn consent(recorded: &Recorded, install_id: &str) -> Consent { } } -/// Read the recorded claim through the bundled CLI. -/// -/// Empty on purpose. Lane A publishes the machine-readable resolve the shell drives, and this is -/// the one call site that changes when it lands: it has to return the CLI's own answer, including -/// its refusals, rather than a verdict computed here. Until then the answer is *unavailable*, which -/// is not [`Recorded::None`] — the shell has not been told that nobody owns the runtime, it has not -/// asked — so no takeover is attempted and nothing is recorded. -pub fn resolve(_app: &AppHandle) -> Option { - None -} - /// One line for the startup state and for the diagnostic. -pub fn describe(recorded: Option<&Recorded>, install_id: Option<&str>) -> String { +pub fn describe(recorded: &Recorded, install_id: Option<&str>) -> String { let installation = match install_id { Some(id) => format!("installation {id}"), None => "installation id unavailable".to_owned(), }; let verdict = match (recorded, install_id) { - (None, _) => { - "recorded owner not read: the bundled CLI's resolve contract has not landed".to_owned() - } - (Some(Recorded::Unknown { reason }), _) => { + (Recorded::Unknown { reason }, _) => { format!("recorded owner could not be read ({reason}), so nothing is claimed") } - (Some(_), None) => { - "recorded owner read, but this installation has no id to compare".to_owned() - } - (Some(recorded), Some(id)) => match (consent(recorded, id), recorded) { - (Consent::Held, Recorded::Owned { ownership }) => format!( + (_, None) => "recorded owner read, but this installation has no id to compare".to_owned(), + (_, Some(id)) => match (consent(recorded, id), recorded) { + (Consent::Held, Recorded::Owned { ownership, .. }) => format!( "this installation owns the runtime (consent generation {})", ownership.consent_generation ), @@ -134,9 +128,27 @@ pub fn describe(recorded: Option<&Recorded>, install_id: Option<&str>) -> String format!("{installation}; {verdict}") } +/// Who the recorded claim names, for the consent panel. +pub fn owner_label(recorded: &Recorded) -> String { + match recorded { + Recorded::None { .. } => "no recorded owner (an npm or standalone ocx install)".to_owned(), + Recorded::Owned { ownership, .. } => match ownership.owner { + Owner::Cli => format!( + "the OpenCodex CLI install (installation {})", + ownership.install_id + ), + Owner::Desktop => format!( + "another OpenCodex desktop installation (installation {})", + ownership.install_id + ), + }, + Recorded::Unknown { reason } => format!("unknown ({reason})"), + } +} + #[cfg(test)] mod tests { - use super::{consent, describe, granted_to, Claim, Consent, Owner, Recorded}; + use super::{consent, describe, granted_to, owner_label, Claim, Consent, Owner, Recorded}; fn owned(owner: Owner, install_id: &str, generation: u64) -> Recorded { Recorded::Owned { @@ -145,6 +157,7 @@ mod tests { install_id: install_id.to_owned(), consent_generation: generation, }, + revision: 4, } } @@ -178,7 +191,10 @@ mod tests { consent(&owned(Owner::Desktop, "abc", 1), "abc"), Consent::Held ); - assert_eq!(consent(&Recorded::None, "abc"), Consent::AskFirstTime); + assert_eq!( + consent(&Recorded::None { revision: 0 }, "abc"), + Consent::AskFirstTime + ); } #[test] @@ -199,21 +215,21 @@ mod tests { reason: "a service state path could not be read".to_owned(), }; assert_eq!(consent(&unknown, "abc"), Consent::Refuse); - assert!(describe(Some(&unknown), Some("abc")).contains("could not be read")); + assert!(describe(&unknown, Some("abc")).contains("could not be read")); } #[test] fn the_wire_shape_is_the_one_the_cli_records() { let resolution: Recorded = serde_json::from_str( - r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":3}}"#, + r#"{"kind":"owned","ownership":{"owner":"desktop","installId":"abc","consentGeneration":3},"revision":4}"#, ) .expect("the recorded resolution"); assert_eq!(resolution, owned(Owner::Desktop, "abc", 3)); assert_eq!(consent(&resolution, "abc"), Consent::Held); - assert!(describe(Some(&resolution), Some("abc")).contains("consent generation 3")); + assert!(describe(&resolution, Some("abc")).contains("consent generation 3")); assert_eq!( - serde_json::from_str::(r#"{"kind":"none"}"#).expect("no claim"), - Recorded::None + serde_json::from_str::(r#"{"kind":"none","revision":0}"#).expect("no claim"), + Recorded::None { revision: 0 } ); assert_eq!( serde_json::from_str::(r#"{"kind":"unknown","reason":"why"}"#) @@ -225,12 +241,50 @@ mod tests { } #[test] - fn the_description_separates_not_asked_from_nobody_owns_it() { - let not_asked = describe(None, Some("abc")); - let unowned = describe(Some(&Recorded::None), Some("abc")); - assert!(not_asked.contains("abc")); - assert_ne!(not_asked, unowned); - assert!(describe(None, None).contains("unavailable")); + fn a_resolve_document_without_an_ownership_answer_reads_unknown() { + assert_eq!( + Recorded::default(), + Recorded::Unknown { + reason: "the bundled CLI did not report ownership".to_owned() + } + ); + assert!(serde_json::from_str::(r#"{"kind":"none"}"#).is_err()); + } + + #[test] + fn the_description_separates_not_read_from_nobody_owns_it() { + let unread = describe( + &Recorded::Unknown { + reason: "why".to_owned(), + }, + Some("abc"), + ); + let unowned = describe(&Recorded::None { revision: 0 }, Some("abc")); + assert!(unread.contains("abc")); + assert_ne!(unread, unowned); + assert!(describe(&Recorded::None { revision: 0 }, None).contains("unavailable")); + } + + #[test] + fn owner_label_names_who_the_claim_is_for() { + assert_eq!( + owner_label(&Recorded::None { revision: 0 }), + "no recorded owner (an npm or standalone ocx install)" + ); + assert_eq!( + owner_label(&owned(Owner::Cli, "npm-1", 1)), + "the OpenCodex CLI install (installation npm-1)" + ); + assert_eq!( + owner_label(&owned(Owner::Desktop, "other", 2)), + "another OpenCodex desktop installation (installation other)" + ); + assert_eq!( + owner_label(&Recorded::Unknown { + reason: "why".to_owned() + }), + "unknown (why)" + ); } #[test] diff --git a/desktop/src-tauri/src/resolve.rs b/desktop/src-tauri/src/resolve.rs index 8172006d0ff..e644b6edcc4 100644 --- a/desktop/src-tauri/src/resolve.rs +++ b/desktop/src-tauri/src/resolve.rs @@ -14,6 +14,7 @@ //! reading that must never happen is "the resolve failed, so nobody must be listening". use crate::endpoint::ProxyEndpoint; +use crate::ownership::Recorded; use serde::Deserialize; use std::path::PathBuf; use tauri::AppHandle; @@ -52,6 +53,36 @@ pub struct Port { pub configured: u16, } +/// Whether the CLI says a desktop takeover can be offered. +/// +/// The token is the binding a later `ocx service claim` repeats back: it covers the exact +/// subject and managing-CLI observations the consent was approved against, so a claim made +/// after either moved is refused rather than recorded. +#[derive(Clone, Debug, PartialEq, Eq, Deserialize)] +#[serde(tag = "kind", rename_all = "camelCase")] +pub enum Takeover { + #[serde(rename_all = "camelCase")] + Supported { + protocol_version: u64, + minimum_cli_version: String, + token: String, + }, + Blocked { + reason: String, + detail: String, + }, +} + +impl Default for Takeover { + /// An older bundled CLI carries no takeover answer at all; silence is not approval. + fn default() -> Self { + Self::Blocked { + reason: "unreported".to_owned(), + detail: "the bundled CLI did not report takeover compatibility".to_owned(), + } + } +} + #[derive(Clone, Debug, PartialEq, Eq, Deserialize)] #[serde(rename_all = "camelCase")] pub struct Resolved { @@ -60,6 +91,12 @@ pub struct Resolved { pub config_home: String, pub port: Port, pub liveness: Liveness, + /// The recorded runtime owner, already in the CLI's three answers. Absent on older + /// documents, which read as unknown rather than as nobody owning the runtime. + #[serde(default)] + pub ownership: Recorded, + #[serde(default)] + pub takeover: Takeover, } impl Resolved { @@ -228,8 +265,10 @@ pub async fn run(app: &AppHandle, deadline: Instant) -> Resolution { #[cfg(test)] mod tests { use super::{ - live_verdict, loopback_reachable, may_start, read, LiveVerdict, Resolution, Status, SCHEMA, + live_verdict, loopback_reachable, may_start, read, LiveVerdict, Resolution, Status, + Takeover, SCHEMA, }; + use crate::ownership::{Owner, Recorded}; const LIVE: &str = r#"{"schema":"ocx-resolve/1","cliVersion":"2.61.0","configHome":"/h", "port":{"effective":10100,"configured":10100,"source":"runtime-record"}, @@ -318,6 +357,69 @@ mod tests { } } + #[test] + fn ownership_and_takeover_answers_are_read_whole() { + let document = format!( + "{}{}}}", + LIVE.strip_suffix('}').unwrap(), + r#","ownership":{"kind":"owned","ownership":{"owner":"cli","installId":"npm-1","consentGeneration":2},"revision":9},"takeover":{"kind":"supported","protocolVersion":1,"minimumCliVersion":"2.61.0","token":"abc"}"# + ); + let resolution = read(Some(0), document.as_bytes(), b""); + let resolved = match resolution.resolved() { + Some(resolved) => resolved.clone(), + None => panic!("{}", resolution.reason().unwrap()), + }; + assert_eq!( + resolved.ownership, + Recorded::Owned { + ownership: crate::ownership::Claim { + owner: Owner::Cli, + install_id: "npm-1".to_owned(), + consent_generation: 2, + }, + revision: 9, + } + ); + assert!(matches!( + resolved.takeover, + Takeover::Supported { ref token, .. } if token == "abc" + )); + } + + #[test] + fn a_missing_ownership_or_takeover_answer_is_not_consent() { + // Older bundled CLIs carry neither field; silence must read unknown/blocked, never + // "nobody owns it" or "takeover supported". + let resolved = read(Some(0), LIVE.as_bytes(), b"") + .resolved() + .expect("a document") + .clone(); + assert!(matches!(resolved.ownership, Recorded::Unknown { .. })); + assert!(matches!(resolved.takeover, Takeover::Blocked { .. })); + assert_eq!(resolved.takeover, Takeover::default()); + } + + #[test] + fn a_blocked_takeover_carries_its_reason() { + let document = format!( + "{}{}}}", + LIVE.strip_suffix('}').unwrap(), + r#","ownership":{"kind":"none","revision":0},"takeover":{"kind":"blocked","reason":"managing-cli-unsupported","detail":"path uses 2.59.0","minimumCliVersion":"2.61.0"}"# + ); + let resolved = read(Some(0), document.as_bytes(), b"") + .resolved() + .expect("a document") + .clone(); + assert_eq!(resolved.ownership, Recorded::None { revision: 0 }); + assert_eq!( + resolved.takeover, + Takeover::Blocked { + reason: "managing-cli-unsupported".to_owned(), + detail: "path uses 2.59.0".to_owned(), + } + ); + } + #[test] fn a_schema_this_app_does_not_know_is_unknown() { let future = LIVE.replace("ocx-resolve/1", "ocx-resolve/2"); diff --git a/desktop/src-tauri/src/startup.rs b/desktop/src-tauri/src/startup.rs index bcc08ba2279..da9b23488a5 100644 --- a/desktop/src-tauri/src/startup.rs +++ b/desktop/src-tauri/src/startup.rs @@ -17,11 +17,12 @@ use crate::{ auth::Auth, + claim, endpoint::ProxyEndpoint, first_run::{self, StartAtLogin}, identity, ownership, proxy::{ProxyClient, RuntimeIdentity}, - resolve, + resolve, runtime_stop, sidecar::{self, SidecarWatch}, tray_availability::{self, TrayAvailability}, AppState, @@ -35,6 +36,7 @@ use std::{ }, }; use tauri::{AppHandle, Emitter, Manager}; +use tokio::sync::oneshot; use tokio::time::{sleep, Duration, Instant}; /// The event the bootstrap page listens on. @@ -95,6 +97,7 @@ pub enum Phase { Resolving, Probing, Attaching, + TakingOver, Starting, Waiting, Ready, @@ -103,11 +106,12 @@ pub enum Phase { /// Every phase, in the order they run. The bootstrap page derives its checklist from this rather /// than restating it, so a phase cannot exist in one place and be missing from the other. -pub const PHASES: [Phase; 8] = [ +pub const PHASES: [Phase; 9] = [ Phase::Registering, Phase::Resolving, Phase::Probing, Phase::Attaching, + Phase::TakingOver, Phase::Starting, Phase::Waiting, Phase::Ready, @@ -122,6 +126,7 @@ impl Phase { Self::Resolving => "resolving", Self::Probing => "probing", Self::Attaching => "attaching", + Self::TakingOver => "taking-over", Self::Starting => "starting", Self::Waiting => "waiting", Self::Ready => "ready", @@ -135,6 +140,7 @@ impl Phase { Self::Resolving => "Resolving the configuration home and port", Self::Probing => "Looking for a runtime that is already listening", Self::Attaching => "Attaching to the runtime that answered", + Self::TakingOver => "Taking over the runtime that was already listening", Self::Starting => "Starting the bundled runtime", Self::Waiting => "Waiting for the runtime to report healthy", Self::Ready => "Ready", @@ -185,6 +191,21 @@ pub struct Progress { pub dashboard: Option, pub diagnostic: Option, pub can_retry: bool, + /// Present only while the shell is waiting on the user's takeover decision. + pub consent: Option, +} + +/// What the consent panel renders. `blocked` carries the CLI's refusal reason when a +/// takeover cannot be offered; the panel is shown only for the offerable case today, but +/// the field is part of the wire so a later UI does not need a schema change. +#[derive(Clone, Debug, Serialize)] +#[serde(rename_all = "camelCase")] +pub struct ConsentPrompt { + pub endpoint: String, + pub port: u16, + pub home: String, + pub owner: String, + pub blocked: Option, } impl Progress { @@ -199,6 +220,7 @@ impl Progress { dashboard: None, diagnostic: None, can_retry: phase == Phase::Failed, + consent: None, } } } @@ -214,8 +236,8 @@ struct Target { #[derive(Clone, Debug)] pub struct Registration { pub login: StartAtLogin, - /// This installation's own id, and what the recorded runtime owner says about it. - pub identity: String, + /// This installation's own id, minted once in the app's config directory. + pub install_id: Option, } struct Live { @@ -230,6 +252,8 @@ pub struct Startup { running: AtomicBool, /// The outcome of the one-time registration, once it has happened. registered: Mutex>, + /// The pending takeover decision, when the sequence is waiting on the user. + consent: Mutex>>, } impl Startup { @@ -241,6 +265,7 @@ impl Startup { }), running: AtomicBool::new(false), registered: Mutex::new(None), + consent: Mutex::new(None), } } @@ -267,7 +292,33 @@ impl Startup { self.live().latest.clone() } + /// The user's answer to a pending takeover prompt. Nothing pending is a no-op: a retry + /// or a late click must never be read as a decision for a prompt that is not up. + pub fn decide_takeover(&self, approved: bool) { + if let Some(sender) = self + .consent + .lock() + .unwrap_or_else(PoisonError::into_inner) + .take() + { + let _ = sender.send(approved); + } + } + + fn await_consent(&self) -> oneshot::Receiver { + let (sender, receiver) = oneshot::channel(); + *self.consent.lock().unwrap_or_else(PoisonError::into_inner) = Some(sender); + receiver + } + + fn clear_consent(&self) { + *self.consent.lock().unwrap_or_else(PoisonError::into_inner) = None; + } + fn restart(&self) { + // A retry during a pending consent prompt drops the sender, so the waiting run reads + // the decision as declined rather than pairing an old prompt with a new sequence. + self.clear_consent(); let mut live = self.live(); live.reported.clear(); live.latest = Progress::new(Phase::Registering, 0); @@ -322,7 +373,7 @@ pub fn begin(app: &AppHandle) { async fn run(app: &AppHandle) { let started = Instant::now(); - let deadline = started + DEADLINE; + let mut deadline = started + DEADLINE; let Some(watch) = app.try_state::().map(|state| state.watch.clone()) else { return; }; @@ -332,11 +383,7 @@ async fn run(app: &AppHandle) { app, started, Phase::Registering, - Some(format!( - "{}; {}", - registration.login.describe(), - registration.identity - )), + Some(registration.login.describe().to_owned()), ); report(app, started, Phase::Resolving, None); @@ -390,10 +437,11 @@ async fn run(app: &AppHandle) { started, Phase::Resolving, Some(format!( - "{} with a configuration home of {}, resolved by the bundled CLI {}", + "{} with a configuration home of {}, resolved by the bundled CLI {}; {}", target.endpoint.url(""), target.home.display(), - answer.cli_version + answer.cli_version, + ownership::describe(&answer.ownership, registration.install_id.as_deref()) )), ); @@ -408,29 +456,93 @@ async fn run(app: &AppHandle) { } }), ); + let mut took_over = false; match resolve::live_verdict(&resolution) { resolve::LiveVerdict::Attach => { - report( - app, - started, - Phase::Attaching, - Some("a runtime was already listening, so this app is a guest on it".to_owned()), - ); - if bind(app, &proxy, deadline).await.is_none() { - fail( - app, - started, - Some(&target), - ®istration, - &watch, - Phase::Attaching, - "the runtime answered but did not identify itself, so this app did not attach" - .to_owned(), - ); - return; + // Without our own id nothing can ever match us, which is the answer Refuse gives. + let consent = match registration.install_id.as_deref() { + Some(install_id) => ownership::consent(&answer.ownership, install_id), + None => ownership::Consent::Refuse, + }; + match attach_plan(consent, &answer.takeover) { + AttachPlan::Guest(detail) => { + attach_as_guest( + app, + started, + &target, + ®istration, + &watch, + &proxy, + endpoint, + deadline, + detail, + ) + .await; + return; + } + AttachPlan::Ask(token) => { + // The prompt has to be visible even when this launch started hidden. + if let Some(window) = app.get_webview_window("main") { + crate::window::show(&window); + } + let Some(startup) = app.try_state::() else { + return; + }; + let receiver = startup.await_consent(); + let mut progress = Progress::new(Phase::Attaching, elapsed(started)); + progress.detail = Some( + "a runtime was already listening; waiting for a decision on taking it over" + .to_owned(), + ); + progress.consent = Some(ConsentPrompt { + endpoint: target.endpoint.url(""), + port: target.endpoint.port, + home: target.home.display().to_string(), + owner: ownership::owner_label(&answer.ownership), + blocked: None, + }); + emit(app, progress, None); + // The user may take any time; the budget exists to bound the machinery, not + // the person, so the deadline moves by whatever the decision took. + let asked = Instant::now(); + let approved = receiver.await.unwrap_or(false); + deadline += asked.elapsed(); + startup.clear_consent(); + if !approved { + attach_as_guest( + app, + started, + &target, + ®istration, + &watch, + &proxy, + endpoint, + deadline, + "a runtime was already listening and taking it over was declined, so this app is a guest on it" + .to_owned(), + ) + .await; + return; + } + if take_over( + app, + started, + &mut deadline, + &target, + ®istration, + &watch, + &proxy, + &answer.ownership, + &token, + ) + .await + .is_err() + { + return; + } + took_over = true; + } } - finish(app, started, endpoint); - return; } // Something holds the port and this app cannot manage it. That is not an absence, so it // does not authorise starting a second runtime beside it either. @@ -448,8 +560,9 @@ async fn run(app: &AppHandle) { } resolve::LiveVerdict::NotLive => {} } - if !resolve::may_start(&resolution) { - // Only a proven absence authorises a start. Nothing else may fall through to one. + if !took_over && !resolve::may_start(&resolution) { + // Only a proven absence authorises a start. Nothing else may fall through to one. A + // takeover just proved its own absence by stopping what was there. fail( app, started, @@ -548,6 +661,190 @@ async fn run(app: &AppHandle) { ); } +/// What an attach turns into once the recorded owner and the CLI's compatibility answer are +/// laid next to each other. `Ask` carries the token the claim has to be made against. +enum AttachPlan { + /// Stay a guest on what answered; the string is the detail the phase reports. + Guest(String), + /// Offer the takeover and wait on the user. + Ask(String), +} + +fn attach_plan(consent: ownership::Consent, takeover: &resolve::Takeover) -> AttachPlan { + match consent { + ownership::Consent::Held => AttachPlan::Guest( + "a runtime was already listening and this installation already owns it".to_owned(), + ), + ownership::Consent::Refuse => AttachPlan::Guest( + "a runtime was already listening; its recorded owner could not be read, so this app is a guest on it and asked nothing".to_owned(), + ), + ownership::Consent::AskFirstTime | ownership::Consent::AskAgain => match takeover { + resolve::Takeover::Blocked { reason, detail } => AttachPlan::Guest(format!( + "a runtime was already listening, but taking it over is not available ({reason}: {detail}), so this app is a guest on it" + )), + resolve::Takeover::Supported { token, .. } => AttachPlan::Ask(token.clone()), + }, + } +} + +/// Report, bind and finish as a guest on the runtime that answered. +#[allow(clippy::too_many_arguments)] +async fn attach_as_guest( + app: &AppHandle, + started: Instant, + target: &Target, + registration: &Registration, + watch: &SidecarWatch, + proxy: &ProxyClient, + endpoint: ProxyEndpoint, + deadline: Instant, + detail: String, +) { + report(app, started, Phase::Attaching, Some(detail)); + if bind(app, proxy, deadline).await.is_none() { + fail( + app, + started, + Some(target), + registration, + watch, + Phase::Attaching, + "the runtime answered but did not identify itself, so this app did not attach" + .to_owned(), + ); + return; + } + finish(app, started, endpoint); +} + +/// Stop the runtime that answered, wait for its silence, and record this installation as +/// the owner. An `Err` has already been reported; `Ok` means the Starting branch may run. +#[allow(clippy::too_many_arguments)] +async fn take_over( + app: &AppHandle, + started: Instant, + deadline: &mut Instant, + target: &Target, + registration: &Registration, + watch: &SidecarWatch, + proxy: &ProxyClient, + recorded: &ownership::Recorded, + token: &str, +) -> Result<(), ()> { + report( + app, + started, + Phase::TakingOver, + Some("stopping the runtime that was already listening".to_owned()), + ); + let stopped = runtime_stop::run(app, *deadline).await; + // A refused connection, not exit 0 and not the probe's deadline, is the receipt: `ocx stop` + // reports exit 79 when the proxy stopped but history cleanup failed after it exited, and a + // `None` from alive_within is only the clock running out — neither is silence. So whatever + // the stop reported, the claim is made only once the port actively refuses. + let mut silent = false; + let mut still_answering = false; + while Instant::now() < *deadline { + match proxy.alive_within(*deadline).await { + Some(Err(_)) => { + silent = true; + break; + } + Some(Ok(_)) => { + still_answering = true; + sleep(POLL).await; + } + None => { + still_answering = false; + break; + } + } + } + if !silent { + fail( + app, + started, + Some(target), + registration, + watch, + Phase::TakingOver, + format!( + "the runtime that was already listening {} ({})", + if still_answering { + "is still answering after the stop" + } else { + "did not go silent before the deadline" + }, + stopped.describe() + ), + ); + return Err(()); + } + if !stopped.is_stopped() { + report( + app, + started, + Phase::TakingOver, + Some(format!( + "the runtime that was already listening stopped answering (stop reported: {})", + stopped.describe() + )), + ); + } + + report( + app, + started, + Phase::TakingOver, + Some("recording this installation as the runtime owner".to_owned()), + ); + let install_id = registration.install_id.clone().unwrap_or_default(); + // An unknown record reaches here only off the UI path, and the claim has to refuse rather + // than fabricate the subject it is claiming against. + let Some(argv) = claim::args(&install_id, recorded, token) else { + fail( + app, + started, + Some(target), + registration, + watch, + Phase::TakingOver, + "the recorded owner could not be read, so no claim was made".to_owned(), + ); + return Err(()); + }; + match claim::run(app, argv, *deadline).await { + claim::ClaimResult::Recorded(ownership) => { + report( + app, + started, + Phase::TakingOver, + Some(format!( + "this installation now owns the runtime (consent generation {})", + ownership.consent_generation + )), + ); + Ok(()) + } + claim::ClaimResult::Failed(message) => { + // The runtime is stopped either way. Refusing here leaves the next launch an + // ordinary absence to start into, which is the acceptable end state. + fail( + app, + started, + Some(target), + registration, + watch, + Phase::TakingOver, + format!( + "the runtime was stopped, but this installation could not be recorded as its owner: {message}" + ), + ); + Err(()) + } + } +} + /// Establish the app's own surface: the tray verdict, the tray, and the login item. /// /// It happens once per process. A retry re-runs the runtime half of the sequence, and running this @@ -599,10 +896,11 @@ async fn register(app: &AppHandle, deadline: Instant) -> Registration { // This installation's own id, and what the recorded runtime owner says about it. The claim // lives in the shared service install state and the CLI is what reads it; the comparison // against our own id is the rule that record publishes. - let install_id = identity::install_id(app); + // This installation's own id; what the recorded runtime owner says about it is part of the + // resolve answer, so the identity line is written where the answer exists. let registration = Registration { login, - identity: ownership::describe(ownership::resolve(app).as_ref(), install_id.as_deref()), + install_id: identity::install_id(app), }; if let Some(startup) = app.try_state::() { startup.remember_registration(registration.clone()); @@ -766,7 +1064,10 @@ pub fn diagnostic( None => lines.push("endpoint: not resolved".to_owned()), } lines.push(format!("start at login: {}", registration.login.describe())); - lines.push(format!("runtime ownership: {}", registration.identity)); + lines.push(format!( + "installation id: {}", + registration.install_id.as_deref().unwrap_or("not minted") + )); lines.push(match watch.exit() { Some(exit) => format!("runtime process: {}", exit.describe()), None => "runtime process: still running or never started".to_owned(), @@ -800,10 +1101,66 @@ fn elapsed(started: Instant) -> u64 { #[cfg(test)] mod tests { - use super::{shows_window, LaunchOrigin, Phase, AUTOSTART_FLAG, DEADLINE, PHASES, POLL}; + use super::{ + attach_plan, shows_window, AttachPlan, LaunchOrigin, Phase, Startup, AUTOSTART_FLAG, + DEADLINE, PHASES, POLL, + }; + use crate::ownership::Consent; + use crate::resolve::Takeover; use crate::tray_availability::TrayAvailability; use tokio::time::Duration; + fn supported() -> Takeover { + Takeover::Supported { + protocol_version: 1, + minimum_cli_version: "2.61.0".to_owned(), + token: "tok".to_owned(), + } + } + + fn blocked() -> Takeover { + Takeover::Blocked { + reason: "managing-cli-unsupported".to_owned(), + detail: "path uses 2.59.0".to_owned(), + } + } + + #[test] + fn a_retry_drops_a_prompt_the_run_is_still_waiting_on() { + // The waiting run reads the dropped sender as declined, so a stale prompt can never + // pair a decision meant for it with the retried sequence. + let startup = Startup::new(); + let mut receiver = startup.await_consent(); + startup.restart(); + assert!(matches!( + receiver.try_recv(), + Err(tokio::sync::oneshot::error::TryRecvError::Closed) + )); + } + + #[test] + fn an_ask_only_arises_when_the_takeover_can_be_taken() { + // Held and Refuse never ask, whatever the CLI reported about compatibility. + assert!(matches!( + attach_plan(Consent::Held, &supported()), + AttachPlan::Guest(_) + )); + assert!(matches!( + attach_plan(Consent::Refuse, &supported()), + AttachPlan::Guest(_) + )); + match attach_plan(Consent::AskFirstTime, &supported()) { + AttachPlan::Ask(token) => assert_eq!(token, "tok"), + AttachPlan::Guest(detail) => panic!("{detail}"), + } + match attach_plan(Consent::AskAgain, &blocked()) { + AttachPlan::Guest(detail) => { + assert!(detail.contains("managing-cli-unsupported: path uses 2.59.0")) + } + AttachPlan::Ask(_) => panic!("a blocked takeover is not an offer"), + } + } + #[test] fn only_the_autostart_argument_marks_a_login_launch() { let user = ["/Applications/OpenCodex.app".to_owned()]; diff --git a/desktop/ui/index.html b/desktop/ui/index.html index 4a74b191615..7829f0d2970 100644 --- a/desktop/ui/index.html +++ b/desktop/ui/index.html @@ -18,7 +18,13 @@ #phases li[data-state="done"] { color: #4b8b3b; } #phases li[data-state="failed"] { color: #b3261e; font-weight: 600; } #failure { margin-top: 1.25rem; display: grid; gap: .75rem; } - #failure[hidden] { display: none; } + #failure[hidden], #consent[hidden] { display: none; } + #consent { margin-top: 1.25rem; display: grid; gap: .75rem; } + #consent p { margin: 0; } + #consentTarget { display: grid; grid-template-columns: max-content 1fr; gap: .25rem .75rem; margin: 0; } + #consentTarget dt { color: #666; } + #consentTarget dd { margin: 0; font-family: ui-monospace, SFMono-Regular, Menlo, monospace; font-size: .85rem; } + #consentNote { color: #666; font-size: .85rem; } .actions { display: flex; gap: .6rem; align-items: center; } button { border: 0; border-radius: .5rem; padding: .6rem 1rem; background: #2563eb; color: white; cursor: pointer; font: inherit; } button.secondary { background: #e3e3e8; color: #202124; } @@ -32,6 +38,7 @@ #phases li[data-state="active"] { color: #f5f5f7; } #phases li[data-state="done"] { color: #8bd17c; } #phases li[data-state="failed"] { color: #ff8a80; } + #consentTarget dt, #consentNote { color: #bbb; } button.secondary { background: #3a3a3c; color: #f5f5f7; } textarea { background: #1c1c1e; border-color: #ffffff22; } } @@ -43,6 +50,19 @@

OpenCodex

Starting OpenCodex…

    +