Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
7 changes: 6 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -283,7 +283,12 @@ approved wherever the owner is
with the same cleaned reason that arrive within 250 ms of the first join one batch of up to 8 queries
(`FindBatches`, keyed by reason so an unrelated program's find never shares an approval or a Deny), asked
on its own task so a caller that gives up cannot strand the rest; the batch is dropped once every caller
has. One prompt quotes every query; a companion answers `{"found_each": [...]}`, one `Found` per query in
has. `op find --also` sends each query as its own `/admin/op/find` request at once, so the batch joins them.
An approved batch's answers are kept for 10 minutes in `KeptValues::finds`, by batch key and query, and a
later find with both the same is answered from there without asking; a forget clears them, and a batch
approved while a forget ran keeps nothing. On a hub, batches and kept answers are keyed by the spoke's ID as
well as the reason (`FindAsk::scope`), so neither a local program nor another spoke can join a spoke's batch
or get its kept answers by claiming its reason One prompt quotes every query; a companion answers `{"found_each": [...]}`, one `Found` per query in
order, and one from before batches, which ignores `also` and answers `found`, is reported as needing an
update. The Mac lists items once per batch and opens an item two queries match once The Mac that
answers runs `op item list --format json`, which holds no values, and scores items by how many query words
Expand Down
6 changes: 5 additions & 1 deletion docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -216,7 +216,11 @@ the prompt quotes the words and the reason. The phone cannot search 1Password, s
reference picked afterwards asks again and names that item. Scripts can do the same with the `secrets` server's
`find_references(query, reason)`. Finds with the same reason that arrive within 250 ms of each other, such as the
calls a script starts together under `asyncio.gather`, share one prompt that quotes every query, up to 8 of them;
each call still gets its own matches.
each call still gets its own matches. One `op find` can do the same with `--also`:
`codemode-mcp op find apple signing --also pushover --reason "set up the release build"` asks once and prints a
JSON list with each query, its items and `more`. Once approved, the same query with the same reason is answered
again for 10 minutes without asking, so a program rerun after misreading the output does not prompt again; a
different query or reason asks, and `codemode-mcp service restart --fresh` forgets every kept answer.

A file attached to an item, such as a signing certificate's `.p12`, is binary. Write it with
`codemode-mcp op read "op://Personal/Signing/cert.p12" --out-file cert.p12`, which keeps its exact bytes and adds no
Expand Down
1 change: 1 addition & 0 deletions src/admin.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1449,6 +1449,7 @@ async fn op_find(
.find_for_program(&companion::FindAsk {
query: request.query,
reason: request.reason,
scope: None,
})
.await;

Expand Down
4 changes: 3 additions & 1 deletion src/companion.rs
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@ pub use identity::{IdentityError, Role, load_or_create};
pub use invite::{InviteStatus, MAX_INVITE_LIFETIME, OpenedInvite};
pub use join::{JoinError, Joined, Joining, join};
pub use pair::{PairError, Paired, Pairing, companion_id, pair};
pub use program::{ProgramAsk, ProgramRead, ProgramReadError, ProgramTarget, ProgramValues};
pub use program::{
ProgramAsk, ProgramRead, ProgramReadError, ProgramTarget, ProgramValues, checked_query,
};
pub use server::{CompanionHandle, ServeError, serve};
pub use ticket::{
AnyTicket, INVITE_PREFIX, InviteSecret, InviteTicket, TICKET_PREFIX, Ticket, TicketError,
Expand Down
87 changes: 76 additions & 11 deletions src/companion/find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,13 @@
//!
//! Finds with the same reason that arrive within [`FIND_BATCH_WINDOW`] of
//! the first are asked as one, up to [`MAX_FIND_QUERIES`], so the searches a
//! script starts together under `asyncio.gather` need one approval
//! script starts together under `asyncio.gather`, or the queries of one
//! `op find`, need one approval
//!
//! An approved answer is kept for [`FIND_KEEP`], so the same query with the
//! same reason, such as a program run again after it misread the output, is
//! answered without asking. It names items and references, never a value,
//! that the owner already let a program with that reason see

use std::{
collections::HashMap,
Expand Down Expand Up @@ -46,6 +52,10 @@ const CANCEL_TITLE: &str = "Deny";
/// milliseconds
const FIND_BATCH_WINDOW: Duration = Duration::from_millis(250);

/// How long an approved find answers the same query and reason again
/// without asking; long enough for a program to be fixed and rerun
const FIND_KEEP: Duration = Duration::from_secs(10 * 60);

/// What a program looks for, as the admin route and the `secrets` builtin
/// server pass it on
#[derive(Debug, Clone, Default)]
Expand All @@ -54,18 +64,23 @@ pub struct FindAsk {
pub query: String,
/// Why, shown quoted as the program's claim
pub reason: Option<String>,
/// Whose finds these are, such as a spoke's ID, so one requester's find
/// never joins another's batch or gets its kept answer whatever reason
/// it claims; `None` for this machine
pub scope: Option<String>,
}

/// The items a find matched and the Mac that searched
#[derive(Debug)]
#[derive(Debug, Clone)]
pub struct ProgramFound {
/// `this Mac`, or the name of the companion that answered
pub source: String,
/// The items, best first
pub found: Found,
}

/// The finds waiting to be asked together, by their cleaned reason
/// The finds waiting to be asked together, by their scope and cleaned
/// reason, as [`batch_key`] joins them
///
/// Keying by reason keeps one script's searches together without letting an
/// unrelated program's find ride on the same approval or fall with its Deny
Expand Down Expand Up @@ -107,26 +122,32 @@ impl OnePassword {
}
let query = checked_query(&ask.query).map_err(ProgramReadError::Invalid)?;
let reason = clean_reason(ask.reason.as_deref());
let key = batch_key(ask.scope.as_deref(), reason.as_deref());
if let Some(found) = self.kept_find(key.as_deref(), &query).await {
tracing::info!("find answered from one approved earlier");

return Ok(found);
}
let (answer, answered) = oneshot::channel();
let waiting = Waiting { query, answer };

let opened = {
// one lock, so a batch cannot be taken for asking between the
// check and the push
let mut forming = self.find_batches().forming();
match forming.get(&reason).cloned() {
match forming.get(&key).cloned() {
Some(batch) => {
let mut queued = batch.waiting();
queued.push(waiting);
if queued.len() >= MAX_FIND_QUERIES {
forming.remove(&reason);
forming.remove(&key);
}

None
}
None => {
let batch = Arc::new(Forming(Mutex::new(vec![waiting])));
forming.insert(reason.clone(), Arc::clone(&batch));
forming.insert(key.clone(), Arc::clone(&batch));

Some(batch)
}
Expand All @@ -136,7 +157,7 @@ impl OnePassword {
// cannot strand the others that joined it
if let Some(batch) = opened {
let onepassword = self.clone();
tokio::spawn(async move { onepassword.ask_batch(&batch, reason).await });
tokio::spawn(async move { onepassword.ask_batch(&batch, key, reason).await });
}

answered.await.unwrap_or_else(|_| {
Expand All @@ -147,18 +168,18 @@ impl OnePassword {
}

/// Waits out the window, then asks once for every find that joined
/// `batch` and hands each its own items
async fn ask_batch(&self, batch: &Arc<Forming>, reason: Option<String>) {
/// `batch`, filed under `key`, and hands each its own items
async fn ask_batch(&self, batch: &Arc<Forming>, key: Option<String>, reason: Option<String>) {
tokio::time::sleep(FIND_BATCH_WINDOW).await;
let mut waiting = {
let mut forming = self.find_batches().forming();
// a full batch already left the map, and a newer one may hold
// its place
if forming
.get(&reason)
.get(&key)
.is_some_and(|current| Arc::ptr_eq(current, batch))
{
forming.remove(&reason);
forming.remove(&key);
}

std::mem::take(&mut *batch.waiting())
Expand All @@ -169,6 +190,9 @@ impl OnePassword {
let mut queries: Vec<String> = waiting.iter().map(|find| find.query.clone()).collect();
queries.sort();
queries.dedup();
// read before asking, so a forget while the prompt is up drops this
// answer instead of keeping it
let forgotten = self.forgotten().await;
// every caller giving up takes the prompts down, as a lone find's did
let all_gone = async {
for find in &mut waiting {
Expand All @@ -179,6 +203,18 @@ impl OnePassword {
answer = self.find_each(&queries, reason.as_deref()) => answer,
() = all_gone => return,
};
if let Ok((source, found)) = &answer {
let answers = queries.iter().zip(found).map(|(query, found)| {
let found = ProgramFound {
source: source.clone(),
found: found.clone(),
};

(query.clone(), found)
});
self.keep_finds(key.as_deref(), answers, FIND_KEEP, forgotten)
.await;
}

for find in waiting {
let own = match &answer {
Expand Down Expand Up @@ -309,6 +345,16 @@ impl OnePassword {
}
}

/// What finds are batched and kept under: the cleaned reason alone for this
/// machine, otherwise the scope and the reason on separate lines; a cleaned
/// reason holds no line break, so a scoped key never equals an unscoped one
fn batch_key(scope: Option<&str>, reason: Option<&str>) -> Option<String> {
match scope {
Some(scope) => Some(format!("{scope}\n{}", reason.unwrap_or(""))),
None => reason.map(str::to_owned),
}
}

impl Companions {
/// Asks the companion Mac `peer` to search for `find.query` and each of
/// `find.also`, answering their items in that order
Expand Down Expand Up @@ -368,3 +414,22 @@ impl Companions {
}
}
}

#[cfg(test)]
mod tests {
use super::{batch_key, clean_reason};

#[test]
fn no_reason_this_machine_claims_files_its_find_under_a_spokes_key() {
let spoke = batch_key(Some("spoke1"), clean_reason(Some("set up")).as_deref());
for claimed in [
"set up",
"spoke1\nset up",
"spoke1\r\nset up",
"spoke1: set up",
] {
let local = batch_key(None, clean_reason(Some(claimed)).as_deref());
assert_ne!(local, spoke, "{claimed:?}");
}
}
}
4 changes: 3 additions & 1 deletion src/env_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -340,7 +340,8 @@ impl SecretsServer {
one you want to `write_env`, which asks the owner again naming that item. \
The owner approves the search with Touch ID on this Mac or a companion Mac; \
to look for several things, start one call for each together under \
`asyncio.gather` with the same reason, and one prompt approves them all."
`asyncio.gather` with the same reason, and one prompt approves them all. \
The same query and reason is answered again for 10 minutes without asking."
)]
async fn find_references(
&self,
Expand All @@ -350,6 +351,7 @@ impl SecretsServer {
let ask = FindAsk {
query: params.query,
reason: Some(params.reason),
scope: None,
};
let found = tokio::select! {
found = self.0.onepassword.find_for_program(&ask) => found.map_err(failed)?,
Expand Down
10 changes: 7 additions & 3 deletions src/guidance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -69,9 +69,13 @@ macro_rules! onepassword_rule {
`codemode-mcp op find WORDS --reason WHY` (or the `secrets` server's `find_references` \
tool) lists matching items' op:// references and types, never values, after the user \
approves on a Mac; then use the reference you picked. A query matches items holding any of \
its words, up to 80 characters and 10 items, so to look for several things, start one \
`find_references` call for each together under `asyncio.gather` with the same `reason`: \
the user approves them all with one prompt. Or pass a description in one step: \
its words, up to 80 characters and 10 items, so put other names for the same thing in one \
query. To look for several things, pass each extra query as `--also WORDS` to one `op \
find`, or start one `find_references` call for each together under `asyncio.gather` with \
the same `reason`: the user approves them all with one prompt. Never run separate finds \
one after another, since each asks the user again. The same query and reason is answered \
again for 10 minutes without asking, so rerun it rather than rewording it if you misread \
the output. Or pass a description in one step: \
`codemode-mcp op read --describe \"Apple signing cert\" --out-file PATH` or `write_env`'s \
`describe`. If the user approves on a Mac it searches instead, writes nothing and answers the \
matches (`op read` exits 3), so run again with the exact reference; if they approve on their \
Expand Down
2 changes: 2 additions & 0 deletions src/hub/server.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1014,6 +1014,8 @@ async fn op_find(
.find_for_program(&FindAsk {
query: find.query,
reason,
// spokes that share a name never share a batch or kept answers
scope: Some(spoke.id.clone()),
})
.await;

Expand Down
Loading
Loading