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
6 changes: 5 additions & 1 deletion docs/design.md
Original file line number Diff line number Diff line change
Expand Up @@ -279,7 +279,11 @@ approved wherever the owner is
the owner was approving. References are deduplicated, so a template asks once per item
- A find (`op find`, `/admin/op/find`, `find_references`; `src/companion/find.rs`) is raced like a program read
between this Mac, after its own Touch ID prompt, and the companion Macs, sent as `{"find": {"query",
"also", "reason"}}`. A companion from before finds answers `malformed request`. Phones are not asked. Finds
"also", "reason"}}`. A companion from before finds answers `malformed request`. Phones are not asked, and a find
no Mac answers within `FIND_WAIT` (30 s, not `ASK_TIMEOUT`) fails with `PHONE_HINT`, since a companion Mac is
asked whether or not the owner is at it. A find with a destination (`op find --out-file`, `find_references`
with `env_file` and `var`) is not a find on the daemon: it is a program read of one `Describe` target, so the
phone answers it with an entered value and a Mac with its matches. Finds
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
Expand Down
19 changes: 16 additions & 3 deletions docs/operations.md
Original file line number Diff line number Diff line change
Expand Up @@ -218,9 +218,22 @@ A program that does not know the exact reference can look it up first:
with its fields' and attached files' references, labels and types, and never a value. Words are matched, ignoring
case, in item titles, vault names, tags and categories, and at most 10 items are listed, best first, with `more`
counting the rest. You approve the search with Touch ID on this Mac while you are there, or on a companion Mac;
the prompt quotes the words and the reason. The phone cannot search 1Password, so it is not asked. Reading the
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
the prompt quotes the words and the reason. Reading the reference picked afterwards asks again and names that
item. Scripts can do the same with the `secrets` server's `find_references(query, reason)`. A program that only
needs the value written is better off describing the item to `op read --describe` or `write_env`, which reaches
your phone too.

The phone cannot search 1Password, so a plain find is not sent to it, and a find that no Mac answers within 30
seconds fails with an error that points at the phone. A companion Mac is asked whether or not you are at it, so
this keeps a find from waiting the full 5 minutes while you are away. Give the find a destination and the phone is
asked as well: `codemode-mcp op find pushover token --out-file token.txt` (one query, no `--also`), or
`find_references` with `env_file` and `var`. A Mac that answers first prints the matches and writes nothing, as
before. On the phone you enter the value the words describe, or pick it with AutoFill, and it is written there;
`op find` then prints `{"written": PATH}` and `find_references` answers `written` with the path and variable and
no items. As with `op read`, text gets a trailing newline; pass `--no-newline` to keep a text file shared from
1Password byte for byte.

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. 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
Expand Down
28 changes: 22 additions & 6 deletions src/companion/find.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,17 @@ const CANCEL_TITLE: &str = "Deny";
/// milliseconds
const FIND_BATCH_WINDOW: Duration = Duration::from_millis(250);

/// How long a find waits for a Mac to answer; the owner at a Mac approves
/// within seconds, and one away from every Mac is better asked on the phone
/// than kept waiting the whole [`ASK_TIMEOUT`]
const FIND_WAIT: Duration = Duration::from_secs(30);

/// Added to the error of a find no Mac answered, so the program asks the
/// phone next
const PHONE_HINT: &str = "no Mac answered, so ask on the owner's phone instead, where they enter \
the value: `codemode-mcp op find WORDS --out-file PATH`, or `find_references` with \
`env_file` and `var`";

/// 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);
Expand Down Expand Up @@ -252,11 +263,9 @@ impl OnePassword {
.map(|companions| companions.peers())
.unwrap_or_default();
if !local && peers.is_empty() {
return Err(ProgramReadError::Failed(
"nobody can approve this: the owner is away from this Mac and no companion Mac is \
paired; a phone cannot search 1Password"
.to_owned(),
));
return Err(ProgramReadError::Failed(format!(
"the owner is away from this Mac and no companion Mac is paired; {PHONE_HINT}"
)));
}

let mut sources: JoinSet<(String, Result<Vec<Found>, Miss>)> = JoinSet::new();
Expand Down Expand Up @@ -304,7 +313,14 @@ impl OnePassword {
}

// dropping the set on any exit takes the other prompts down
first_answer(&mut sources, ASK_TIMEOUT + grace, "find").await
first_answer(&mut sources, FIND_WAIT + grace, "find")
.await
.map_err(|error| match error {
ProgramReadError::Failed(message) => {
ProgramReadError::Failed(format!("{message}; {PHONE_HINT}"))
}
other => other,
})
}

/// Asks on this Mac: its own Touch ID prompt naming the query, then the
Expand Down
69 changes: 63 additions & 6 deletions src/env_files.rs
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,15 @@ pub struct FindReferencesParams {
/// Why the program needs to know; every prompt shows it. Calls started
/// together under `asyncio.gather` with the same reason share one prompt
pub reason: String,
/// Absolute path of an env file, as `write_env` takes it, for when the
/// owner answers on their phone: they enter the value the query
/// describes and it is written there as `var`. A Mac that answers first
/// lists the matches and writes nothing. Give with `var`
#[serde(default)]
pub env_file: Option<String>,
/// The variable the entered value is written as; give with `env_file`
#[serde(default)]
pub var: Option<String>,
}

/// The answer to `find_references`; never holds a value
Expand All @@ -99,6 +108,19 @@ pub struct ReferencesFound {
pub more: usize,
/// Where the owner approved
pub approved_on: String,
/// Set when the owner entered the value on their phone, in which case
/// `items` is empty
#[serde(skip_serializing_if = "Option::is_none")]
pub written: Option<VarWritten>,
}

/// The variable `find_references` wrote when the owner entered its value
#[derive(Debug, Serialize, schemars::JsonSchema)]
pub struct VarWritten {
/// The env file written
pub path: String,
/// The variable whose line was written
pub var: String,
}

/// One item `find_references` matched
Expand Down Expand Up @@ -336,18 +358,52 @@ impl SecretsServer {
#[tool(
description = "Lists the 1Password items whose title, vault, tags or category match the \
query, with each field's and attached file's op:// reference and type but \
no values. Use it when you do not know the exact reference, then pass the \
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. \
The same query and reason is answered again for 10 minutes without asking."
no values. Use it when you need the reference itself, such as for a \
template; when you only need the value in an env file, call `write_env` \
with `describe` instead. The owner approves the search with Touch ID on \
this Mac or a companion Mac; the phone cannot search, so without \
`env_file` and `var` a find no Mac answers within 30 seconds fails. With \
them the phone is asked too, and the value the owner enters there is \
written as `var`, answering `written` with no items. To look for several \
things, start one call for each together under `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,
Parameters(params): Parameters<FindReferencesParams>,
context: RequestContext<RoleServer>,
) -> Result<Json<ReferencesFound>, CallToolResult> {
match (params.env_file, params.var) {
(Some(path), Some(var)) => {
// the query is the description `write_env` sends: a Mac
// searches for it, the phone has the owner enter the value
let write = WriteEnvParams {
path,
vars: BTreeMap::new(),
describe: BTreeMap::from([(var.clone(), params.query)]),
reason: params.reason,
keep_minutes: None,
};
let Json(written) = self.write_env(Parameters(write), context).await?;

return Ok(Json(match written.found {
Some(found) => found,
None => ReferencesFound {
items: Vec::new(),
more: 0,
approved_on: written.approved_on,
written: Some(VarWritten {
path: written.path,
var,
}),
},
}));
}
(None, None) => {}
_ => return Err(failed("give env_file and var together")),
}

let ask = FindAsk {
query: params.query,
reason: Some(params.reason),
Expand Down Expand Up @@ -395,6 +451,7 @@ fn references_found(found: ProgramFound) -> ReferencesFound {
.collect(),
more: found.found.more,
approved_on: found.source,
written: None,
}
}

Expand Down
24 changes: 14 additions & 10 deletions src/guidance.rs
Original file line number Diff line number Diff line change
Expand Up @@ -65,21 +65,25 @@ macro_rules! onepassword_rule {
write only its op:// reference into the env file and start the program with `codemode-mcp op \
run --env-file FILE --reason WHY -- COMMAND`, which puts the value in that command's \
environment only and masks it in its output. `codemode-mcp op read` prints values, so use it \
only when nothing else will do. When you do not know the exact reference, find it first: \
only when nothing else will do. When you do not know the exact reference and only need the \
value written, pass a description instead, which also reaches the user's phone: \
`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 \
phone they pick or enter the item, file attachments included, and it is written. Find a \
reference only when you need the reference itself, such as for a template: \
`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 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 \
tool) lists matching items' op:// references and types, never values. Only a Mac can \
search, so a find no Mac answers within 30 seconds fails; give it a destination \
(`--out-file PATH`, or `find_references`'s `env_file` and `var`) and the user can enter \
the value on their phone instead, answered as `written`. A query matches items holding any \
of 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 \
phone they pick the item, file attachments included, and it is written. A file attached to an item, such as a `.p12`, is \
the output. A file attached to an item, such as a `.p12`, is \
not text: write it with `codemode-mcp op read REFERENCE --out-file PATH`, which keeps its \
exact bytes, since env files, templates and `op run` take text only. The user approves each batch once, with Touch ID on \
this Mac or on their phone or other Mac, and sees the file it goes to; add `--keep 15m` (or \
Expand Down
103 changes: 103 additions & 0 deletions src/main.rs
Original file line number Diff line number Diff line change
Expand Up @@ -429,6 +429,12 @@ enum OpCommand {
/// the JSON printed is then a list of each query with its items. The same
/// query with the same reason is answered again for 10 minutes after an
/// approval without asking
///
/// The phone cannot search 1Password, so without `--out-file` a find
/// that no Mac answers within 30 seconds fails. With `--out-file` the
/// phone is asked too: the owner enters the value the words describe and
/// it is written there, printing `{"written": PATH}`; a Mac that answers
/// first prints the matches as usual and writes nothing
Find {
/// Words to look for, such as `apple signing`; an item matching any
/// of them is listed
Expand All @@ -441,6 +447,20 @@ enum OpCommand {
/// Why the program is looking; every prompt shows it
#[arg(long)]
reason: Option<String>,
/// Where the value goes when the owner enters it on the phone,
/// created with `--file-mode`; takes one query, without `--also`
#[arg(short = 'o', long, conflicts_with = "also")]
out_file: Option<PathBuf>,
/// Replace `--out-file` if it exists
#[arg(short, long, requires = "out_file")]
force: bool,
/// Mode of `--out-file`, in octal
#[arg(long, default_value = "0600", value_parser = parse_mode)]
file_mode: u32,
/// Write an entered text value without a trailing newline, such as
/// a text file shared from 1Password that must stay byte for byte
#[arg(short, long, requires = "out_file")]
no_newline: bool,
},
/// Fill each `{{ op://… }}` in a template, like `op inject`, with one
/// approval for the whole template
Expand Down Expand Up @@ -2274,10 +2294,28 @@ async fn op_command(config: &Config, command: OpCommand) -> Result<()> {
}
}
}
// clap keeps `--also` out of this one
OpCommand::Find {
query,
reason,
out_file: Some(out_file),
force,
file_mode,
no_newline,
..
} => {
let query = query.join(" ");
find_or_enter(
config, query, reason, out_file, force, file_mode, no_newline,
)
.await?
}
OpCommand::Find {
query,
also,
reason,
out_file: None,
..
} => {
let several = !also.is_empty();
let queries: Vec<String> = std::iter::once(query.join(" ")).chain(also).collect();
Expand Down Expand Up @@ -2540,6 +2578,71 @@ async fn op_run(
Ok(exit_code(status))
}

/// `op find --out-file`: the query goes out as a description, so a Mac
/// searches for it and the phone has the owner enter the value, which is
/// written to `out_file`
async fn find_or_enter(
config: &Config,
query: String,
reason: Option<String>,
out_file: PathBuf,
force: bool,
file_mode: u32,
no_newline: bool,
) -> Result<()> {
companion::checked_query(&query).map_err(|message| eyre!("refused: {message}"))?;
// the file written is the one the prompt names
let out_file = resolved_path(&out_file);
op::check_writable(&out_file, force)?;

eprintln!(
"Asking the owner to approve a 1Password search on a Mac, or to enter the value on \
the phone..."
);
let request = admin::OpReadRequest {
targets: vec![ProgramTarget::Describe(query)],
reason,
destination: Some(out_file.display().to_string()),
keep_secs: None,
};
let response: admin::OpReadResponse =
admin_post_as_owner(config, "/admin/op/read", &request).await?;
let printed = match response {
admin::OpReadResponse::Values { source, values } => {
let mut contents = values
.into_iter()
.next()
.ok_or_else(|| eyre!("the daemon sent no value"))?
.into_bytes();
// binary is written exactly as 1Password holds it; text gets
// a newline, as `op read` gives it, unless `--no-newline`
if !no_newline && std::str::from_utf8(&contents).is_ok() {
contents.push(b'\n');
}
op::write_private(&out_file, &contents, file_mode, force)
.wrap_err_with(|| format!("cannot write {}", out_file.display()))?;
Comment thread
coderabbitai[bot] marked this conversation as resolved.
Resolved
eprintln!("Entered on {source}");

serde_json::json!({ "written": out_file.display().to_string() })
}
admin::OpReadResponse::Found {
source,
items,
more,
} => {
eprintln!("Approved on {source}; nothing was written");

serde_json::json!({ "items": items, "more": more })
}
admin::OpReadResponse::Invalid { message } => bail!("refused: {message}"),
admin::OpReadResponse::Denied { message } => bail!("denied: {message}"),
admin::OpReadResponse::Failed { message } => bail!("no answer: {message}"),
};
println!("{}", serde_json::to_string_pretty(&printed)?);

Ok(())
}

/// Exit code of `op read --describe` when a Mac searched instead of reading
const FOUND_EXIT: i32 = 3;

Expand Down
Loading
Loading