Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
0aa114c
feat(cli): expose ownership and takeover compatibility on resolve, ad…
lidge-jun Sep 21, 2026
e522840
feat(desktop): ask consent on attach and take over the runtime with t…
lidge-jun Sep 21, 2026
6990d15
fix(desktop): accept a stop that silenced the runtime, clear a pendin…
lidge-jun Sep 21, 2026
2bc9544
test(desktop): assert the ownership contract the resolve document car…
lidge-jun Sep 21, 2026
1d75e74
test(desktop): update contract assertions for service claim and takeo…
lidge-jun Sep 21, 2026
a63e39e
test(desktop): count both bind call sites, owned or borrowed
lidge-jun Sep 21, 2026
5f5cd7d
fix(desktop): require a refused probe as the stop receipt, keep the c…
lidge-jun Sep 21, 2026
8287b62
test(desktop): assert the shared-deadline guard the consent wait extends
luvs01 Sep 22, 2026
cd58b63
docs(desktop): define the ownership acceptance contract and its reuse…
luvs01 Sep 22, 2026
3cb5715
docs(pr): attach the takeover consent surface capture
luvs01 Sep 22, 2026
8381c9c
fix(desktop): make the deadline guard's expiry atomic with consent tr…
luvs01 Sep 22, 2026
0be72ab
fix(desktop): refuse reports that arrive after the run already ended
luvs01 Sep 22, 2026
9699346
fix(desktop): serialize startup state and event publication
luvs01 Sep 22, 2026
e6277c1
Merge commit 'a6b54297b03fc218105273887ce147c901848d89' into takeover…
luvs01 Sep 22, 2026
3bc5f90
style(desktop): apply Rust formatter to the reporting gate
luvs01 Sep 22, 2026
8d6b1e1
fix(desktop): box the terminal expiry payload
luvs01 Sep 22, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
289 changes: 289 additions & 0 deletions desktop/src-tauri/src/claim.rs
Original file line number Diff line number Diff line change
@@ -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<ClaimOwnership>,
/// Present on failure: the CLI's machine-readable error code.
pub code: Option<String>,
/// Present on failure.
pub message: Option<String>,
}

/// 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<Vec<String>> {
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<i32>, 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<String>, 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!(),
}
}
}
15 changes: 14 additions & 1 deletion desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
mod auth;
mod claim;
#[cfg(target_os = "macos")]
mod companion_query;
mod companion_usage;
Expand Down Expand Up @@ -176,6 +177,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::Startup>() {
startup.decide_takeover(approved);
}
}

pub fn run() {
let builder = tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
Expand Down Expand Up @@ -209,7 +221,8 @@ pub fn run() {
hide_dashboard,
startup_snapshot,
startup_phases,
retry_startup
retry_startup,
decide_takeover
])
.setup(|app| {
app.manage(AppState::new());
Expand Down
Loading
Loading