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
5 changes: 5 additions & 0 deletions .githooks/pre-push
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,11 @@
# Enable once per clone:
# git config core.hooksPath .githooks
#
# Data leaves the machine: with a TypeSafe or OpenRouter key set, the pushed
# diff and commit message are sent to that provider on every push. Only a local
# kev on 127.0.0.1:8009 keeps them here. Do not enable this on a repository whose
# code may not go to a third party.
#
# git hands pre-push "<local ref> <local sha> <remote ref> <remote sha>" lines
# on stdin. The push is read from the first line; a delete (all-zero local) and
# a run with nothing on stdin are both nothing to decide about.
Expand Down
21 changes: 13 additions & 8 deletions PROTOCOL.md
Original file line number Diff line number Diff line change
Expand Up @@ -51,11 +51,13 @@ two-channel contract. `--no-tools` forces the blind case, and then the prompt
carries one extra sentence: the context is empty, answer from what you know, and
never cite a file or line you were not given.

Neither mode is a sandbox: clank runs with your permissions and `read_file`
reaches any path you can read — an absolute path, `~/.ssh`, `/etc`. `--tools`
bounds what the model can *do* (observe, nothing else), not what it can *reach*.
The pipe bounds the input; for containment, run clank as a user that cannot read
what you are protecting.
The tools are confined to the working directory: every path must resolve,
symlinks followed, to it or below it, so `~/.ssh`, `/etc` and `../` are refused
as a tool error the model sees. Evidence from anywhere else is piped in by the
shell. `read_file` reads regular files of at most 4 MB. This is still not a
sandbox: clank runs with your permissions, and the working directory is whatever
you started it in. For containment, run clank as a user that cannot read what
you are protecting.

## The request sequence

Expand Down Expand Up @@ -271,16 +273,19 @@ same reasons the decision stage is not a flag:
| R1 composition | the shell already composes a search with a summary | `clank-web "query" \| clank -m "summarize with citations"` is the whole feature |
| invariant 3 | a tool round would read the network on an input the pipe cannot show | the query is the invocation, and the results are what the next stage reads |
| invariant 4 | clank's network is the model endpoint | the search APIs live in the binary whose job is those APIs |
| R4 no memory | a search key would become a clank tool setting | `.clank/config.toml` is optional and shared. `clank` and `clank-jev` read `[clank]`. `clank-web` reads `[web]`. A missing file in the working directory leaves flags and the environment in charge |
| R4 no memory | a search key would become a clank tool setting | `.clank/config.toml` is optional and shared. `clank` reads `[clank]`, `clank-jev` only `[clank].timeout`. `clank-web` reads `[web]`. A missing file in the working directory leaves flags and the environment in charge |

Discovery is the working directory. Each binary loads `./.clank/config.toml`
when that file exists, and does not search parent directories. `--config PATH`
names a file. `CLANK_CONFIG` names one when the flag is absent. An explicit path
that is missing is usage (exit `2` on `clank`, `clank-jev`, and `clank-web`). A
missing `./.clank/config.toml` is not an error. Value precedence stays flags,
then the environment, then the file, then built-ins. The key is the environment
variable named by `api_key_env`. The sample at `fixtures/clank.config.toml`
names variables and does not carry a key.
variable named by `api_key_env`. A file found in the working directory came with
whatever was cloned there, so it may name only each section's own variable
(`CLANK_API_KEY`, `BRAVE_API_KEY`, `TAVILY_API_KEY`); any other name is usage
unless the file was named with `--config` or `CLANK_CONFIG`. The sample at
`fixtures/clank.config.toml` names variables and does not carry a key.

`clank --list-tools` stays the four read-only filesystem observers. One
invocation is one request: no crawl, no JavaScript, no second fetch of a link
Expand Down
5 changes: 3 additions & 2 deletions STATUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -59,8 +59,9 @@ says where the project is, what is open, and how to check any of it.
`typesafe`, `openrouter`, `kev`. `clank-web`: one search or one fetch,
providers `brave` (the default) and `tavily`. Optional `./.clank/config.toml`
in the working directory (`--config` or `CLANK_CONFIG` to name another file):
`[clank]` is the model and endpoint for `clank` and `clank-jev`; `[web]` is
search settings for `clank-web`. No built-in chat model or base URL.
`[clank]` is the model and endpoint for `clank` (`clank-jev` reads only its
`timeout`); `[web]` is search settings for `clank-web`. No built-in chat model
or base URL.
- 121 tests (`cargo test`), all against stub servers: no model, no key, no
network. `tests/wire.rs` (24, the protocol), `tests/jev.rs` (10, the decision
stage), `tests/jev_ci_stub.rs` (1), `tests/web.rs` (10, the search stage),
Expand Down
16 changes: 8 additions & 8 deletions docs/clank-jev.md
Original file line number Diff line number Diff line change
Expand Up @@ -31,14 +31,14 @@ Credentials come from the environment, never from argv:
| `kev` | `127.0.0.1:8009/v1/systemone` (`--base-url` to move it) | none | `kev-latest` |

`./.clank/config.toml` is optional, and it is read from the working directory
only. `--config PATH` or `CLANK_CONFIG` names a different file.
`[clank].base_url` and `[clank].model` apply when `--base-url` and `--model`
were not passed, and they replace the provider built-ins above. `--timeout` wins
over `[clank].timeout`, which wins over 60 seconds. Provider credentials stay
the variables in the table; `[clank].api_key_env` names the fallback variable
when those are unset. `[web]` is search configuration for `clank-web`. A missing
file in the working directory leaves the flags and the provider environment in
charge. `CLANK_MODEL` and `CLANK_BASE_URL` belong to `clank`.
only. `--config PATH` or `CLANK_CONFIG` names a different file. `--timeout` wins
over `[clank].timeout`, which wins over 60 seconds. That is the only key
`clank-jev` reads: the endpoint and model are `--base-url` and `--model`,
otherwise the provider built-ins above, and credentials are only the variables
in the table. The rest of `[clank]` is the chat endpoint for `clank`, and a file
that could name the Jev endpoint would also choose where the Jev key is sent.
`[web]` is search configuration for `clank-web`. `CLANK_MODEL` and
`CLANK_BASE_URL` belong to `clank`.

`kev` is a local System One server; [kev](https://github.com/jaredpalmer/kev) is
a trained Jev-family model (LoRA + pointer readout head on Qwen, one prefill
Expand Down
12 changes: 6 additions & 6 deletions docs/clank-web.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,10 +46,10 @@ flag is absent. A path given that way has to exist. A missing
`./.clank/config.toml` leaves flags and the environment in charge.

Precedence is flags, then the environment, then the file, then built-ins.
`clank` and `clank-jev` read `[clank]` (model, endpoint, timeout, token cap).
`clank-web` reads `[web]` and leaves `[clank]` alone, so a chat `base_url` is
not a search endpoint. `[web]` is not required: a file that only names a Brave
key does not change `clank` or `clank-jev`.
`clank` reads `[clank]` (model, endpoint, timeout, token cap); `clank-jev` reads
only `[clank].timeout`. `clank-web` reads `[web]` and leaves `[clank]` alone, so
a chat `base_url` is not a search endpoint. `[web]` is not required: a file that
only names a Brave key does not change `clank` or `clank-jev`.

The sample is [`fixtures/clank.config.toml`](../fixtures/clank.config.toml). It
names environment variables. It does not contain a key.
Expand Down Expand Up @@ -86,8 +86,8 @@ that table.

| key | meaning |
|---|---|
| `[clank].base_url` / `model` | chat endpoint for `clank` and `clank-jev`, under the flags and `CLANK_BASE_URL` / `CLANK_MODEL` |
| `[clank].api_key_env` | variable holding the chat key; `CLANK_API_KEY` and `--api-key` win |
| `[clank].base_url` / `model` | chat endpoint for `clank`, under the flags and `CLANK_BASE_URL` / `CLANK_MODEL` |
| `[clank].api_key_env` | variable holding the chat key; `CLANK_API_KEY` and `--api-key` win. A file found in the working directory may only name `CLANK_API_KEY` here (and `BRAVE_API_KEY` / `TAVILY_API_KEY` in `[web.*]`); another name needs `--config` or `CLANK_CONFIG` |
| `[clank].timeout` / `max_tokens` / `max_rounds` | under the flags (and `CLANK_TIMEOUT`) and above 600 / 8192 / 12 |
| `[web].default_provider` | `brave` (the built-in) or `tavily`; `--provider` wins |
| `[web].limit` | 1..=20, built-in 5; `--limit` wins |
Expand Down
35 changes: 28 additions & 7 deletions src/client.rs
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,8 @@ pub fn stream_round(
emit: &mut dyn FnMut(&str),
on_thinking: &mut dyn FnMut(&str),
) -> Result<Round, Fail> {
let url = format!("{}/chat/completions", req.base_url);
let url = format!("{}/chat/completions", req.base_url.trim_end_matches('/'));
let shown_url = crate::redacted_url(&url);
let mut body = json!({
"model": req.model,
"messages": req.messages,
Expand Down Expand Up @@ -82,7 +83,7 @@ pub fn stream_round(
request = request.header("Authorization", format!("Bearer {key}"));
}
let mut resp = request.send_json(&body).map_err(|e| {
Fail::Model(crate::config::redact(&format!("request to {url} failed: {e}"), req.api_key.unwrap_or("")))
Fail::Model(crate::config::redact(&format!("request to {shown_url} failed: {e}"), req.api_key.unwrap_or("")))
})?;

if resp.status() != 200 {
Expand All @@ -101,23 +102,36 @@ pub fn stream_round(
finish_reason: None,
};
let lines = std::io::BufReader::new(resp.body_mut().as_reader()).lines();
// A stream is finished when the server says so, with a `finish_reason` or
// `[DONE]`. A connection that closes before either is a cut-off answer,
// whatever text arrived before it.
let mut finished = false;

for line in lines {
let line = line.map_err(|e| Fail::Model(format!("stream read error: {e}")))?;
let Some(data) = line.strip_prefix("data:") else { continue };
let data = data.trim();
if data.is_empty() || data == "[DONE]" {
if data == "[DONE]" {
break;
}
if data == "[DONE]" {
finished = true;
break;
}
if data.is_empty() {
continue;
}
let Ok(v) = serde_json::from_str::<Value>(data) else { continue };
let v = serde_json::from_str::<Value>(data).map_err(|e| {
let head: String = data.chars().take(200).collect();
Fail::Model(format!("the server sent a stream event that is not JSON ({e}): {head}"))
})?;
if let Some(err) = v.get("error") {
let detail = crate::config::redact(&err.to_string(), req.api_key.unwrap_or(""));
return Err(Fail::Model(format!("the server reported an error mid-stream: {detail}")));
}

let Some(choices) = v.get("choices").and_then(|c| c.as_array()) else { continue };
for ch in choices {
if let Some(fr) = ch.get("finish_reason").and_then(|f| f.as_str()) {
round.finish_reason = Some(fr.to_string());
finished = true;
}
let Some(delta) = ch.get("delta") else { continue };
if let Some(t) = delta.get("content").and_then(|c| c.as_str()) {
Expand Down Expand Up @@ -155,5 +169,12 @@ pub fn stream_round(
}
}

if !finished {
return Err(Fail::Model(
"the stream ended before the server finished the answer (no finish_reason, no [DONE]); \
what was printed is incomplete"
.into(),
));
}
Ok(round)
}
45 changes: 44 additions & 1 deletion src/config.rs
Original file line number Diff line number Diff line change
Expand Up @@ -80,11 +80,39 @@ pub fn load(flag: Option<&Path>) -> Result<Option<File>, String> {
}
let start = std::env::current_dir().map_err(|e| format!("current directory: {e}"))?;
match implied_file(&start) {
Some(path) => read_at(&path).map(Some),
Some(path) => {
let file = read_at(&path)?;
implied_keys(&file)?;
Ok(Some(file))
}
None => Ok(None),
}
}

/// A file found in the working directory arrives with whatever repository was
/// cloned there, so it does not get to choose which environment variable is a
/// secret: `api_key_env = "GITHUB_TOKEN"` beside a `base_url` it also chose
/// would send that token to its own server. It may name each section's own
/// variable; any other name needs a file the caller named with `--config` or
/// `CLANK_CONFIG`.
fn implied_keys(file: &File) -> Result<(), String> {
let sections = [
("[clank]", file.clank.api_key_env.as_deref(), "CLANK_API_KEY"),
("[web.brave]", file.web.brave.api_key_env.as_deref(), "BRAVE_API_KEY"),
("[web.tavily]", file.web.tavily.api_key_env.as_deref(), "TAVILY_API_KEY"),
];
for (label, named, own) in sections {
if let Some(name) = named.filter(|n| *n != own) {
return Err(format!(
"{}: {label}.api_key_env names {name}; a config found in the working directory \
may only name {own}. Pass the file with --config or CLANK_CONFIG to name another variable",
file.path.display()
));
}
}
Ok(())
}

fn read_at(path: &Path) -> Result<File, String> {
let text = std::fs::read_to_string(path).map_err(|e| format!("reading {}: {e}", path.display()))?;
let mut file = parse(&text).map_err(|e| format!("{}: {e}", path.display()))?;
Expand Down Expand Up @@ -186,6 +214,8 @@ pub fn env_var(name: &str) -> Option<String> {
/// `named` is `api_key_env` and replaces `well_known` when the file sets it.
/// An inline `api_key` is used only when that variable is unset. `env` is the
/// lookup so tests can pass a table instead of the process environment.
// `clank-jev` compiles this module too and takes its keys from the environment only.
#[allow(dead_code)]
pub fn key_from(
named: Option<&str>,
inline: Option<&str>,
Expand Down Expand Up @@ -272,6 +302,19 @@ mod tests {
assert_eq!(redact("untouched", ""), "untouched");
}

#[test]
fn an_implied_file_names_only_its_own_key_variables() {
let mut file = parse("[clank]\napi_key_env = \"CLANK_API_KEY\"\n[web.brave]\napi_key_env = \"BRAVE_API_KEY\"\n").unwrap();
assert!(implied_keys(&file).is_ok());
file = parse("[clank]\napi_key_env = \"GITHUB_TOKEN\"\n").unwrap();
let err = implied_keys(&file).unwrap_err();
assert!(err.contains("GITHUB_TOKEN") && err.contains("--config"), "{err}");
file = parse("[web.tavily]\napi_key_env = \"AWS_SECRET_ACCESS_KEY\"\n").unwrap();
assert!(implied_keys(&file).is_err());
let sample = parse(include_str!("../fixtures/clank.config.toml")).unwrap();
assert!(implied_keys(&sample).is_ok(), "the documented sample stays usable in place");
}

#[test]
fn the_implied_file_is_the_working_directory_only() {
let root = std::env::temp_dir().join(format!("clank-cfg-discover-{}", std::process::id()));
Expand Down
Loading
Loading