diff --git a/README.md b/README.md index 3d0b5407..b3603362 100644 --- a/README.md +++ b/README.md @@ -319,6 +319,27 @@ 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=--base:@=DEEP-DEPS/llm-step --base-url=https://api.anthropic.com +reader=--base:@=DEEP-DEPS/llm-call --base-url=https://api.anthropic.com +``` + +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. Pinning +`base-url` beside the model credential prevents a worker configured for another +destination from receiving that credential; a custom endpoint remains supported +when the local reader pins that exact URL. + `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..9df3bcb5 100644 --- a/crates/caos-cli/src/lib.rs +++ b/crates/caos-cli/src/lib.rs @@ -16,18 +16,20 @@ 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, + run_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"; +const DEFAULT_BASE_URL: &str = "https://api.anthropic.com"; const DEFAULT_SYSTEM: &str = "You are a coding agent operating on a git workspace. Use the \ available tools for file access, builds, tests, and edits. Keep responses concise."; @@ -962,8 +964,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 +1048,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 +1064,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 +1097,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 +1973,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 +2037,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}")]; - if let Some(url) = &options.base_url { - kvs.push(format!("--base-url={url}")); - } - let llm_base = eval_workspace_dep(t, "llm-call")?; + let store = build_secret_store(t)?; + require_model_secret(&store)?; + let kvs = vec![format!( + "--base-url={}", + options.base_url.as_deref().unwrap_or(DEFAULT_BASE_URL) + )]; + 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 +2056,7 @@ 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 (kind, hash) = run_client_request_with_store(t, &llm, &call, &store)?; if kind != "blob" { return Err(format!( "conversation title run returned a {kind}, expected a blob" diff --git a/crates/caos/src/bin/caos.rs b/crates/caos/src/bin/caos.rs index 0fc61ba2..d0601f80 100644 --- a/crates/caos/src/bin/caos.rs +++ b/crates/caos/src/bin/caos.rs @@ -155,6 +155,9 @@ struct RunnerJob { /// Secrets the server granted this job (design/secrets.md): name → value. /// Dropped at `/secret/` for the worker, out of band from its args. secrets: Vec<(String, String)>, + /// Grants this job was already entitled to, serialized for an explicit + /// detached `run-async` launched by the worker. + delegated_secrets: String, } impl RunnerJob { @@ -188,6 +191,7 @@ impl RunnerJob { nonce, token: field("token").map(str::to_string), secrets, + delegated_secrets: field("delegated_secrets").unwrap_or_default().to_string(), }) } } @@ -243,10 +247,12 @@ fn run_runner_job( // first, so a warm runner never leaks a secret into a later job that wasn't // granted it. write_secrets(&job.secrets)?; + write_delegated_secrets(&job.delegated_secrets)?; let ran = run_worker(&envs, &job.secrets); // Remove the secrets whether the worker passed or failed; the next job's // `write_secrets` also wipes, but don't leave plaintext around meanwhile. remove_secrets(); + remove_delegated_secrets(); ran?; let result = read_result(&cas)?; remove_cas(&cas)?; @@ -288,6 +294,30 @@ fn remove_secrets() { let _ = std::fs::remove_dir_all(SECRET_DIR); } +/// Write the serialized grants where only the root runner and the setuid +/// `caos run-async` client can read them. The ordinary worker already receives +/// each granted value through `/secret/`; it does not need this metadata. +fn write_delegated_secrets(secrets: &str) -> Result<(), String> { + remove_delegated_secrets(); + if secrets.is_empty() { + return Ok(()); + } + let path = std::path::Path::new(caos::DELEGATED_SECRETS_PATH); + let parent = path + .parent() + .ok_or("delegated secrets path has no parent")?; + std::fs::create_dir_all(parent) + .map_err(|error| format!("creating {}: {error}", parent.display()))?; + std::fs::write(path, secrets) + .map_err(|error| format!("writing {}: {error}", path.display()))?; + std::fs::set_permissions(path, std::fs::Permissions::from_mode(0o400)) + .map_err(|error| format!("chmod {}: {error}", path.display())) +} + +fn remove_delegated_secrets() { + let _ = std::fs::remove_file(caos::DELEGATED_SECRETS_PATH); +} + /// Unpack an ArgTree: its hash (returned back for `/cas/args`) and the salt /// (its reserved `salt` entry, empty if absent). `base`/`salt` are entries of /// this one tree, per SPEC's ArgTree. 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..cc209ff6 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