git.lucas.co / cce-secrets
secrets manager
git clone https://git.lucas.co/cce-secrets.git

commit7b0f1997241dba4938e68d7d1ca07cac8b3433fb
parentb8b222f2ed
authorLucas Galante <lsgalante12@gmail.com>
date2026-10-02 11:16
keyring sync: run one op at a time, so a login raises one dialog

Every op call still waiting on 1Password's Authorize dialog gets a dialog
of its own. At login the first tick's `item list` sat on its dialog while
cce-secrets asked for a one-time code, and the daemon spawned `item get
--otp` beside it: the user answered two identical "cce-keyring-sync wants
CLI access" dialogs at both logins on 2026-10-02 (1Password log: two
caller connections, 26 s and 8 s apart). A process-wide lock queues the
code request behind the pending call, which it then rides on.

A single op call from a fresh parent was measured to raise exactly one
dialog, so op itself is not the second requester. The new test runs three
calls against a stand-in op and fails with the lock removed.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>

 src/bin/cce-keyring-sync/op.rs    | 41 +++++++++++++++++++++++++++++++++++++--
 src/bin/cce-keyring-sync/serve.rs |  6 +++---
 2 files changed, 42 insertions(+), 5 deletions(-)

diff --git a/src/bin/cce-keyring-sync/op.rs b/src/bin/cce-keyring-sync/op.rs
index 9c5d60d..e1002e6 100644
--- a/src/bin/cce-keyring-sync/op.rs
+++ b/src/bin/cce-keyring-sync/op.rs
@@ -18,6 +18,9 @@ use serde_json::{json, Value};
 /// prompt reports itself as such instead of as a kill.
 pub const OP_TIMEOUT: Duration = Duration::from_secs(75);
 
+/// Serializes every `op` spawn in this process (see `run_as`).
+static OP_LOCK: tokio::sync::Mutex<()> = tokio::sync::Mutex::const_new(());
+
 /// The text `op` prints when the Authorize dialog timed out unanswered.
 const DISMISSED: &str = "authorization prompt dismissed";
 /// What `op` prints when nobody unlocked the app in time: the first call of
@@ -99,11 +102,13 @@ pub struct OnePassword {
     pub vault: String,
     /// `--account`, for a person with several signed in. Empty: op's default.
     pub account: String,
+    /// The program run: `op` from PATH; a stand-in script under test.
+    pub bin: String,
 }
 
 impl OnePassword {
     pub fn new(vault: &str) -> Self {
-        OnePassword { vault: vault.to_string(), account: String::new() }
+        OnePassword { vault: vault.to_string(), account: String::new(), bin: "op".to_string() }
     }
 
     /// Spawn `op` as a direct child (the authorization is keyed to *our*
@@ -116,7 +121,14 @@ impl OnePassword {
     /// `run`, with `--format json` optional: `--otp` refuses it.
     async fn run_as(&self, args: &[&str], stdin: Option<Vec<u8>>, json: bool) -> Result<Vec<u8>, String> {
         use tokio::io::AsyncWriteExt;
-        let mut cmd = tokio::process::Command::new("op");
+        // One `op` at a time per process. Each call still waiting on the
+        // Authorize dialog gets a dialog of its own: at login the first
+        // tick's `item list` sat on its dialog while a cce-secrets code
+        // request spawned `item get --otp`, and the user answered two
+        // identical dialogs (2026-10-02, both logins that day). Queued
+        // behind the pending call, the second inherits its authorization.
+        let _one_at_a_time = OP_LOCK.lock().await;
+        let mut cmd = tokio::process::Command::new(&self.bin);
         cmd.args(args);
         if json {
             cmd.arg("--format").arg("json");
@@ -518,4 +530,29 @@ mod tests {
         assert!(is_app_down("op item: connecting to desktop app: cannot connect to 1Password app, make sure it is running"));
         assert!(!is_app_down("op item: authorization timeout"));
     }
+
+    #[tokio::test(flavor = "multi_thread", worker_threads = 4)]
+    async fn op_calls_never_overlap() {
+        // A code request arriving while the tick's call waits on its dialog
+        // must queue behind it, not raise a second dialog of its own.
+        let dir = std::env::temp_dir().join(format!("op-serial-{}", std::process::id()));
+        std::fs::create_dir_all(&dir).unwrap();
+        let log = dir.join("log");
+        let bin = dir.join("op");
+        std::fs::write(&bin, format!("#!/bin/sh\necho start >> '{0}'\nsleep 0.3\necho end >> '{0}'\n", log.display())).unwrap();
+        use std::os::unix::fs::PermissionsExt;
+        std::fs::set_permissions(&bin, std::fs::Permissions::from_mode(0o755)).unwrap();
+        let mut op = OnePassword::new("v");
+        op.bin = bin.to_str().unwrap().to_string();
+        let calls = (0..3).map(|_| {
+            let op = OnePassword { bin: op.bin.clone(), ..OnePassword::new("v") };
+            tokio::spawn(async move { op.run(&["item", "list"], None).await })
+        });
+        for c in calls.collect::<Vec<_>>() {
+            c.await.unwrap().unwrap();
+        }
+        let lines = std::fs::read_to_string(&log).unwrap();
+        std::fs::remove_dir_all(&dir).unwrap();
+        assert_eq!(lines, "start\nend\n".repeat(3), "op calls overlapped");
+    }
 }
diff --git a/src/bin/cce-keyring-sync/serve.rs b/src/bin/cce-keyring-sync/serve.rs
index 9702b21..dfd3eef 100644
--- a/src/bin/cce-keyring-sync/serve.rs
+++ b/src/bin/cce-keyring-sync/serve.rs
@@ -58,9 +58,9 @@ pub async fn serve(state_path: PathBuf) {
     }
     loop {
         let Ok((stream, _)) = listener.accept().await else { continue };
-        // Its own task: a code request that raises a dialog holds `op`
-        // for up to a minute, and must hold neither the tick nor the
-        // next request.
+        // Its own task, so a slow reply holds no other connection. The
+        // `op` call itself queues behind any in flight (op.rs, `run_as`):
+        // two calls waiting on authorization would raise two dialogs.
         let state_path = state_path.clone();
         tokio::spawn(async move { handle(stream, &state_path).await });
     }