From 44d4a4bb0cfa79c8d827ea48ee29f307e0c09c49 Mon Sep 17 00:00:00 2001 From: Nishad Date: Tue, 18 Aug 2026 17:48:43 -0700 Subject: [PATCH 1/4] Use secrets in conversations --- README.md | 18 +++++++++++ SPEC.md | 19 +++--------- crates/caos-cli/src/lib.rs | 58 +++++++++++++++++++++-------------- crates/caos/src/eval.rs | 16 ++++++++-- crates/caos/src/lib.rs | 34 ++++++++++++++++++-- design/agent-harness.md | 10 ++++-- std/llm-call/src/main.rs | 7 +++-- std/llm-step/src/main.rs | 7 +++-- std/llm-test/common.sh | 10 +++++- tests/caos-tools/cli.sh | 10 +++++- tests/chat-offline/cli.sh | 37 +++++++++++++++++++--- tests/chat-online/cli.sh | 9 ++++++ tests/chat-tools-grep/cli.sh | 1 - tests/chat-tools-mixed/cli.sh | 1 - tests/chat-tools/cli.sh | 1 - tests/llm-call/cli.sh | 10 +++++- tests/max-tokens/cli.sh | 10 +++++- tests/merge-harness/cli.sh | 10 +++++- 18 files changed, 203 insertions(+), 65 deletions(-) diff --git a/README.md b/README.md index 3d0b5407..ef695159 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,24 @@ the object machinery through a one-way dependency. Their difference is the missing `entropy=`, warn on a weak one (`--check` reports only and exits non-zero, for CI). Offline — no server (design/secrets.md). +Conversations read their model credential from that local store rather than +putting it in a curried worker. A minimal per-device setup is: + +```text +# .gitignore +.caos-secrets/ + +# .caos-secrets/anthropic-api-key +name=anthropic-api-key +value:@=/absolute/path/to/anthropic-api-key +reader=DEEP-DEPS/llm-step +reader=DEEP-DEPS/llm-call +``` + +Run `caos-cli secrets` once to add the random `entropy=` used for cache +isolation. The file and value path stay local; only the entropy-derived identity +enters an ArgTree, while the value is carried out of band for the run. + `caos-cli` must run inside a git working tree with the server as its `caos` remote — the remote's URL is also where compute is triggered and results are fetched, so there is nothing else to configure: diff --git a/SPEC.md b/SPEC.md index 63323bf7..b9281f6a 100644 --- a/SPEC.md +++ b/SPEC.md @@ -125,8 +125,11 @@ credential's destination or permitted repository belong beside the credential; requiring a wrapper would make that policy repository-owned and needlessly proliferate expression directories. -What remains is not about the eval path: the agent harness carries no store, -and `value:@=` is UTF-8 only. See "Remaining work". +The agent harness carries the same store: conversation preparation resolves +`llm-step` with it, the admitted request includes the resulting isolation +identity, and foreground or recovery dispatches send the store out of band. +Both conversation LLM workers read `anthropic-api-key` from `/secret`, never +from a curried arg. `value:@=` remains UTF-8 only; see "Remaining work". ## Problem @@ -198,18 +201,6 @@ Note that this means that the server sees all secrets. We can revisit if this be ## Remaining work -- **The agent harness carries no store.** `caos talk`/`chat` (`chat.rs`'s - `turn` and `generate_conversation_title`) pass an empty store and an empty - header, so an agent turn — and every tool it invokes as a sub-run — is granted - nothing. This is where the note's own motivating example lives (an agent - reaching for github-push), so it is a hole, not a boundary. It was never - decided: the `&[]` is what the parameter-threading left behind. Filling it is - one call (`build_secret_store` before `prepare_request`, its header on - `request_compute`), but it is a **policy** choice first: a store-carrying turn - is per-user keyed via `secret-hash`, so every chat that matches a reader stops - sharing cache with other users. Worth deciding explicitly rather than by - default. - - **Binary `value:@=`.** Read but kept UTF-8 (binary/multiline later). - **Shared-server exposure.** Carrying the whole store means a shared server diff --git a/crates/caos-cli/src/lib.rs b/crates/caos-cli/src/lib.rs index 806f6d5b..95d877ca 100644 --- a/crates/caos-cli/src/lib.rs +++ b/crates/caos-cli/src/lib.rs @@ -16,15 +16,16 @@ use std::time::{Duration, Instant}; use serde_json::{json, Value}; use caos::{ - compute_client_request, curry_client_object, eval_workspace_dep, prepare_client_request, - GitTransport, Transport, CAOS_REMOTE, + build_secret_store, compute_client_request_with_store, curry_client_object, + eval_workspace_dep_with_store, prepare_client_request_with_store, ClientSecret, GitTransport, + Transport, CAOS_REMOTE, }; const CONVERSATION_PREFIX: &str = "refs/caos/v2/conversations/"; const HEAD_SUFFIX: &str = "/head"; const MAX_CONVERSATION_ID_BYTES: usize = 124; const MAX_APPEND_ATTEMPTS: usize = 32; -const API_KEY_ENV: &str = "ANTHROPIC_API_KEY"; +const MODEL_API_SECRET: &str = "anthropic-api-key"; const AUTO_NAME_PREFIX: &str = "talk-"; const MERGE_REF_CANDIDATES: &[&str] = &["main", "master", "origin/main", "origin/master"]; pub const DEFAULT_MODEL: &str = "claude-opus-4-8"; @@ -962,8 +963,10 @@ pub fn prepare_queued_request( queued_head: &str, ) -> Result { validate_hash(queued_head, "queued conversation head")?; - let llm = resolve_llm(t, options, id)?; - prepare_client_request(t, &llm, &[format!("--head:commit={queued_head}")]) + let store = build_secret_store(t)?; + require_model_secret(&store)?; + let llm = resolve_llm(t, options, id, &store)?; + prepare_client_request_with_store(t, &llm, &[format!("--head:commit={queued_head}")], &store) } /// Resolve the human-facing identity once per client. `author` remains @@ -1044,9 +1047,12 @@ fn unsafe_username_character(character: char) -> bool { ) } -fn resolve_llm(t: &GitTransport, options: &TurnOptions, id: &str) -> Result { - let api_key = std::env::var(API_KEY_ENV) - .map_err(|_| format!("{API_KEY_ENV} must be set to start a conversation request"))?; +fn resolve_llm( + t: &GitTransport, + options: &TurnOptions, + id: &str, + store: &[ClientSecret], +) -> Result { let system = match (&options.system, &options.system_file) { (Some(system), None) => system.clone(), (None, Some(path)) => std::fs::read_to_string(path) @@ -1057,11 +1063,7 @@ fn resolve_llm(t: &GitTransport, options: &TurnOptions, id: &str) -> Result Result Result<(), String> { + if store.iter().any(|secret| secret.name() == MODEL_API_SECRET) { + return Ok(()); + } + Err(format!( + "conversation needs a {MODEL_API_SECRET:?} secret in .caos-secrets" + )) +} + fn request_is_active(status: &str) -> bool { matches!(status, "queued" | "running") } @@ -1084,8 +1095,9 @@ fn request_is_active(status: &str) -> bool { /// conversation state; `llm-step` advances the canonical head itself. pub fn resume_request(t: &GitTransport, request: &str) -> Result<(), String> { validate_hash(request, "request")?; + let store = build_secret_store(t)?; let server = t.server_url()?; - compute_client_request(&server, request).map(|_| ()) + compute_client_request_with_store(&server, request, &store).map(|_| ()) } /// Reissue the exact request recorded by a nonterminal conversation. Repeated @@ -1959,10 +1971,11 @@ pub fn run_chat_turn( }); emit(TurnEvent::PhaseStarted(TurnPhase::Model)); emit(TurnEvent::Status("waiting for agent".to_string())); + let store = build_secret_store(t)?; let server = t.server_url()?; let (tx, rx) = std::sync::mpsc::channel(); std::thread::spawn(move || { - let result = compute_client_request(&server, &request).map(|_| ()); + let result = compute_client_request_with_store(&server, &request, &store).map(|_| ()); let _ = tx.send(result); }); request_result = Some(rx); @@ -2022,14 +2035,13 @@ pub fn generate_conversation_title( options: &TurnOptions, first_message: &str, ) -> Result { - let api_key = std::env::var(API_KEY_ENV).map_err(|_| { - format!("{API_KEY_ENV} must be set (it rides, curried, into the title run)") - })?; - let mut kvs = vec![format!("--api-key={api_key}")]; + let store = build_secret_store(t)?; + require_model_secret(&store)?; + let mut kvs = Vec::new(); if let Some(url) = &options.base_url { kvs.push(format!("--base-url={url}")); } - let llm_base = eval_workspace_dep(t, "llm-call")?; + let llm_base = eval_workspace_dep_with_store(t, "llm-call", &store)?; let llm = curry_client_object(t, &llm_base, &kvs)?.to_string(); let messages = serde_json::to_string(&title_messages(first_message)) .map_err(|error| format!("encoding title context: {error}"))?; @@ -2042,8 +2054,8 @@ pub fn generate_conversation_title( "--model={}", options.model.as_deref().unwrap_or(DEFAULT_MODEL) )); - let arg_tree = prepare_client_request(t, &llm, &call)?; - let (kind, hash) = compute_client_request(&t.server_url()?, &arg_tree)?; + let arg_tree = prepare_client_request_with_store(t, &llm, &call, &store)?; + let (kind, hash) = compute_client_request_with_store(&t.server_url()?, &arg_tree, &store)?; if kind != "blob" { return Err(format!( "conversation title run returned a {kind}, expected a blob" diff --git a/crates/caos/src/eval.rs b/crates/caos/src/eval.rs index 8a4b863a..0a04cfaa 100644 --- a/crates/caos/src/eval.rs +++ b/crates/caos/src/eval.rs @@ -132,12 +132,22 @@ static EVAL_NODE_MEMO: Memo<(String, String)> = Memo::new(); /// the DEEPENED tree — so resolving the raw `std/llm-step` directory out of the /// worktree cannot work, whatever the path is spelled. pub fn eval_workspace_dep(t: &dyn Transport, name: &str) -> Result { + eval_workspace_dep_with_store(t, name, &[]) +} + +/// Resolve a workspace entry point while carrying the caller's secret store +/// through expression evaluation. Conversation setup uses this form so a tool +/// embedded by the llm-step expression keeps its secret-dependent identity in +/// the enclosing turn request. +pub fn eval_workspace_dep_with_store( + t: &dyn Transport, + name: &str, + store: &[ClientSecret], +) -> Result { let (_, oid) = t .ingest_path(".")? .ok_or_else(|| "this client cannot ingest the workspace tree".to_string())?; - // Entry-point resolution feeds `assemble_arg_tree` (which marks the run) or - // a reader match, so it carries no store of its own — no marking here. - eval_path(t, &oid.to_string(), &format!("DEEP-DEPS/{name}"), &[]) + eval_path(t, &oid.to_string(), &format!("DEEP-DEPS/{name}"), store) .map(|(_kind, hash)| hash) .map_err(|error| workspace_dep_error(name, &error)) } diff --git a/crates/caos/src/lib.rs b/crates/caos/src/lib.rs index bc434efc..3b48444a 100644 --- a/crates/caos/src/lib.rs +++ b/crates/caos/src/lib.rs @@ -32,7 +32,7 @@ use std::time::{Duration, SystemTime, UNIX_EPOCH}; use gix::objs::WriteTo; mod eval; -pub use eval::{cli_eval_path, eval_workspace_dep}; +pub use eval::{cli_eval_path, eval_workspace_dep, eval_workspace_dep_with_store}; /// `run-tool