collin/anvil · ddc1cc10
Extract Docker plumbing into an anvil-docker crate
Collin Richards · 2026-08-24 07:44 UTC · ddc1cc107965ffcec42c61eee92f943e95fd2d61 · parent 3f59c98c · browse files
modifiedCLAUDE.md+2 −0
| ⋯ 52 unchanged lines | |||
| 53 | 53 | sips -Z 1100 -s formatOptions 70 /tmp/att --out /tmp/att-small.jpg # then Read that | |
| 54 | 54 | ``` | |
| 55 | 55 | ||
| 56 | + | note that we can't use portless for this app because of a websocets bug with http/2 | |
| 57 | + | ||
| 56 | 58 | Current status, resume notes, the agreed next steps (a/b/c), and the roadmap live | |
| 57 | 59 | in the TODO. Read it first: | |
| 58 | 60 | ||
| ⋯ 1 unchanged line | |||
modifiedCargo.lock+72 −1
| ⋯ 122 unchanged lines | |||
| 123 | 123 | name = "anvil-agent" | |
| 124 | 124 | version = "0.0.0" | |
| 125 | 125 | dependencies = [ | |
| 126 | - | "anvil-ci", | |
| 127 | 126 | "anvil-core", | |
| 127 | + | "anvil-docker", | |
| 128 | 128 | "anvil-git", | |
| 129 | 129 | "async-trait", | |
| 130 | 130 | "bollard", | |
| ⋯ 8 unchanged lines | |||
| 139 | 139 | version = "0.0.0" | |
| 140 | 140 | dependencies = [ | |
| 141 | 141 | "anvil-core", | |
| 142 | + | "anvil-docker", | |
| 142 | 143 | "anvil-git", | |
| 143 | 144 | "bollard", | |
| 144 | 145 | "flate2", | |
| ⋯ 40 unchanged lines | |||
| 185 | 186 | "curve25519-dalek", | |
| 186 | 187 | "gix", | |
| 187 | 188 | "hmac 0.12.1", | |
| 189 | + | "jaq-core", | |
| 190 | + | "jaq-json", | |
| 191 | + | "jaq-std", | |
| 188 | 192 | "pulldown-cmark", | |
| 189 | 193 | "rusqlite", | |
| 190 | 194 | "serde", | |
| ⋯ 10 unchanged lines | |||
| 201 | 205 | ] | |
| 202 | 206 | ||
| 203 | 207 | [[package]] | |
| 208 | + | name = "anvil-docker" | |
| 209 | + | version = "0.0.0" | |
| 210 | + | dependencies = [ | |
| 211 | + | "bollard", | |
| 212 | + | "futures-util", | |
| 213 | + | "tracing", | |
| 214 | + | ] | |
| 215 | + | ||
| 216 | + | [[package]] | |
| 204 | 217 | name = "anvil-git" | |
| 205 | 218 | version = "0.0.0" | |
| 206 | 219 | dependencies = [ | |
| ⋯ 2068 unchanged lines | |||
| 2275 | 2288 | checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1" | |
| 2276 | 2289 | ||
| 2277 | 2290 | [[package]] | |
| 2291 | + | name = "hifijson" | |
| 2292 | + | version = "0.5.0" | |
| 2293 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2294 | + | checksum = "242402749acf71e6f32f5857598b7002c4058a4e3c3b22b4c7d51cab9aea754e" | |
| 2295 | + | ||
| 2296 | + | [[package]] | |
| 2278 | 2297 | name = "hkdf" | |
| 2279 | 2298 | version = "0.13.0" | |
| 2280 | 2299 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 385 unchanged lines | |||
| 2666 | 2685 | checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682" | |
| 2667 | 2686 | ||
| 2668 | 2687 | [[package]] | |
| 2688 | + | name = "jaq-core" | |
| 2689 | + | version = "3.1.0" | |
| 2690 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2691 | + | checksum = "7561783b20275a6c9cb576e39208b0c635f34ef14357f1f05a2927a774f3adec" | |
| 2692 | + | dependencies = [ | |
| 2693 | + | "dyn-clone", | |
| 2694 | + | "once_cell", | |
| 2695 | + | "typed-arena", | |
| 2696 | + | ] | |
| 2697 | + | ||
| 2698 | + | [[package]] | |
| 2699 | + | name = "jaq-json" | |
| 2700 | + | version = "2.0.2" | |
| 2701 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2702 | + | checksum = "48d801b0b57f10064c4e9f5a4f6c97d0ccf62649b179ff8ac23cd494a3120ee9" | |
| 2703 | + | dependencies = [ | |
| 2704 | + | "bstr", | |
| 2705 | + | "bytes", | |
| 2706 | + | "foldhash 0.1.5", | |
| 2707 | + | "hifijson", | |
| 2708 | + | "indexmap 2.14.0", | |
| 2709 | + | "jaq-core", | |
| 2710 | + | "jaq-std", | |
| 2711 | + | "num-bigint", | |
| 2712 | + | "num-traits", | |
| 2713 | + | "ryu", | |
| 2714 | + | "self_cell", | |
| 2715 | + | ] | |
| 2716 | + | ||
| 2717 | + | [[package]] | |
| 2718 | + | name = "jaq-std" | |
| 2719 | + | version = "3.0.2" | |
| 2720 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 2721 | + | checksum = "7941c8de9c591052050550f228c62ef80d3ecbd84c330f5c454bc8ebb7a04089" | |
| 2722 | + | dependencies = [ | |
| 2723 | + | "bstr", | |
| 2724 | + | "jaq-core", | |
| 2725 | + | ] | |
| 2726 | + | ||
| 2727 | + | [[package]] | |
| 2669 | 2728 | name = "jiff" | |
| 2670 | 2729 | version = "0.2.28" | |
| 2671 | 2730 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 1403 unchanged lines | |||
| 4075 | 4134 | ] | |
| 4076 | 4135 | ||
| 4077 | 4136 | [[package]] | |
| 4137 | + | name = "self_cell" | |
| 4138 | + | version = "1.3.0" | |
| 4139 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 4140 | + | checksum = "2ab42ca02749e120097e328d91d415325bdf43b1c72c4c8badf37375fe40a813" | |
| 4141 | + | ||
| 4142 | + | [[package]] | |
| 4078 | 4143 | name = "semver" | |
| 4079 | 4144 | version = "1.0.28" | |
| 4080 | 4145 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 898 unchanged lines | |||
| 4979 | 5044 | ] | |
| 4980 | 5045 | ||
| 4981 | 5046 | [[package]] | |
| 5047 | + | name = "typed-arena" | |
| 5048 | + | version = "2.0.2" | |
| 5049 | + | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 5050 | + | checksum = "6af6ae20167a9ece4bcb41af5b80f8a1f1df981f6391189ce00fd257af04126a" | |
| 5051 | + | ||
| 5052 | + | [[package]] | |
| 4982 | 5053 | name = "typenum" | |
| 4983 | 5054 | version = "1.20.1" | |
| 4984 | 5055 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 736 unchanged lines | |||
modifiedCargo.toml+1 −0
| ⋯ 15 unchanged lines | |||
| 16 | 16 | # Internal crates | |
| 17 | 17 | anvil-core = { path = "crates/anvil-core" } | |
| 18 | 18 | anvil-ci = { path = "crates/anvil-ci" } | |
| 19 | + | anvil-docker = { path = "crates/anvil-docker" } | |
| 19 | 20 | anvil-agent = { path = "crates/anvil-agent" } | |
| 20 | 21 | anvil-git = { path = "crates/anvil-git" } | |
| 21 | 22 | anvil-web = { path = "crates/anvil-web" } | |
| ⋯ 83 unchanged lines | |||
modifiedDockerfile+1 −1
| ⋯ 5 unchanged lines | |||
| 6 | 6 | # So building this image is a fast `COPY` — no QEMU-emulated release build, and | |
| 7 | 7 | # the VPS never compiles anything. | |
| 8 | 8 | ||
| 9 | - | FROM debian:bookworm-slim | |
| 9 | + | FROM ubuntu:26.04 | |
| 10 | 10 | ||
| 11 | 11 | # No git in the image: anvil is pure gitoxide (see CLAUDE.md), including the | |
| 12 | 12 | # push-mirroring client. | |
| ⋯ 21 unchanged lines | |||
modifiedcrates/anvil-agent/Cargo.toml+2 −2
| ⋯ 7 unchanged lines | |||
| 8 | 8 | description = "Agent sessions for anvil: a tmux-hosted agent CLI per repository, in a container, attachable from the browser." | |
| 9 | 9 | ||
| 10 | 10 | [dependencies] | |
| 11 | - | # The Docker connect/pull plumbing is shared with the CI runner rather than | |
| 11 | + | # The Docker connect/pull plumbing is shared with the job runner rather than | |
| 12 | 12 | # duplicated — both start containers from the same runner image. | |
| 13 | - | anvil-ci.workspace = true | |
| 13 | + | anvil-docker.workspace = true | |
| 14 | 14 | anvil-core.workspace = true | |
| 15 | 15 | async-trait.workspace = true | |
| 16 | 16 | anvil-git.workspace = true | |
| ⋯ 5 unchanged lines | |||
modifiedcrates/anvil-agent/src/container.rs+51 −14
| ⋯ 13 unchanged lines | |||
| 14 | 14 | container::{ | |
| 15 | 15 | Config, | |
| 16 | 16 | CreateContainerOptions, | |
| 17 | + | DownloadFromContainerOptions, | |
| 17 | 18 | RemoveContainerOptions, | |
| 18 | 19 | StartContainerOptions, | |
| 19 | 20 | UploadToContainerOptions, | |
| ⋯ 6 unchanged lines | |||
| 26 | 27 | }, | |
| 27 | 28 | models::HostConfig, | |
| 28 | 29 | }; | |
| 30 | + | use futures_util::StreamExt; | |
| 29 | 31 | ||
| 30 | 32 | /// Label carrying the session id, so containers can be found again after a | |
| 31 | 33 | /// restart — the registry is in-memory and does not survive one. | |
| 32 | 34 | pub const LABEL_SESSION: &str = "anvil.session"; | |
| 33 | 35 | ||
| 34 | - | /// Working directory inside the container, matching the CI runner's. | |
| 35 | - | pub const WORKDIR: &str = "/workspace"; | |
| 36 | + | /// Working directory inside the container. Under [`RUN_AS`]'s home rather | |
| 37 | + | /// than a bare `/workspace` (which is what the CI runner still uses — CI runs | |
| 38 | + | /// as root, so it has no home directory to prefer) so tools that assume a | |
| 39 | + | /// project lives under `$HOME` behave. | |
| 40 | + | pub const WORKDIR: &str = "/home/ubuntu/workspace"; | |
| 36 | 41 | ||
| 37 | - | /// The unprivileged user `deploy/runner/Dockerfile` creates. Sessions run as | |
| 38 | - | /// this rather than root; CI keeps the image's default (root) because plenty of | |
| 39 | - | /// pipelines expect to `apt-get`. | |
| 40 | - | pub const RUN_AS: &str = "agent"; | |
| 42 | + | /// The unprivileged user sessions run as rather than root; CI keeps the | |
| 43 | + | /// image's default (root) because plenty of pipelines expect to `apt-get`. | |
| 44 | + | /// This is `ubuntu`, not a purpose-made account: `deploy/runner/Dockerfile`'s | |
| 45 | + | /// base image already ships a uid-1000 user by that name, so reusing it saves | |
| 46 | + | /// a `useradd`. If the base image ever moves off Ubuntu, this needs an actual | |
| 47 | + | /// account created again. | |
| 48 | + | pub const RUN_AS: &str = "ubuntu"; | |
| 41 | 49 | ||
| 42 | 50 | /// That user's uid/gid, needed when building tars so the uploaded files are | |
| 43 | 51 | /// owned by the account that has to write them. | |
| 44 | 52 | const RUN_AS_UID: u64 = 1000; | |
| 45 | 53 | ||
| 46 | 54 | /// Home directory of [`RUN_AS`]; the agent CLI's config lives under it. | |
| 47 | - | pub const HOME: &str = "/home/agent"; | |
| 55 | + | pub const HOME: &str = "/home/ubuntu"; | |
| 48 | 56 | ||
| 49 | 57 | /// tmux session name, matching `session-entrypoint.sh`. | |
| 50 | 58 | pub const TMUX_SESSION: &str = "agent"; | |
| ⋯ 30 unchanged lines | |||
| 81 | 89 | env: Some( | |
| 82 | 90 | env.iter() | |
| 83 | 91 | .map(|(k, v)| format!("{k}={v}")) | |
| 84 | - | .chain([format!("HOME={HOME}"), "TERM=xterm-256color".to_string()]) | |
| 92 | + | .chain([ | |
| 93 | + | format!("HOME={HOME}"), | |
| 94 | + | format!("ANVIL_WORKDIR={WORKDIR}"), | |
| 95 | + | "TERM=xterm-256color".to_string(), | |
| 96 | + | ]) | |
| 85 | 97 | .collect(), | |
| 86 | 98 | ), | |
| 87 | 99 | working_dir: Some(WORKDIR.to_string()), | |
| ⋯ 33 unchanged lines | |||
| 121 | 133 | .map_err(|e| format!("upload to session container: {e}")) | |
| 122 | 134 | } | |
| 123 | 135 | ||
| 136 | + | /// Read one file's current bytes out of the container (which must already | |
| 137 | + | /// exist, though it need not be started — the filesystem is there either | |
| 138 | + | /// way). `None` covers every reason it might not be readable yet: the file | |
| 139 | + | /// does not exist, an intermediate directory does not exist, anything — | |
| 140 | + | /// a "json" secret starts from `{}` in all of those cases alike, the same as | |
| 141 | + | /// a brand new file would. | |
| 142 | + | pub async fn download_file(docker: &Docker, id: &str, absolute_path: &str) -> Option<Vec<u8>> { | |
| 143 | + | let mut stream = docker.download_from_container( | |
| 144 | + | id, | |
| 145 | + | Some(DownloadFromContainerOptions { | |
| 146 | + | path: absolute_path.to_string(), | |
| 147 | + | }), | |
| 148 | + | ); | |
| 149 | + | let mut tar = Vec::new(); | |
| 150 | + | while let Some(chunk) = stream.next().await { | |
| 151 | + | tar.extend_from_slice(&chunk.ok()?); | |
| 152 | + | } | |
| 153 | + | let mut archive = tar::Archive::new(tar.as_slice()); | |
| 154 | + | let mut entries = archive.entries().ok()?; | |
| 155 | + | let mut entry = entries.next()?.ok()?; | |
| 156 | + | let mut contents = Vec::new(); | |
| 157 | + | std::io::Read::read_to_end(&mut entry, &mut contents).ok()?; | |
| 158 | + | Some(contents) | |
| 159 | + | } | |
| 160 | + | ||
| 124 | 161 | /// Build a tar of `(path, contents, executable)` entries, rooted at `prefix` | |
| 125 | 162 | /// (no leading slash) and owned by the session user. | |
| 126 | 163 | /// | |
| 127 | 164 | /// Emits an explicit entry for every intermediate **directory**, which matters | |
| 128 | 165 | /// more than it looks: a tar of files alone makes Docker create the parent | |
| 129 | - | /// directories itself, owned by root. The session runs as `agent`, so `src/` | |
| 130 | - | /// would come out root-owned and the agent could edit existing files but never | |
| 131 | - | /// add one — which is most of what a coding agent does. | |
| 166 | + | /// directories itself, owned by root. The session runs as [`RUN_AS`], so | |
| 167 | + | /// `src/` would come out root-owned and the agent could edit existing files | |
| 168 | + | /// but never add one — which is most of what a coding agent does. | |
| 132 | 169 | pub fn build_tar(prefix: &str, files: &[(String, Vec<u8>, bool)]) -> Vec<u8> { | |
| 133 | 170 | let mut builder = tar::Builder::new(Vec::new()); | |
| 134 | 171 | let mtime = anvil_core::agent::now_secs().max(0) as u64; | |
| ⋯ 3 unchanged lines | |||
| 138 | 175 | header.set_size(size); | |
| 139 | 176 | header.set_mode(mode); | |
| 140 | 177 | header.set_entry_type(entry_type); | |
| 141 | - | // Ownership matters: the container runs as `agent`, and a checkout it | |
| 142 | - | // cannot write is not a workspace. | |
| 178 | + | // Ownership matters: the container runs as [`RUN_AS`], and a checkout | |
| 179 | + | // it cannot write is not a workspace. | |
| 143 | 180 | header.set_uid(RUN_AS_UID); | |
| 144 | 181 | header.set_gid(RUN_AS_UID); | |
| 145 | 182 | // Without this every file lands in 1970, which upsets anything that | |
| ⋯ 21 unchanged lines | |||
| 167 | 204 | } | |
| 168 | 205 | dirs.sort_by_key(|d| d.matches('/').count()); | |
| 169 | 206 | ||
| 170 | - | // The root itself, so `/workspace` is agent-owned even when empty. | |
| 207 | + | // The root itself, so `prefix` is agent-owned even when empty. | |
| 171 | 208 | let mut header = header_for(0, 0o755, tar::EntryType::Directory); | |
| 172 | 209 | let _ = builder.append_data(&mut header, format!("{prefix}/"), std::io::empty()); | |
| 173 | 210 | for dir in &dirs { | |
| ⋯ 390 unchanged lines | |||
modifiedcrates/anvil-agent/src/lib.rs+9 −2
| ⋯ 3 unchanged lines | |||
| 4 | 4 | //! Deliberately a sibling of `anvil-ci` rather than part of it. The CI runner | |
| 5 | 5 | //! is a single task processing one job at a time; a session lives for hours and | |
| 6 | 6 | //! must never sit in that queue. Sharing the runner image and the Docker | |
| 7 | - | //! plumbing (`anvil_ci::docker`) is the extent of the overlap. | |
| 7 | + | //! plumbing (`anvil_docker`) is the extent of the overlap. | |
| 8 | 8 | //! | |
| 9 | 9 | //! The shape: | |
| 10 | 10 | //! | |
| ⋯ 49 unchanged lines | |||
| 60 | 60 | ||
| 61 | 61 | /// Start a session against `repo_id` at `base_ref`, returning its id. | |
| 62 | 62 | /// | |
| 63 | + | /// `secret_names` are the user secrets (`anvil_core::secrets::list_for_user`) | |
| 64 | + | /// this session opted into at start time — see `supervisor::launch` for how | |
| 65 | + | /// they actually get into the container. Empty is a normal choice, not an | |
| 66 | + | /// error: most sessions want none. | |
| 67 | + | /// | |
| 63 | 68 | /// Creates the row first so a failure part-way through is still visible in the | |
| 64 | 69 | /// UI, then hands off to a supervisor task. Returns as soon as the container is | |
| 65 | 70 | /// up — the caller redirects to the session page and attaches from there. | |
| ⋯ 4 unchanged lines | |||
| 70 | 75 | kind: &str, | |
| 71 | 76 | base_ref: &str, | |
| 72 | 77 | prompt: &str, | |
| 78 | + | secret_names: &[String], | |
| 73 | 79 | ) -> Result<i64, StartError> { | |
| 74 | 80 | let cfg = &app.config.agent; | |
| 75 | 81 | if !cfg.enabled { | |
| ⋯ 17 unchanged lines | |||
| 93 | 99 | &base_commit, | |
| 94 | 100 | prompt, | |
| 95 | 101 | &cfg.image, | |
| 102 | + | &secret_names.join(","), | |
| 96 | 103 | ) | |
| 97 | 104 | .await | |
| 98 | 105 | .map_err(|e| StartError::Failed(e.to_string()))?; | |
| ⋯ 32 unchanged lines | |||
| 131 | 138 | /// does for interrupted runs — except a terminal session cannot be resumed, so | |
| 132 | 139 | /// it ends rather than re-queues. | |
| 133 | 140 | pub async fn reconcile(app: &App) { | |
| 134 | - | let docker = match anvil_ci::docker::connect() { | |
| 141 | + | let docker = match anvil_docker::connect() { | |
| 135 | 142 | Ok(docker) => docker, | |
| 136 | 143 | Err(e) => { | |
| 137 | 144 | tracing::warn!("agent: skipping reconcile, {e}"); | |
| ⋯ 72 unchanged lines | |||
modifiedcrates/anvil-agent/src/supervisor.rs+129 −6
| ⋯ 11 unchanged lines | |||
| 12 | 12 | }, | |
| 13 | 13 | models::AgentSession, | |
| 14 | 14 | repos, | |
| 15 | + | secrets, | |
| 15 | 16 | storage, | |
| 16 | 17 | users, | |
| 17 | 18 | }; | |
| ⋯ 33 unchanged lines | |||
| 51 | 52 | /// session page, which attaches over a websocket. | |
| 52 | 53 | pub async fn launch(app: App, session: AgentSession, repo_path: PathBuf) -> Result<(), String> { | |
| 53 | 54 | let cfg = &app.config.agent; | |
| 54 | - | let docker = anvil_ci::docker::connect()?; | |
| 55 | - | anvil_ci::docker::ensure_image(&docker, &cfg.image).await?; | |
| 55 | + | let docker = anvil_docker::connect()?; | |
| 56 | + | anvil_docker::ensure_image(&docker, &cfg.image).await?; | |
| 56 | 57 | ||
| 58 | + | // The user secrets (secrets::UserSecret) this session opted into at | |
| 59 | + | // start time — an empty choice is the normal case, not an error. Resolved | |
| 60 | + | // before anything is created, so a missing unlock fails fast rather than | |
| 61 | + | // after a container already exists. See docs/secrets.md: this is opt-in | |
| 62 | + | // per session rather than blanket, on purpose — a session can be steered | |
| 63 | + | // by repo content it reads (prompt injection, docs/untrusted-mode.md), | |
| 64 | + | // so it should only ever reach what it was deliberately given. | |
| 65 | + | let (env_secrets, file_writes, json_merges) = resolve_secrets(&app, &session).await?; | |
| 66 | + | ||
| 57 | 67 | // The agent CLI, run interactively under tmux. `--dangerously-skip- | |
| 58 | 68 | // permissions` is defensible precisely because the container *is* the | |
| 59 | 69 | // sandbox: all capabilities dropped, no socket, no mounts, nothing of | |
| ⋯ 4 unchanged lines | |||
| 64 | 74 | "--dangerously-skip-permissions".to_string(), | |
| 65 | 75 | ]; | |
| 66 | 76 | ||
| 67 | - | let container_id = container::create(&docker, cfg, session.id, &[], &command).await?; | |
| 77 | + | let container_id = container::create(&docker, cfg, session.id, &env_secrets, &command).await?; | |
| 68 | 78 | ||
| 69 | 79 | // Seed the workspace. M1 uploads the commit's files, exactly as CI does — | |
| 70 | 80 | // no `.git`, so no credential is needed anywhere yet. | |
| ⋯ 16 unchanged lines | |||
| 87 | 97 | return Err(e); | |
| 88 | 98 | } | |
| 89 | 99 | ||
| 100 | + | // "file"/"json" secrets, written into $HOME — after credentials_dir, so a | |
| 101 | + | // session's own explicit choice wins if the two ever target the same | |
| 102 | + | // path. A "json" merge may download what credentials_dir (or the image, | |
| 103 | + | // e.g. claude-onboarding.json) just put there, hence after both and not | |
| 104 | + | // interleaved with them. | |
| 105 | + | if let Err(e) = write_secret_files(&docker, &container_id, file_writes, json_merges).await { | |
| 106 | + | container::remove(&docker, &container_id).await; | |
| 107 | + | return Err(e); | |
| 108 | + | } | |
| 109 | + | ||
| 90 | 110 | if let Err(e) = container::start(&docker, &container_id).await { | |
| 91 | 111 | container::remove(&docker, &container_id).await; | |
| 92 | 112 | return Err(e); | |
| ⋯ 23 unchanged lines | |||
| 116 | 136 | Ok(()) | |
| 117 | 137 | } | |
| 118 | 138 | ||
| 139 | + | /// Resolve `session.secret_names` into what `launch` actually needs: pairs | |
| 140 | + | /// to inject as environment variables, whole-file writes, and json-field | |
| 141 | + | /// merges — see [`secrets::kind`]. Empty everywhere is the normal case for a | |
| 142 | + | /// session that opted into nothing. | |
| 143 | + | #[allow(clippy::type_complexity)] | |
| 144 | + | async fn resolve_secrets( | |
| 145 | + | app: &App, | |
| 146 | + | session: &AgentSession, | |
| 147 | + | ) -> Result< | |
| 148 | + | ( | |
| 149 | + | Vec<(String, String)>, | |
| 150 | + | Vec<(String, String)>, | |
| 151 | + | Vec<(String, String, String)>, | |
| 152 | + | ), | |
| 153 | + | String, | |
| 154 | + | > { | |
| 155 | + | let names: Vec<String> = session | |
| 156 | + | .secret_names | |
| 157 | + | .split(',') | |
| 158 | + | .map(str::trim) | |
| 159 | + | .filter(|s| !s.is_empty()) | |
| 160 | + | .map(str::to_string) | |
| 161 | + | .collect(); | |
| 162 | + | if names.is_empty() { | |
| 163 | + | return Ok((Vec::new(), Vec::new(), Vec::new())); | |
| 164 | + | } | |
| 165 | + | ||
| 166 | + | let values = app | |
| 167 | + | .user_vault | |
| 168 | + | .take(session.user_id, &names) | |
| 169 | + | .map_err(|missing| { | |
| 170 | + | format!( | |
| 171 | + | "session wants secret(s) {} but they are sealed, or were unlocked without them — \ | |
| 172 | + | run `anvild secret user unlock`", | |
| 173 | + | missing.join(", ") | |
| 174 | + | ) | |
| 175 | + | })?; | |
| 176 | + | ||
| 177 | + | let mut env_secrets = Vec::new(); | |
| 178 | + | let mut file_writes = Vec::new(); | |
| 179 | + | let mut json_merges = Vec::new(); | |
| 180 | + | for (name, value) in values { | |
| 181 | + | // Deleted between opting in and this session actually launching: | |
| 182 | + | // skip it rather than fail the whole session over a secret that no | |
| 183 | + | // longer exists. | |
| 184 | + | let Some(secret) = secrets::find_for_user(&app.db, session.user_id, &name) | |
| 185 | + | .await | |
| 186 | + | .map_err(|e| e.to_string())? | |
| 187 | + | else { | |
| 188 | + | continue; | |
| 189 | + | }; | |
| 190 | + | match secret.kind.as_str() { | |
| 191 | + | secrets::kind::FILE => file_writes.push((secret.dest_path, value)), | |
| 192 | + | secrets::kind::JSON => json_merges.push((secret.dest_path, secret.field, value)), | |
| 193 | + | _ => env_secrets.push((name, value)), | |
| 194 | + | } | |
| 195 | + | } | |
| 196 | + | Ok((env_secrets, file_writes, json_merges)) | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | /// Write "file"/"json" user secrets into the session's `$HOME`. Multiple | |
| 200 | + | /// entries targeting the same path compose against one shared in-memory copy | |
| 201 | + | /// — a "file" secret provides the base if one names that path, otherwise a | |
| 202 | + | /// "json" merge downloads whatever is there already (from the image or | |
| 203 | + | /// `seed_credentials`) — rather than each overwriting the last. | |
| 204 | + | async fn write_secret_files( | |
| 205 | + | docker: &bollard::Docker, | |
| 206 | + | container_id: &str, | |
| 207 | + | file_writes: Vec<(String, String)>, | |
| 208 | + | json_merges: Vec<(String, String, String)>, | |
| 209 | + | ) -> Result<(), String> { | |
| 210 | + | if file_writes.is_empty() && json_merges.is_empty() { | |
| 211 | + | return Ok(()); | |
| 212 | + | } | |
| 213 | + | ||
| 214 | + | let mut contents: std::collections::BTreeMap<String, Vec<u8>> = | |
| 215 | + | std::collections::BTreeMap::new(); | |
| 216 | + | for (dest_path, value) in file_writes { | |
| 217 | + | contents.insert(dest_path, value.into_bytes()); | |
| 218 | + | } | |
| 219 | + | for (dest_path, field, value) in json_merges { | |
| 220 | + | let current = match contents.get(&dest_path) { | |
| 221 | + | Some(bytes) => bytes.clone(), | |
| 222 | + | None => { | |
| 223 | + | let absolute = format!("{}/{dest_path}", container::HOME); | |
| 224 | + | container::download_file(docker, container_id, &absolute) | |
| 225 | + | .await | |
| 226 | + | .unwrap_or_default() | |
| 227 | + | } | |
| 228 | + | }; | |
| 229 | + | let merged = | |
| 230 | + | anvil_core::secrets::json_merge(¤t, &field, &value).map_err(|e| e.to_string())?; | |
| 231 | + | contents.insert(dest_path, merged); | |
| 232 | + | } | |
| 233 | + | ||
| 234 | + | let entries: Vec<_> = contents | |
| 235 | + | .into_iter() | |
| 236 | + | .map(|(path, bytes)| (path, bytes, false)) | |
| 237 | + | .collect(); | |
| 238 | + | let tar = container::build_tar(container::HOME.trim_start_matches('/'), &entries); | |
| 239 | + | container::upload(docker, container_id, tar).await | |
| 240 | + | } | |
| 241 | + | ||
| 119 | 242 | /// Upload the configured credentials directory to the session user's home. | |
| 120 | 243 | /// | |
| 121 | 244 | /// Empty config means sessions start unauthenticated, which is a legitimate | |
| ⋯ 30 unchanged lines | |||
| 152 | 275 | mut shutdown: tokio::sync::watch::Receiver<bool>, | |
| 153 | 276 | prompt: String, | |
| 154 | 277 | ) { | |
| 155 | - | let docker = match anvil_ci::docker::connect() { | |
| 278 | + | let docker = match anvil_docker::connect() { | |
| 156 | 279 | Ok(docker) => docker, | |
| 157 | 280 | Err(e) => { | |
| 158 | 281 | finish(&app, session_id, &container_id, status::FAILED, 0, &e).await; | |
| ⋯ 129 unchanged lines | |||
| 288 | 411 | exit_code: i64, | |
| 289 | 412 | error: &str, | |
| 290 | 413 | ) { | |
| 291 | - | if let Ok(docker) = anvil_ci::docker::connect() { | |
| 414 | + | if let Ok(docker) = anvil_docker::connect() { | |
| 292 | 415 | container::remove(&docker, container_id).await; | |
| 293 | 416 | } | |
| 294 | 417 | app.sessions.remove(session_id); | |
| ⋯ 18 unchanged lines | |||
| 313 | 436 | .get(session_id) | |
| 314 | 437 | .ok_or("session is not running")?; | |
| 315 | 438 | ||
| 316 | - | let docker = anvil_ci::docker::connect()?; | |
| 439 | + | let docker = anvil_docker::connect()?; | |
| 317 | 440 | let terminal = container::attach(&docker, &handle.container_id, cols, rows).await?; | |
| 318 | 441 | ||
| 319 | 442 | handle | |
| ⋯ 17 unchanged lines | |||
modifiedcrates/anvil-ci/Cargo.toml+1 −0
| ⋯ 8 unchanged lines | |||
| 9 | 9 | ||
| 10 | 10 | [dependencies] | |
| 11 | 11 | anvil-core.workspace = true | |
| 12 | + | anvil-docker.workspace = true | |
| 12 | 13 | anvil-git.workspace = true | |
| 13 | 14 | bollard.workspace = true | |
| 14 | 15 | flate2.workspace = true | |
| ⋯ 9 unchanged lines | |||
deletedcrates/anvil-ci/src/docker.rs+0 −65
| 1 | - | //! Docker plumbing shared by the CI runner and the agent-session supervisor. | |
| 2 | - | //! | |
| 3 | - | //! Both create containers from the same runner image | |
| 4 | - | //! ([`anvil_core::config::DEFAULT_RUNNER_IMAGE`]) against the same daemon, so | |
| 5 | - | //! the connect/pull dance lives here rather than being written twice. | |
| 6 | - | ||
| 7 | - | use bollard::{ | |
| 8 | - | Docker, | |
| 9 | - | image::CreateImageOptions, | |
| 10 | - | }; | |
| 11 | - | use futures_util::StreamExt; | |
| 12 | - | ||
| 13 | - | /// Connect to the daemon over the local socket. | |
| 14 | - | pub fn connect() -> Result<Docker, String> { | |
| 15 | - | Docker::connect_with_socket_defaults() | |
| 16 | - | .map_err(|e| format!("docker unavailable (is the socket mounted?): {e}")) | |
| 17 | - | } | |
| 18 | - | ||
| 19 | - | /// Make sure `image` is present locally, pulling it if it is not. | |
| 20 | - | /// | |
| 21 | - | /// A failed pull is only fatal when the image is *also* absent locally. anvil's | |
| 22 | - | /// own runner image is built by `deploy/runner/build.sh` straight into the | |
| 23 | - | /// host's image store and exists in no registry, so an unconditional pull — | |
| 24 | - | /// which is what this used to be — fails for the one image most jobs now use. | |
| 25 | - | pub async fn ensure_image(docker: &Docker, image: &str) -> Result<(), String> { | |
| 26 | - | // Split name:tag so we don't accidentally pull every tag. A ':' that has a | |
| 27 | - | // '/' after it is a registry port, not a tag. | |
| 28 | - | let (from_image, tag) = match image.rsplit_once(':') { | |
| 29 | - | Some((name, tag)) if !tag.contains('/') => (name.to_string(), tag.to_string()), | |
| 30 | - | _ => (image.to_string(), "latest".to_string()), | |
| 31 | - | }; | |
| 32 | - | ||
| 33 | - | let mut pull = docker.create_image( | |
| 34 | - | Some(CreateImageOptions { | |
| 35 | - | from_image, | |
| 36 | - | tag, | |
| 37 | - | ..Default::default() | |
| 38 | - | }), | |
| 39 | - | None, | |
| 40 | - | None, | |
| 41 | - | ); | |
| 42 | - | let mut pull_error = None; | |
| 43 | - | while let Some(item) = pull.next().await { | |
| 44 | - | if let Err(e) = item { | |
| 45 | - | pull_error = Some(e.to_string()); | |
| 46 | - | break; | |
| 47 | - | } | |
| 48 | - | } | |
| 49 | - | ||
| 50 | - | let Some(pull_error) = pull_error else { | |
| 51 | - | return Ok(()); | |
| 52 | - | }; | |
| 53 | - | ||
| 54 | - | // The pull failed. That is fine if the image is already here — the local | |
| 55 | - | // build case — and fatal otherwise. | |
| 56 | - | match docker.inspect_image(image).await { | |
| 57 | - | Ok(_) => { | |
| 58 | - | tracing::debug!("pull of {image} failed ({pull_error}); using the local image"); | |
| 59 | - | Ok(()) | |
| 60 | - | } | |
| 61 | - | Err(_) => Err(format!( | |
| 62 | - | "image {image} is not available locally and could not be pulled: {pull_error}" | |
| 63 | - | )), | |
| 64 | - | } | |
| 65 | - | } |
modifiedcrates/anvil-ci/src/lib.rs+2 −4
| ⋯ 13 unchanged lines | |||
| 14 | 14 | //! pids/memory/cpu caps plus a wall-clock timeout and an optional image | |
| 15 | 15 | //! allowlist ([`anvil_core::config::CiConfig`]). | |
| 16 | 16 | ||
| 17 | - | pub mod docker; | |
| 18 | - | ||
| 19 | 17 | use std::{ | |
| 20 | 18 | collections::BTreeMap, | |
| 21 | 19 | io::Read, | |
| ⋯ 378 unchanged lines | |||
| 400 | 398 | "image {image} is not permitted by ci.allowed_images" | |
| 401 | 399 | )); | |
| 402 | 400 | } | |
| 403 | - | let docker = docker::connect()?; | |
| 404 | - | docker::ensure_image(&docker, image).await?; | |
| 401 | + | let docker = anvil_docker::connect()?; | |
| 402 | + | anvil_docker::ensure_image(&docker, image).await?; | |
| 405 | 403 | ||
| 406 | 404 | // Build a single `set -e` script from the steps. The steps run in a | |
| 407 | 405 | // subshell so the meta-extractor trailer still runs (and the original | |
| ⋯ 468 unchanged lines | |||
modifiedcrates/anvil-cli/src/secret.rs+387 −0
| ⋯ 18 unchanged lines | |||
| 19 | 19 | Identity, | |
| 20 | 20 | Recipient, | |
| 21 | 21 | body_aad, | |
| 22 | + | kind, | |
| 22 | 23 | seal, | |
| 24 | + | user_aad, | |
| 23 | 25 | }; | |
| 24 | 26 | use anyhow::{ | |
| 25 | 27 | Context, | |
| ⋯ 39 unchanged lines | |||
| 65 | 67 | /// Re-seal every secret to the owner's current ssh keys — run this after | |
| 66 | 68 | /// adding a key, which otherwise cannot open anything sealed before it. | |
| 67 | 69 | Rekey { repo: String }, | |
| 70 | + | /// Secrets scoped to your own account rather than a repository — for | |
| 71 | + | /// injecting into your own agent sessions. See `docs/secrets.md`. | |
| 72 | + | #[command(subcommand)] | |
| 73 | + | User(UserSecretCommand), | |
| 68 | 74 | } | |
| 69 | 75 | ||
| 76 | + | #[derive(Subcommand)] | |
| 77 | + | pub enum UserSecretCommand { | |
| 78 | + | /// List your secrets (names, kind, and key coverage — never values). | |
| 79 | + | List, | |
| 80 | + | /// Encrypt a value and store it as an environment variable secret. Reads | |
| 81 | + | /// the value from stdin unless `--value` is given. | |
| 82 | + | Set { | |
| 83 | + | /// Variable name, e.g. `CLAUDE_CREDS`. | |
| 84 | + | name: String, | |
| 85 | + | #[arg(long)] | |
| 86 | + | value: Option<String>, | |
| 87 | + | }, | |
| 88 | + | /// Encrypt a value and store it as a whole-file secret, written at | |
| 89 | + | /// `--path` (relative to `$HOME`) in any session that opts in. Reads the | |
| 90 | + | /// value from `--value-file`, or stdin if that is omitted. | |
| 91 | + | SetFile { | |
| 92 | + | name: String, | |
| 93 | + | /// Destination under the session's $HOME, e.g. | |
| 94 | + | /// `.claude/.credentials.json`. | |
| 95 | + | #[arg(long)] | |
| 96 | + | path: String, | |
| 97 | + | #[arg(long)] | |
| 98 | + | value_file: Option<PathBuf>, | |
| 99 | + | }, | |
| 100 | + | /// Encrypt a value and store it as a json-merge secret: `--field` (a | |
| 101 | + | /// jq-style path, e.g. `.oauthAccount.token`) is set inside the JSON file | |
| 102 | + | /// at `--path`, leaving the rest of that file alone. Reads the value from | |
| 103 | + | /// stdin unless `--value` is given. | |
| 104 | + | SetJson { | |
| 105 | + | name: String, | |
| 106 | + | #[arg(long)] | |
| 107 | + | path: String, | |
| 108 | + | /// jq-style path within that file, e.g. `.oauthAccount.token`. | |
| 109 | + | #[arg(long)] | |
| 110 | + | field: String, | |
| 111 | + | #[arg(long)] | |
| 112 | + | value: Option<String>, | |
| 113 | + | }, | |
| 114 | + | /// Decrypt and print one secret's value. | |
| 115 | + | Get { name: String }, | |
| 116 | + | /// Delete a secret. | |
| 117 | + | Rm { name: String }, | |
| 118 | + | /// Decrypt every secret and hand the values to the server, which holds | |
| 119 | + | /// them in memory (never on disk) so your agent sessions can use them | |
| 120 | + | /// until they expire. | |
| 121 | + | Unlock { | |
| 122 | + | /// How long the unlock lasts, e.g. `8h`, `45m`, `7d`. | |
| 123 | + | #[arg(long, default_value = "8h")] | |
| 124 | + | ttl: String, | |
| 125 | + | }, | |
| 126 | + | /// Forget the unlocked values on the server immediately. | |
| 127 | + | Lock, | |
| 128 | + | /// Re-seal every secret to your current ssh keys — run this after adding | |
| 129 | + | /// a key, which otherwise cannot open anything sealed before it. | |
| 130 | + | Rekey, | |
| 131 | + | } | |
| 132 | + | ||
| 70 | 133 | /// Connection and identity options shared by every `secret` subcommand. | |
| 71 | 134 | #[derive(clap::Args)] | |
| 72 | 135 | pub struct SecretOpts { | |
| ⋯ 28 unchanged lines | |||
| 101 | 164 | Ok(()) | |
| 102 | 165 | } | |
| 103 | 166 | SecretCommand::Rekey { repo } => rekey(&client, opts, &repo).await, | |
| 167 | + | SecretCommand::User(cmd) => run_user(cmd, &client, opts).await, | |
| 168 | + | } | |
| 169 | + | } | |
| 170 | + | ||
| 171 | + | async fn run_user(command: UserSecretCommand, client: &Client, opts: &SecretOpts) -> Result<()> { | |
| 172 | + | match command { | |
| 173 | + | UserSecretCommand::List => user_list(client).await, | |
| 174 | + | UserSecretCommand::Set { name, value } => { | |
| 175 | + | user_set(client, &name, kind::ENV, "", "", read_value(value)?).await | |
| 176 | + | } | |
| 177 | + | UserSecretCommand::SetFile { | |
| 178 | + | name, | |
| 179 | + | path, | |
| 180 | + | value_file, | |
| 181 | + | } => { | |
| 182 | + | let value = match value_file { | |
| 183 | + | Some(path) => std::fs::read_to_string(&path) | |
| 184 | + | .with_context(|| format!("reading {}", path.display()))?, | |
| 185 | + | None => read_value(None)?, | |
| 186 | + | }; | |
| 187 | + | user_set(client, &name, kind::FILE, &path, "", value).await | |
| 188 | + | } | |
| 189 | + | UserSecretCommand::SetJson { | |
| 190 | + | name, | |
| 191 | + | path, | |
| 192 | + | field, | |
| 193 | + | value, | |
| 194 | + | } => user_set(client, &name, kind::JSON, &path, &field, read_value(value)?).await, | |
| 195 | + | UserSecretCommand::Get { name } => user_get(client, opts, &name).await, | |
| 196 | + | UserSecretCommand::Rm { name } => { | |
| 197 | + | client.delete_user(&name).await?; | |
| 198 | + | println!("deleted {name}"); | |
| 199 | + | Ok(()) | |
| 200 | + | } | |
| 201 | + | UserSecretCommand::Unlock { ttl } => user_unlock(client, opts, &ttl).await, | |
| 202 | + | UserSecretCommand::Lock => { | |
| 203 | + | client.lock_user().await?; | |
| 204 | + | println!( | |
| 205 | + | "sealed — agent sessions that opt into a secret will fail to start until unlocked" | |
| 206 | + | ); | |
| 207 | + | Ok(()) | |
| 208 | + | } | |
| 209 | + | UserSecretCommand::Rekey => user_rekey(client, opts).await, | |
| 104 | 210 | } | |
| 105 | 211 | } | |
| 106 | 212 | ||
| 213 | + | /// A value from `--value`, or stdin (a prompt if it's a terminal, else read | |
| 214 | + | /// to EOF) — the same fallback [`set`] uses. | |
| 215 | + | fn read_value(value: Option<String>) -> Result<String> { | |
| 216 | + | match value { | |
| 217 | + | Some(v) => Ok(v), | |
| 218 | + | None if std::io::stdin().is_terminal() => Ok(rpassword::prompt_password("value: ")?), | |
| 219 | + | None => { | |
| 220 | + | let mut buf = String::new(); | |
| 221 | + | std::io::stdin().read_to_string(&mut buf)?; | |
| 222 | + | Ok(buf.trim_end_matches('\n').to_string()) | |
| 223 | + | } | |
| 224 | + | } | |
| 225 | + | } | |
| 226 | + | ||
| 107 | 227 | // --- commands -------------------------------------------------------------- | |
| 108 | 228 | ||
| 109 | 229 | async fn list(client: &Client, repo: &str) -> Result<()> { | |
| ⋯ 149 unchanged lines | |||
| 259 | 379 | Ok(()) | |
| 260 | 380 | } | |
| 261 | 381 | ||
| 382 | + | // --- user secret commands --------------------------------------------------- | |
| 383 | + | // | |
| 384 | + | // Same shape as the repository commands above, minus the repository: the | |
| 385 | + | // target is always the authenticated account itself. | |
| 386 | + | ||
| 387 | + | async fn user_list(client: &Client) -> Result<()> { | |
| 388 | + | let state = client.fetch_user().await?; | |
| 389 | + | if state.secrets.is_empty() { | |
| 390 | + | println!("no user secrets."); | |
| 391 | + | } | |
| 392 | + | let current: Vec<&str> = state | |
| 393 | + | .recipients | |
| 394 | + | .iter() | |
| 395 | + | .map(|r| r.fingerprint.as_str()) | |
| 396 | + | .collect(); | |
| 397 | + | for secret in &state.secrets { | |
| 398 | + | let missing = current | |
| 399 | + | .iter() | |
| 400 | + | .filter(|fp| !secret.recipients.iter().any(|s| s == **fp)) | |
| 401 | + | .count(); | |
| 402 | + | let note = if missing > 0 { | |
| 403 | + | format!(" — {missing} registered key(s) cannot open it; run `anvild secret user rekey`") | |
| 404 | + | } else { | |
| 405 | + | String::new() | |
| 406 | + | }; | |
| 407 | + | let dest = match secret.kind.as_str() { | |
| 408 | + | kind::JSON => format!(" {} {}", secret.dest_path, secret.field), | |
| 409 | + | kind::FILE => format!(" {}", secret.dest_path), | |
| 410 | + | _ => String::new(), | |
| 411 | + | }; | |
| 412 | + | println!( | |
| 413 | + | "{:<24} {}{dest} sealed to {} key(s){note}", | |
| 414 | + | secret.name, | |
| 415 | + | secret.kind, | |
| 416 | + | secret.recipients.len() | |
| 417 | + | ); | |
| 418 | + | } | |
| 419 | + | match state.unlocked_until { | |
| 420 | + | 0 => println!("\nsealed (agent sessions cannot read these)"), | |
| 421 | + | until => println!("\nunlocked until {}", fmt_time(until)), | |
| 422 | + | } | |
| 423 | + | Ok(()) | |
| 424 | + | } | |
| 425 | + | ||
| 426 | + | async fn user_set( | |
| 427 | + | client: &Client, | |
| 428 | + | name: &str, | |
| 429 | + | set_kind: &str, | |
| 430 | + | dest_path: &str, | |
| 431 | + | field: &str, | |
| 432 | + | value: String, | |
| 433 | + | ) -> Result<()> { | |
| 434 | + | if !anvil_core::secrets::valid_name(name) { | |
| 435 | + | bail!("secret names are A–Z, 0–9 and _, and cannot start with a digit"); | |
| 436 | + | } | |
| 437 | + | let state = client.fetch_user().await?; | |
| 438 | + | let recipients = state.recipient_keys()?; | |
| 439 | + | let envelope = seal( | |
| 440 | + | value.as_bytes(), | |
| 441 | + | &user_aad(&client.username, name), | |
| 442 | + | &recipients, | |
| 443 | + | )?; | |
| 444 | + | client | |
| 445 | + | .put_user(name, set_kind, dest_path, field, &envelope) | |
| 446 | + | .await?; | |
| 447 | + | println!("sealed {name} to {} key(s)", envelope.recipients.len()); | |
| 448 | + | if state.unlocked_until > 0 { | |
| 449 | + | println!( | |
| 450 | + | "note: your secrets are unlocked with the *old* set — re-run `anvild secret user unlock` for sessions to see this value" | |
| 451 | + | ); | |
| 452 | + | } | |
| 453 | + | Ok(()) | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | async fn user_get(client: &Client, opts: &SecretOpts, name: &str) -> Result<()> { | |
| 457 | + | let state = client.fetch_user().await?; | |
| 458 | + | let identity = load_identity(opts)?; | |
| 459 | + | let secret = state | |
| 460 | + | .secrets | |
| 461 | + | .iter() | |
| 462 | + | .find(|s| s.name == name) | |
| 463 | + | .ok_or_else(|| anyhow!("no user secret named {name}"))?; | |
| 464 | + | let envelope = secret.parse()?; | |
| 465 | + | let plaintext = envelope.open(&user_aad(&client.username, name), &identity)?; | |
| 466 | + | print!("{}", String::from_utf8_lossy(&plaintext)); | |
| 467 | + | Ok(()) | |
| 468 | + | } | |
| 469 | + | ||
| 470 | + | async fn user_unlock(client: &Client, opts: &SecretOpts, ttl: &str) -> Result<()> { | |
| 471 | + | let state = client.fetch_user().await?; | |
| 472 | + | if state.secrets.is_empty() { | |
| 473 | + | bail!("no user secrets to unlock"); | |
| 474 | + | } | |
| 475 | + | let identity = load_identity(opts)?; | |
| 476 | + | let mut values = BTreeMap::new(); | |
| 477 | + | for secret in &state.secrets { | |
| 478 | + | let envelope = secret.parse()?; | |
| 479 | + | let plaintext = envelope | |
| 480 | + | .open(&user_aad(&client.username, &secret.name), &identity) | |
| 481 | + | .with_context(|| format!("opening {}", secret.name))?; | |
| 482 | + | values.insert( | |
| 483 | + | secret.name.clone(), | |
| 484 | + | String::from_utf8(plaintext) | |
| 485 | + | .with_context(|| format!("{} is not valid UTF-8", secret.name))?, | |
| 486 | + | ); | |
| 487 | + | } | |
| 488 | + | let response = client.unlock_user(values, parse_ttl(ttl)?).await?; | |
| 489 | + | println!( | |
| 490 | + | "unlocked {} value(s) until {} — held in memory only, and lost on restart", | |
| 491 | + | response.count, | |
| 492 | + | fmt_time(response.unlocked_until) | |
| 493 | + | ); | |
| 494 | + | Ok(()) | |
| 495 | + | } | |
| 496 | + | ||
| 497 | + | async fn user_rekey(client: &Client, opts: &SecretOpts) -> Result<()> { | |
| 498 | + | let state = client.fetch_user().await?; | |
| 499 | + | let recipients = state.recipient_keys()?; | |
| 500 | + | let identity = load_identity(opts)?; | |
| 501 | + | let mut rekeyed = 0; | |
| 502 | + | for secret in &state.secrets { | |
| 503 | + | let current: Vec<String> = recipients.iter().map(|r| r.fingerprint.clone()).collect(); | |
| 504 | + | if current.len() == secret.recipients.len() | |
| 505 | + | && current.iter().all(|fp| secret.recipients.contains(fp)) | |
| 506 | + | { | |
| 507 | + | continue; | |
| 508 | + | } | |
| 509 | + | let aad = user_aad(&client.username, &secret.name); | |
| 510 | + | let plaintext = secret | |
| 511 | + | .parse()? | |
| 512 | + | .open(&aad, &identity) | |
| 513 | + | .with_context(|| format!("opening {}", secret.name))?; | |
| 514 | + | let resealed = seal(&plaintext, &aad, &recipients)?; | |
| 515 | + | client | |
| 516 | + | .put_user( | |
| 517 | + | &secret.name, | |
| 518 | + | &secret.kind, | |
| 519 | + | &secret.dest_path, | |
| 520 | + | &secret.field, | |
| 521 | + | &resealed, | |
| 522 | + | ) | |
| 523 | + | .await?; | |
| 524 | + | println!("re-sealed {} to {} key(s)", secret.name, recipients.len()); | |
| 525 | + | rekeyed += 1; | |
| 526 | + | } | |
| 527 | + | if rekeyed == 0 { | |
| 528 | + | println!("nothing to do — every secret is already sealed to the current keys"); | |
| 529 | + | } | |
| 530 | + | Ok(()) | |
| 531 | + | } | |
| 532 | + | ||
| 262 | 533 | // --- identity -------------------------------------------------------------- | |
| 263 | 534 | ||
| 264 | 535 | fn load_identity(opts: &SecretOpts) -> Result<Identity> { | |
| ⋯ 91 unchanged lines | |||
| 356 | 627 | } | |
| 357 | 628 | } | |
| 358 | 629 | ||
| 630 | + | #[derive(Deserialize)] | |
| 631 | + | struct UserSecretsState { | |
| 632 | + | unlocked_until: i64, | |
| 633 | + | recipients: Vec<RecipientJson>, | |
| 634 | + | secrets: Vec<UserSecretJson>, | |
| 635 | + | } | |
| 636 | + | ||
| 637 | + | #[derive(Deserialize)] | |
| 638 | + | struct UserSecretJson { | |
| 639 | + | name: String, | |
| 640 | + | kind: String, | |
| 641 | + | dest_path: String, | |
| 642 | + | field: String, | |
| 643 | + | envelope: serde_json::Value, | |
| 644 | + | recipients: Vec<String>, | |
| 645 | + | } | |
| 646 | + | ||
| 647 | + | impl UserSecretsState { | |
| 648 | + | fn recipient_keys(&self) -> Result<Vec<Recipient>> { | |
| 649 | + | if self.recipients.is_empty() { | |
| 650 | + | bail!("you have no ssh-ed25519 key registered — add one first"); | |
| 651 | + | } | |
| 652 | + | self.recipients | |
| 653 | + | .iter() | |
| 654 | + | .map(|r| Recipient::from_openssh(&r.key).map_err(Into::into)) | |
| 655 | + | .collect() | |
| 656 | + | } | |
| 657 | + | } | |
| 658 | + | ||
| 659 | + | impl UserSecretJson { | |
| 660 | + | fn parse(&self) -> Result<Envelope> { | |
| 661 | + | Ok(Envelope::parse(&serde_json::to_string(&self.envelope)?)?) | |
| 662 | + | } | |
| 663 | + | } | |
| 664 | + | ||
| 359 | 665 | impl Client { | |
| 360 | 666 | fn new(opts: &SecretOpts, config_base_url: &str) -> Result<Self> { | |
| 361 | 667 | // HTTPS needs a crypto provider installed; the build deliberately has | |
| ⋯ 94 unchanged lines | |||
| 456 | 762 | check(response).await?; | |
| 457 | 763 | Ok(()) | |
| 458 | 764 | } | |
| 765 | + | ||
| 766 | + | fn user_url(&self, suffix: &str) -> String { | |
| 767 | + | format!("{}/-/api/user/secrets{suffix}", self.base) | |
| 768 | + | } | |
| 769 | + | ||
| 770 | + | async fn fetch_user(&self) -> Result<UserSecretsState> { | |
| 771 | + | let response = self | |
| 772 | + | .http | |
| 773 | + | .get(self.user_url("")) | |
| 774 | + | .basic_auth(&self.username, Some(&self.password)) | |
| 775 | + | .send() | |
| 776 | + | .await | |
| 777 | + | .context("contacting anvil")?; | |
| 778 | + | check(response).await?.json().await.context("reading reply") | |
| 779 | + | } | |
| 780 | + | ||
| 781 | + | async fn put_user( | |
| 782 | + | &self, | |
| 783 | + | name: &str, | |
| 784 | + | put_kind: &str, | |
| 785 | + | dest_path: &str, | |
| 786 | + | field: &str, | |
| 787 | + | envelope: &Envelope, | |
| 788 | + | ) -> Result<()> { | |
| 789 | + | let response = self | |
| 790 | + | .http | |
| 791 | + | .post(self.user_url("")) | |
| 792 | + | .basic_auth(&self.username, Some(&self.password)) | |
| 793 | + | .json(&serde_json::json!({ | |
| 794 | + | "name": name, | |
| 795 | + | "kind": put_kind, | |
| 796 | + | "dest_path": dest_path, | |
| 797 | + | "field": field, | |
| 798 | + | "envelope": envelope, | |
| 799 | + | })) | |
| 800 | + | .send() | |
| 801 | + | .await | |
| 802 | + | .context("contacting anvil")?; | |
| 803 | + | check(response).await?; | |
| 804 | + | Ok(()) | |
| 805 | + | } | |
| 806 | + | ||
| 807 | + | async fn delete_user(&self, name: &str) -> Result<()> { | |
| 808 | + | let response = self | |
| 809 | + | .http | |
| 810 | + | .delete(self.user_url(&format!("/{name}"))) | |
| 811 | + | .basic_auth(&self.username, Some(&self.password)) | |
| 812 | + | .send() | |
| 813 | + | .await | |
| 814 | + | .context("contacting anvil")?; | |
| 815 | + | check(response).await?; | |
| 816 | + | Ok(()) | |
| 817 | + | } | |
| 818 | + | ||
| 819 | + | async fn unlock_user( | |
| 820 | + | &self, | |
| 821 | + | values: BTreeMap<String, String>, | |
| 822 | + | ttl_secs: i64, | |
| 823 | + | ) -> Result<UnlockResponse> { | |
| 824 | + | let response = self | |
| 825 | + | .http | |
| 826 | + | .post(self.user_url("/unlock")) | |
| 827 | + | .basic_auth(&self.username, Some(&self.password)) | |
| 828 | + | .json(&serde_json::json!({ "values": values, "ttl_secs": ttl_secs })) | |
| 829 | + | .send() | |
| 830 | + | .await | |
| 831 | + | .context("contacting anvil")?; | |
| 832 | + | check(response).await?.json().await.context("reading reply") | |
| 833 | + | } | |
| 834 | + | ||
| 835 | + | async fn lock_user(&self) -> Result<()> { | |
| 836 | + | let response = self | |
| 837 | + | .http | |
| 838 | + | .post(self.user_url("/lock")) | |
| 839 | + | .basic_auth(&self.username, Some(&self.password)) | |
| 840 | + | .send() | |
| 841 | + | .await | |
| 842 | + | .context("contacting anvil")?; | |
| 843 | + | check(response).await?; | |
| 844 | + | Ok(()) | |
| 845 | + | } | |
| 459 | 846 | } | |
| 460 | 847 | ||
| 461 | 848 | async fn check(response: reqwest::Response) -> Result<reqwest::Response> { | |
| ⋯ 62 unchanged lines | |||
modifiedcrates/anvil-core/Cargo.toml+3 −0
| ⋯ 26 unchanged lines | |||
| 27 | 27 | tracing.workspace = true | |
| 28 | 28 | tokio.workspace = true | |
| 29 | 29 | pulldown-cmark.workspace = true | |
| 30 | + | jaq-core = "3.1.0" | |
| 31 | + | jaq-json = "2.0.2" | |
| 32 | + | jaq-std = { version = "3.0.2", default-features = false, features = ["std"] } | |
| 30 | 33 | ||
| 31 | 34 | [dev-dependencies] | |
| 32 | 35 | tokio = { workspace = true } | |
| ⋯ 5 unchanged lines | |||
modifiedcrates/anvil-core/src/agent.rs+2 −0
| ⋯ 52 unchanged lines | |||
| 53 | 53 | base_commit: &str, | |
| 54 | 54 | prompt: &str, | |
| 55 | 55 | image: &str, | |
| 56 | + | secret_names: &str, | |
| 56 | 57 | ) -> Result<AgentSession> { | |
| 57 | 58 | let mut conn = db.clone(); | |
| 58 | 59 | let now = crate::now(); | |
| ⋯ 14 unchanged lines | |||
| 73 | 74 | last_attach_at: 0, | |
| 74 | 75 | exit_code: 0, | |
| 75 | 76 | error: "", | |
| 77 | + | secret_names: secret_names, | |
| 76 | 78 | }) | |
| 77 | 79 | .exec(&mut conn) | |
| 78 | 80 | .await?; | |
| ⋯ 212 unchanged lines | |||
modifiedcrates/anvil-core/src/db.rs+25 −2
| ⋯ 17 unchanged lines | |||
| 18 | 18 | Session, | |
| 19 | 19 | SshKey, | |
| 20 | 20 | User, | |
| 21 | + | UserSecret, | |
| 21 | 22 | }, | |
| 22 | 23 | }; | |
| 23 | 24 | ||
| ⋯ 25 unchanged lines | |||
| 49 | 50 | ApiToken, | |
| 50 | 51 | AdminCache, | |
| 51 | 52 | RepoSecret, | |
| 52 | - | AgentSession | |
| 53 | + | AgentSession, | |
| 54 | + | UserSecret | |
| 53 | 55 | )) | |
| 54 | 56 | .connect(&url) | |
| 55 | 57 | .await?; | |
| ⋯ 29 unchanged lines | |||
| 85 | 87 | AGENT_SESSIONS_DDL, | |
| 86 | 88 | r#"CREATE INDEX IF NOT EXISTS "index_agent_sessions_by_repo_id" ON "agent_sessions" ("repo_id")"#, | |
| 87 | 89 | r#"CREATE INDEX IF NOT EXISTS "index_agent_sessions_by_status" ON "agent_sessions" ("status")"#, | |
| 90 | + | USER_SECRETS_DDL, | |
| 91 | + | r#"CREATE INDEX IF NOT EXISTS "index_user_secrets_by_user_id" ON "user_secrets" ("user_id")"#, | |
| 88 | 92 | ]; | |
| 89 | 93 | ||
| 90 | 94 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| ⋯ 79 unchanged lines | |||
| 170 | 174 | "finished_at" BIGINT NOT NULL, | |
| 171 | 175 | "last_attach_at" BIGINT NOT NULL, | |
| 172 | 176 | "exit_code" BIGINT NOT NULL, | |
| 173 | - | "error" TEXT NOT NULL )"#; | |
| 177 | + | "error" TEXT NOT NULL, | |
| 178 | + | "secret_names" TEXT NOT NULL )"#; | |
| 174 | 179 | ||
| 180 | + | const USER_SECRETS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "user_secrets" ( | |
| 181 | + | "id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT, | |
| 182 | + | "user_id" BIGINT NOT NULL, | |
| 183 | + | "name" TEXT NOT NULL, | |
| 184 | + | "kind" TEXT NOT NULL, | |
| 185 | + | "dest_path" TEXT NOT NULL, | |
| 186 | + | "field" TEXT NOT NULL, | |
| 187 | + | "envelope" TEXT NOT NULL, | |
| 188 | + | "recipients" TEXT NOT NULL, | |
| 189 | + | "created_at" BIGINT NOT NULL, | |
| 190 | + | "updated_at" BIGINT NOT NULL )"#; | |
| 191 | + | ||
| 175 | 192 | /// Columns added to existing tables after deployment, applied as | |
| 176 | 193 | /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS` | |
| 177 | 194 | /// for columns, so presence is checked via `pragma_table_info`). The model | |
| ⋯ 26 unchanged lines | |||
| 204 | 221 | "sso_sub", | |
| 205 | 222 | r#"ALTER TABLE "users" ADD COLUMN "sso_sub" TEXT NOT NULL DEFAULT ''"#, | |
| 206 | 223 | ), | |
| 224 | + | ( | |
| 225 | + | "agent_sessions", | |
| 226 | + | "secret_names", | |
| 227 | + | r#"ALTER TABLE "agent_sessions" ADD COLUMN "secret_names" TEXT NOT NULL DEFAULT ''"#, | |
| 228 | + | ), | |
| 207 | 229 | ]; | |
| 208 | 230 | ||
| 209 | 231 | /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses | |
| ⋯ 35 unchanged lines | |||
| 245 | 267 | "admin_caches", | |
| 246 | 268 | "repo_secrets", | |
| 247 | 269 | "agent_sessions", | |
| 270 | + | "user_secrets", | |
| 248 | 271 | ]; | |
| 249 | 272 | ||
| 250 | 273 | /// Every schema object (table + indexes) for `table`, normalized. | |
| ⋯ 129 unchanged lines | |||
modifiedcrates/anvil-core/src/lib.rs+7 −0
| ⋯ 68 unchanged lines | |||
| 69 | 69 | /// Plaintext repo secrets for CI, held in memory only and lost on | |
| 70 | 70 | /// restart — see [`secrets::Vault`]. | |
| 71 | 71 | pub vault: secrets::Vault, | |
| 72 | + | /// Plaintext user secrets for agent sessions — same shape as `vault`, | |
| 73 | + | /// just keyed by `user_id` instead of `repo_id`. A second instance | |
| 74 | + | /// rather than a shared one: repo and user secrets are unrelated | |
| 75 | + | /// namespaces, and mixing them into one map would risk an id collision | |
| 76 | + | /// mattering some day. | |
| 77 | + | pub user_vault: secrets::Vault, | |
| 72 | 78 | /// Agent sessions live in this process, keyed by session id. Empty after a | |
| 73 | 79 | /// restart, which is why startup reconciles rows against containers — see | |
| 74 | 80 | /// [`agent::Registry`]. | |
| ⋯ 18 unchanged lines | |||
| 93 | 99 | db, | |
| 94 | 100 | ci_tx: None, | |
| 95 | 101 | vault: secrets::Vault::default(), | |
| 102 | + | user_vault: secrets::Vault::default(), | |
| 96 | 103 | sessions: agent::Registry::default(), | |
| 97 | 104 | csrf_secret, | |
| 98 | 105 | }) | |
| ⋯ 53 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+43 −0
| ⋯ 155 unchanged lines | |||
| 156 | 156 | pub exit_code: i64, | |
| 157 | 157 | /// Supervisor-facing failure detail, empty when there is none. | |
| 158 | 158 | pub error: String, | |
| 159 | + | /// Comma-separated names of user secrets (`UserSecret`) this session | |
| 160 | + | /// opted into at start time. May name one that isn't currently | |
| 161 | + | /// unlocked — launch fails with a clear error rather than silently | |
| 162 | + | /// starting without it. Recorded (not just consumed) so a session's page | |
| 163 | + | /// can show what it could reach. New column: goes last, see `User.sso_sub`. | |
| 164 | + | pub secret_names: String, | |
| 159 | 165 | } | |
| 160 | 166 | ||
| 161 | 167 | /// An issue on a repository. `number` is the user-facing per-repo sequence | |
| ⋯ 134 unchanged lines | |||
| 296 | 302 | pub updated_at: i64, | |
| 297 | 303 | } | |
| 298 | 304 | ||
| 305 | + | /// A per-account secret, stored only as a sealed envelope, sealed to *its | |
| 306 | + | /// own owner's* ssh-ed25519 keys rather than a repository's — see | |
| 307 | + | /// [`RepoSecret`] for the shared envelope shape and the crypto notes. | |
| 308 | + | /// | |
| 309 | + | /// Consumed by that same account's agent sessions, which opt in to specific | |
| 310 | + | /// names by name at start time (`AgentSession::secret_names`) — never | |
| 311 | + | /// injected blanket into every session, since a session can be steered by | |
| 312 | + | /// repo content it reads (prompt injection). | |
| 313 | + | #[derive(Clone, Debug, toasty::Model)] | |
| 314 | + | pub struct UserSecret { | |
| 315 | + | #[key] | |
| 316 | + | #[auto] | |
| 317 | + | pub id: i64, | |
| 318 | + | #[index] | |
| 319 | + | pub user_id: i64, | |
| 320 | + | /// Unique per account. Also the environment variable name for `kind == | |
| 321 | + | /// "env"`; just an identifier otherwise. | |
| 322 | + | pub name: String, | |
| 323 | + | /// `"env"`, `"file"`, or `"json"` — see [`crate::secrets::kind`]. | |
| 324 | + | pub kind: String, | |
| 325 | + | /// Destination under the session's `$HOME` for `"file"`/`"json"` | |
| 326 | + | /// (e.g. `.claude/.credentials.json`); empty for `"env"`. Named | |
| 327 | + | /// `dest_path` rather than `path`: toasty's derive macro reserves `path` | |
| 328 | + | /// as a generated identifier on every model, and collides with a field | |
| 329 | + | /// of that name. | |
| 330 | + | pub dest_path: String, | |
| 331 | + | /// jq-style assignment path within `dest_path`'s JSON (e.g. | |
| 332 | + | /// `.oauthAccount.token`); only set, and only meaningful, for `"json"`. | |
| 333 | + | pub field: String, | |
| 334 | + | /// The sealed envelope, as JSON (`anvil-secret-v1`). | |
| 335 | + | pub envelope: String, | |
| 336 | + | /// Comma-separated SSH fingerprints the envelope is sealed to. | |
| 337 | + | pub recipients: String, | |
| 338 | + | pub created_at: i64, | |
| 339 | + | pub updated_at: i64, | |
| 340 | + | } | |
| 341 | + | ||
| 299 | 342 | /// Cached admin metrics computed periodically (e.g., disk usage snapshot). | |
| 300 | 343 | #[derive(Clone, Debug, toasty::Model)] | |
| 301 | 344 | pub struct AdminCache { | |
| ⋯ 10 unchanged lines | |||
modifiedcrates/anvil-core/src/secrets.rs+355 −1
| ⋯ 51 unchanged lines | |||
| 52 | 52 | Error, | |
| 53 | 53 | Result, | |
| 54 | 54 | }, | |
| 55 | - | models::RepoSecret, | |
| 55 | + | models::{ | |
| 56 | + | RepoSecret, | |
| 57 | + | UserSecret, | |
| 58 | + | }, | |
| 56 | 59 | }; | |
| 57 | 60 | ||
| 58 | 61 | /// Algorithm identifier carried in every envelope. | |
| ⋯ 268 unchanged lines | |||
| 327 | 330 | format!("anvil-secret-v1\n{owner}/{repo}\n{name}").into_bytes() | |
| 328 | 331 | } | |
| 329 | 332 | ||
| 333 | + | /// Associated data for a [`UserSecret`](UserSecret): the | |
| 334 | + | /// scheme, the owning account, and the variable name. A user-secret envelope | |
| 335 | + | /// and a repo-secret envelope never open under each other's AAD, even if a | |
| 336 | + | /// name collides, because `user:{username}` can never equal `{owner}/{repo}`. | |
| 337 | + | pub fn user_aad(username: &str, name: &str) -> Vec<u8> { | |
| 338 | + | format!("anvil-secret-v1\nuser:{username}\n{name}").into_bytes() | |
| 339 | + | } | |
| 340 | + | ||
| 341 | + | /// The three ways a [`UserSecret`](UserSecret) lands in a | |
| 342 | + | /// session container. | |
| 343 | + | pub mod kind { | |
| 344 | + | /// Injected as an environment variable named after the secret. | |
| 345 | + | pub const ENV: &str = "env"; | |
| 346 | + | /// Written whole as a file at the secret's `path`, under `$HOME`. | |
| 347 | + | pub const FILE: &str = "file"; | |
| 348 | + | /// Merged into one field (`field`, a jq-style path) of the JSON file at | |
| 349 | + | /// `path`, under `$HOME` — the rest of that file is left alone. | |
| 350 | + | pub const JSON: &str = "json"; | |
| 351 | + | } | |
| 352 | + | ||
| 330 | 353 | /// Whether `name` is usable as a shell environment variable: uppercase, | |
| 331 | 354 | /// digits, and underscores, not starting with a digit. | |
| 332 | 355 | pub fn valid_name(name: &str) -> bool { | |
| ⋯ 91 unchanged lines | |||
| 424 | 447 | scalar | |
| 425 | 448 | } | |
| 426 | 449 | ||
| 450 | + | // --- json merge (the "json" kind) ------------------------------------------- | |
| 451 | + | ||
| 452 | + | /// Every strict prefix of `field` that ends right before a top-level `.` | |
| 453 | + | /// (i.e. one outside `[...]` and quoted strings), shortest first. For | |
| 454 | + | /// `.oauthAccount.token` that's just `[".oauthAccount"]`; for `.a.b.c` it's | |
| 455 | + | /// `[".a", ".a.b"]`. Used to vivify each missing intermediate object before | |
| 456 | + | /// the final assignment — see the comment in [`json_merge`]. | |
| 457 | + | fn path_prefixes(field: &str) -> Vec<&str> { | |
| 458 | + | let mut prefixes = Vec::new(); | |
| 459 | + | let mut depth = 0i32; | |
| 460 | + | let mut in_quotes = false; | |
| 461 | + | for (i, b) in field.bytes().enumerate() { | |
| 462 | + | match b { | |
| 463 | + | b'"' => in_quotes = !in_quotes, | |
| 464 | + | b'[' if !in_quotes => depth += 1, | |
| 465 | + | b']' if !in_quotes => depth -= 1, | |
| 466 | + | b'.' if !in_quotes && depth == 0 && i > 0 => prefixes.push(&field[..i]), | |
| 467 | + | _ => {} | |
| 468 | + | } | |
| 469 | + | } | |
| 470 | + | prefixes | |
| 471 | + | } | |
| 472 | + | ||
| 473 | + | /// Set `field` (a jq-style path, e.g. `.oauthAccount.token`) to `value` | |
| 474 | + | /// within `current` (a JSON document, or empty for "start from `{}`"), | |
| 475 | + | /// returning the whole document with that one field changed. | |
| 476 | + | /// | |
| 477 | + | /// `value` is bound as a jq variable (`$__anvil_secret_value`) rather than | |
| 478 | + | /// interpolated into the filter text, so it is never parsed as jq syntax — | |
| 479 | + | /// only `field` is; it comes from the secret's own metadata (set by whoever | |
| 480 | + | /// created it), never from the decrypted plaintext. | |
| 481 | + | pub fn json_merge(current: &[u8], field: &str, value: &str) -> Result<Vec<u8>> { | |
| 482 | + | use jaq_core::{ | |
| 483 | + | Compiler, | |
| 484 | + | Ctx, | |
| 485 | + | Vars, | |
| 486 | + | data, | |
| 487 | + | load::{ | |
| 488 | + | Arena, | |
| 489 | + | File, | |
| 490 | + | Loader, | |
| 491 | + | }, | |
| 492 | + | unwrap_valr, | |
| 493 | + | }; | |
| 494 | + | use jaq_json::Val; | |
| 495 | + | ||
| 496 | + | let current = if current.is_empty() { | |
| 497 | + | b"{}".as_slice() | |
| 498 | + | } else { | |
| 499 | + | current | |
| 500 | + | }; | |
| 501 | + | let current = jaq_json::read::parse_single(current) | |
| 502 | + | .map_err(|e| Error::Invalid(format!("json secret: existing file is not JSON: {e}")))?; | |
| 503 | + | ||
| 504 | + | // Deliberately no jaq_std/jaq_json defs: `field = $value` is core jq | |
| 505 | + | // path/assignment syntax, entirely handled by jaq_core, and never names a | |
| 506 | + | // library filter. jaq_std's defs.jq is loaded as one unit — pulling it in | |
| 507 | + | // for the few basics jaq_json's own defs lean on drags in every other | |
| 508 | + | // definition too, including ones behind features (format/log/math/regex/ | |
| 509 | + | // time) this crate does not enable, which then fail to resolve even | |
| 510 | + | // though nothing here calls them. | |
| 511 | + | // | |
| 512 | + | // Real jq auto-creates missing intermediate objects (`{} | .a.b = 1` | |
| 513 | + | // gives `{"a":{"b":1}}`); jaq 3.1.1 does not — `setpath`/`=` error with | |
| 514 | + | // "cannot use null as iterable" the moment a path walks through a | |
| 515 | + | // missing key, confirmed against both jaq-core directly and the real | |
| 516 | + | // `jaq` CLI binary. `//=` (default-if-null) does not have that bug, so | |
| 517 | + | // each intermediate prefix of the path is vivified with one before the | |
| 518 | + | // final assignment. | |
| 519 | + | let mut program = String::new(); | |
| 520 | + | for prefix in path_prefixes(field) { | |
| 521 | + | program.push('('); | |
| 522 | + | program.push_str(prefix); | |
| 523 | + | program.push_str(" //= {}) | "); | |
| 524 | + | } | |
| 525 | + | program.push_str(field); | |
| 526 | + | program.push_str(" = $__anvil_secret_value"); | |
| 527 | + | let arena = Arena::default(); | |
| 528 | + | let modules = Loader::new(jaq_core::defs()) | |
| 529 | + | .load( | |
| 530 | + | &arena, | |
| 531 | + | File { | |
| 532 | + | path: (), | |
| 533 | + | code: program.as_str(), | |
| 534 | + | }, | |
| 535 | + | ) | |
| 536 | + | .map_err(|e| Error::Invalid(format!("json secret: bad jq path `{field}`: {e:?}")))?; | |
| 537 | + | ||
| 538 | + | let funs = jaq_core::funs().chain(jaq_json::funs()); | |
| 539 | + | let filter = Compiler::default() | |
| 540 | + | .with_funs(funs) | |
| 541 | + | .with_global_vars(["$__anvil_secret_value"]) | |
| 542 | + | .compile(modules) | |
| 543 | + | .map_err(|e| Error::Invalid(format!("json secret: bad jq path `{field}`: {e:?}")))?; | |
| 544 | + | ||
| 545 | + | let vars = Vars::new([Val::from(value.to_string())]); | |
| 546 | + | let ctx = Ctx::<data::JustLut<Val>>::new(&filter.lut, vars); | |
| 547 | + | let mut out = filter.id.run((ctx, current)).map(unwrap_valr); | |
| 548 | + | let result = out | |
| 549 | + | .next() | |
| 550 | + | .ok_or_else(|| Error::Invalid("json secret: jq path produced no output".into()))? | |
| 551 | + | .map_err(|e| Error::Invalid(format!("json secret: {e}")))?; | |
| 552 | + | ||
| 553 | + | let mut buf = Vec::new(); | |
| 554 | + | let pp = jaq_json::write::Pp { | |
| 555 | + | indent: Some(" ".to_string()), | |
| 556 | + | ..Default::default() | |
| 557 | + | }; | |
| 558 | + | jaq_json::write::write(&mut buf, &pp, 0, &result) | |
| 559 | + | .map_err(|e| Error::Invalid(format!("json secret: serializing result: {e}")))?; | |
| 560 | + | Ok(buf) | |
| 561 | + | } | |
| 562 | + | ||
| 427 | 563 | // --- persistence ----------------------------------------------------------- | |
| 428 | 564 | ||
| 429 | 565 | /// List a repository's secrets, oldest first. Envelopes are opaque here. | |
| ⋯ 72 unchanged lines | |||
| 502 | 638 | Ok(()) | |
| 503 | 639 | } | |
| 504 | 640 | ||
| 641 | + | // --- user secrets ------------------------------------------------------------ | |
| 642 | + | // | |
| 643 | + | // The same shape as the repository functions above, keyed by `user_id` | |
| 644 | + | // instead of `repo_id`. See [`UserSecret`]. | |
| 645 | + | ||
| 646 | + | /// List an account's secrets, oldest first. Envelopes are opaque here. | |
| 647 | + | pub async fn list_for_user(db: &toasty::Db, user_id: i64) -> Result<Vec<UserSecret>> { | |
| 648 | + | let mut conn = db.clone(); | |
| 649 | + | let mut secrets = UserSecret::filter(UserSecret::fields().user_id().eq(user_id)) | |
| 650 | + | .exec(&mut conn) | |
| 651 | + | .await?; | |
| 652 | + | secrets.sort_by(|a, b| a.name.cmp(&b.name)); | |
| 653 | + | Ok(secrets) | |
| 654 | + | } | |
| 655 | + | ||
| 656 | + | /// Look one up by name within an account. | |
| 657 | + | pub async fn find_for_user( | |
| 658 | + | db: &toasty::Db, | |
| 659 | + | user_id: i64, | |
| 660 | + | name: &str, | |
| 661 | + | ) -> Result<Option<UserSecret>> { | |
| 662 | + | Ok(list_for_user(db, user_id) | |
| 663 | + | .await? | |
| 664 | + | .into_iter() | |
| 665 | + | .find(|s| s.name == name)) | |
| 666 | + | } | |
| 667 | + | ||
| 668 | + | /// Create or replace a user secret. `envelope` must already have been parsed | |
| 669 | + | /// with [`Envelope::parse`]. `dest_path` must be non-empty for `kind::FILE`/ | |
| 670 | + | /// `kind::JSON` and empty for `kind::ENV`; `field` must be non-empty only for | |
| 671 | + | /// `kind::JSON`. | |
| 672 | + | #[allow(clippy::too_many_arguments)] | |
| 673 | + | pub async fn put_for_user( | |
| 674 | + | db: &toasty::Db, | |
| 675 | + | user_id: i64, | |
| 676 | + | name: &str, | |
| 677 | + | put_kind: &str, | |
| 678 | + | dest_path: &str, | |
| 679 | + | field: &str, | |
| 680 | + | envelope: &Envelope, | |
| 681 | + | ) -> Result<()> { | |
| 682 | + | if !valid_name(name) { | |
| 683 | + | return Err(Error::Invalid( | |
| 684 | + | "secret names are A–Z, 0–9 and _, and cannot start with a digit".into(), | |
| 685 | + | )); | |
| 686 | + | } | |
| 687 | + | // Always relative to $HOME by construction — strip a leading "~/" or "/" | |
| 688 | + | // so "~/.claude/x.json", "/.claude/x.json" and ".claude/x.json" all store | |
| 689 | + | // (and later inject) the same way. | |
| 690 | + | let dest_path = dest_path | |
| 691 | + | .strip_prefix("~/") | |
| 692 | + | .or_else(|| dest_path.strip_prefix('/')) | |
| 693 | + | .unwrap_or(dest_path); | |
| 694 | + | let has_path = !dest_path.is_empty(); | |
| 695 | + | let has_field = !field.is_empty(); | |
| 696 | + | match put_kind { | |
| 697 | + | kind::ENV if has_path || has_field => { | |
| 698 | + | return Err(Error::Invalid("env secrets take no path or field".into())); | |
| 699 | + | } | |
| 700 | + | kind::FILE if !has_path || has_field => { | |
| 701 | + | return Err(Error::Invalid( | |
| 702 | + | "file secrets need a path and take no field".into(), | |
| 703 | + | )); | |
| 704 | + | } | |
| 705 | + | kind::JSON if !has_path || !has_field => { | |
| 706 | + | return Err(Error::Invalid( | |
| 707 | + | "json secrets need both a path and a field".into(), | |
| 708 | + | )); | |
| 709 | + | } | |
| 710 | + | kind::ENV | kind::FILE | kind::JSON => {} | |
| 711 | + | _ => return Err(Error::Invalid(format!("unknown secret kind `{put_kind}`"))), | |
| 712 | + | } | |
| 713 | + | ||
| 714 | + | let json = serde_json::to_string(envelope) | |
| 715 | + | .map_err(|e| Error::Invalid(format!("serializing envelope: {e}")))?; | |
| 716 | + | let recipients = envelope.recipient_fingerprints().join(","); | |
| 717 | + | let now = crate::now(); | |
| 718 | + | let mut conn = db.clone(); | |
| 719 | + | match find_for_user(db, user_id, name).await? { | |
| 720 | + | Some(mut existing) => { | |
| 721 | + | existing | |
| 722 | + | .update() | |
| 723 | + | .kind(put_kind) | |
| 724 | + | .dest_path(dest_path) | |
| 725 | + | .field(field) | |
| 726 | + | .envelope(json) | |
| 727 | + | .recipients(recipients) | |
| 728 | + | .updated_at(now) | |
| 729 | + | .exec(&mut conn) | |
| 730 | + | .await?; | |
| 731 | + | } | |
| 732 | + | None => { | |
| 733 | + | toasty::create!(UserSecret { | |
| 734 | + | user_id: user_id, | |
| 735 | + | name: name, | |
| 736 | + | kind: put_kind, | |
| 737 | + | dest_path: dest_path, | |
| 738 | + | field: field, | |
| 739 | + | envelope: json, | |
| 740 | + | recipients: recipients, | |
| 741 | + | created_at: now, | |
| 742 | + | updated_at: now, | |
| 743 | + | }) | |
| 744 | + | .exec(&mut conn) | |
| 745 | + | .await?; | |
| 746 | + | } | |
| 747 | + | } | |
| 748 | + | Ok(()) | |
| 749 | + | } | |
| 750 | + | ||
| 751 | + | /// Delete a user secret by name. No-op if it does not exist. | |
| 752 | + | pub async fn delete_for_user(db: &toasty::Db, user_id: i64, name: &str) -> Result<()> { | |
| 753 | + | if let Some(secret) = find_for_user(db, user_id, name).await? { | |
| 754 | + | let mut conn = db.clone(); | |
| 755 | + | secret.delete().exec(&mut conn).await?; | |
| 756 | + | } | |
| 757 | + | Ok(()) | |
| 758 | + | } | |
| 759 | + | ||
| 760 | + | /// Delete every secret of an account (for account deletion, once that path | |
| 761 | + | /// exists — mirrors [`delete_all`]). | |
| 762 | + | pub async fn delete_all_for_user(db: &toasty::Db, user_id: i64) -> Result<()> { | |
| 763 | + | for secret in list_for_user(db, user_id).await? { | |
| 764 | + | let mut conn = db.clone(); | |
| 765 | + | secret.delete().exec(&mut conn).await?; | |
| 766 | + | } | |
| 767 | + | Ok(()) | |
| 768 | + | } | |
| 769 | + | ||
| 505 | 770 | // --- the unlock vault ------------------------------------------------------ | |
| 506 | 771 | ||
| 507 | 772 | /// Plaintext secrets for unlocked repositories, held in memory only. | |
| ⋯ 126 unchanged lines | |||
| 634 | 899 | ||
| 635 | 900 | use super::*; | |
| 636 | 901 | ||
| 902 | + | #[test] | |
| 903 | + | fn json_merge_sets_a_top_level_field_on_empty_input() { | |
| 904 | + | let out = json_merge(b"", ".token", "hunter2").unwrap(); | |
| 905 | + | let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); | |
| 906 | + | assert_eq!(v, serde_json::json!({"token": "hunter2"})); | |
| 907 | + | } | |
| 908 | + | ||
| 909 | + | #[test] | |
| 910 | + | fn json_merge_creates_intermediate_objects() { | |
| 911 | + | let out = json_merge(b"", ".oauthAccount.token", "hunter2").unwrap(); | |
| 912 | + | let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); | |
| 913 | + | assert_eq!(v, serde_json::json!({"oauthAccount": {"token": "hunter2"}})); | |
| 914 | + | } | |
| 915 | + | ||
| 916 | + | #[test] | |
| 917 | + | fn json_merge_preserves_sibling_fields() { | |
| 918 | + | let existing = br#"{"theme":"auto","oauthAccount":{"other":1}}"#; | |
| 919 | + | let out = json_merge(existing, ".oauthAccount.token", "hunter2").unwrap(); | |
| 920 | + | let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); | |
| 921 | + | assert_eq!( | |
| 922 | + | v, | |
| 923 | + | serde_json::json!({"theme": "auto", "oauthAccount": {"other": 1, "token": "hunter2"}}) | |
| 924 | + | ); | |
| 925 | + | } | |
| 926 | + | ||
| 927 | + | #[test] | |
| 928 | + | fn json_merge_does_not_interpolate_the_value_as_jq_syntax() { | |
| 929 | + | // A value that looks like a jq injection attempt must land as a | |
| 930 | + | // literal string, not be evaluated. | |
| 931 | + | let out = json_merge(b"", ".token", "\" | .pwned = true # ").unwrap(); | |
| 932 | + | let v: serde_json::Value = serde_json::from_slice(&out).unwrap(); | |
| 933 | + | assert_eq!(v, serde_json::json!({"token": "\" | .pwned = true # "})); | |
| 934 | + | } | |
| 935 | + | ||
| 936 | + | #[test] | |
| 937 | + | fn json_merge_rejects_bad_paths() { | |
| 938 | + | assert!(json_merge(b"{}", "not a jq path", "x").is_err()); | |
| 939 | + | } | |
| 940 | + | ||
| 637 | 941 | fn keypair() -> (PrivateKey, Recipient) { | |
| 638 | 942 | let key = PrivateKey::from(Ed25519Keypair::from_seed(&random_bytes())); | |
| 639 | 943 | let line = key.public_key().to_openssh().unwrap(); | |
| ⋯ 40 unchanged lines | |||
| 680 | 984 | } | |
| 681 | 985 | ||
| 682 | 986 | #[test] | |
| 987 | + | fn user_aad_round_trips_and_never_opens_under_a_repo_aad() { | |
| 988 | + | let (key, r) = keypair(); | |
| 989 | + | let id = Identity::from_private_key(&key).unwrap(); | |
| 990 | + | let env = seal( | |
| 991 | + | b"hunter2", | |
| 992 | + | &user_aad("collin", "TOKEN"), | |
| 993 | + | std::slice::from_ref(&r), | |
| 994 | + | ) | |
| 995 | + | .unwrap(); | |
| 996 | + | assert_eq!( | |
| 997 | + | env.open(&user_aad("collin", "TOKEN"), &id).unwrap(), | |
| 998 | + | b"hunter2" | |
| 999 | + | ); | |
| 1000 | + | // No repo name can ever collide with "user:{username}": the AAD | |
| 1001 | + | // schemes are namespace-disjoint by construction. | |
| 1002 | + | assert!( | |
| 1003 | + | env.open(&body_aad("collin", "TOKEN", "TOKEN"), &id) | |
| 1004 | + | .is_err() | |
| 1005 | + | ); | |
| 1006 | + | ||
| 1007 | + | // And the reverse: a repo secret cannot be opened as a user secret. | |
| 1008 | + | let repo_env = seal(b"hunter2", &body_aad("collin", "anvil", "TOKEN"), &[r]).unwrap(); | |
| 1009 | + | assert!(repo_env.open(&user_aad("collin", "TOKEN"), &id).is_err()); | |
| 1010 | + | } | |
| 1011 | + | ||
| 1012 | + | #[test] | |
| 1013 | + | fn vault_take_is_a_per_call_allowlist() { | |
| 1014 | + | let vault = Vault::default(); | |
| 1015 | + | let mut values = std::collections::BTreeMap::new(); | |
| 1016 | + | values.insert("A".to_string(), "1".to_string()); | |
| 1017 | + | values.insert("B".to_string(), "2".to_string()); | |
| 1018 | + | vault.unlock(1, values, 3600); | |
| 1019 | + | ||
| 1020 | + | // Asking for a subset returns exactly that subset. | |
| 1021 | + | let got = vault.take(1, &["A".to_string()]).unwrap(); | |
| 1022 | + | assert_eq!(got, vec![("A".to_string(), "1".to_string())]); | |
| 1023 | + | ||
| 1024 | + | // Asking for a name that was never unlocked reports it missing, | |
| 1025 | + | // even though other names for the same key are available. | |
| 1026 | + | let missing = vault | |
| 1027 | + | .take(1, &["A".to_string(), "C".to_string()]) | |
| 1028 | + | .unwrap_err(); | |
| 1029 | + | assert_eq!(missing, vec!["C".to_string()]); | |
| 1030 | + | ||
| 1031 | + | // A key with nothing unlocked reports every requested name missing. | |
| 1032 | + | let missing = vault.take(2, &["A".to_string()]).unwrap_err(); | |
| 1033 | + | assert_eq!(missing, vec!["A".to_string()]); | |
| 1034 | + | } | |
| 1035 | + | ||
| 1036 | + | #[test] | |
| 683 | 1037 | fn tampering_with_the_body_is_detected() { | |
| 684 | 1038 | let (key, r) = keypair(); | |
| 685 | 1039 | let id = Identity::from_private_key(&key).unwrap(); | |
| ⋯ 36 unchanged lines | |||
addedcrates/anvil-docker/Cargo.toml+13 −0
| 1 | + | [package] | |
| 2 | + | name = "anvil-docker" | |
| 3 | + | version.workspace = true | |
| 4 | + | edition.workspace = true | |
| 5 | + | license.workspace = true | |
| 6 | + | repository.workspace = true | |
| 7 | + | rust-version.workspace = true | |
| 8 | + | description = "Docker connect/pull plumbing shared by anvil's job runner and agent-session supervisor." | |
| 9 | + | ||
| 10 | + | [dependencies] | |
| 11 | + | bollard.workspace = true | |
| 12 | + | futures-util.workspace = true | |
| 13 | + | tracing.workspace = true |
addedcrates/anvil-docker/src/lib.rs+75 −0
| 1 | + | //! Docker plumbing shared by the job runner (`anvil-worker`) and the | |
| 2 | + | //! agent-session supervisor (`anvil-agent`). | |
| 3 | + | //! | |
| 4 | + | //! Both create containers from the same runner image | |
| 5 | + | //! (`anvil_core::config::DEFAULT_RUNNER_IMAGE`) against the same daemon, so the | |
| 6 | + | //! connect/pull dance lives here rather than being written twice. Its own crate | |
| 7 | + | //! rather than a module of either, so `anvil-agent` need not depend on the | |
| 8 | + | //! runner and the runner need not depend on `anvil-core`. | |
| 9 | + | ||
| 10 | + | use bollard::{ | |
| 11 | + | Docker, | |
| 12 | + | image::CreateImageOptions, | |
| 13 | + | }; | |
| 14 | + | use futures_util::StreamExt; | |
| 15 | + | ||
| 16 | + | /// Connect to the daemon. | |
| 17 | + | /// | |
| 18 | + | /// Environment-aware (`DOCKER_HOST`, `DOCKER_CERT_PATH`, …) rather than the | |
| 19 | + | /// hardcoded `/var/run/docker.sock` this used to use. A runner on macOS is the | |
| 20 | + | /// reason: Docker Desktop creates that symlink only when "Allow the default | |
| 21 | + | /// Docker socket to be used" is ticked, and puts the real socket at | |
| 22 | + | /// `~/.docker/run/docker.sock`; Colima and OrbStack differ again. Falling back | |
| 23 | + | /// to the socket default when nothing is set keeps Linux behaviour identical. | |
| 24 | + | pub fn connect() -> Result<Docker, String> { | |
| 25 | + | Docker::connect_with_defaults() | |
| 26 | + | .map_err(|e| format!("docker unavailable (is DOCKER_HOST/the socket right?): {e}")) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | /// Make sure `image` is present locally, pulling it if it is not. | |
| 30 | + | /// | |
| 31 | + | /// A failed pull is only fatal when the image is *also* absent locally. anvil's | |
| 32 | + | /// own runner image is built by `deploy/runner/build.sh` straight into the | |
| 33 | + | /// host's image store and exists in no registry, so an unconditional pull — | |
| 34 | + | /// which is what this used to be — fails for the one image most jobs now use. | |
| 35 | + | pub async fn ensure_image(docker: &Docker, image: &str) -> Result<(), String> { | |
| 36 | + | // Split name:tag so we don't accidentally pull every tag. A ':' that has a | |
| 37 | + | // '/' after it is a registry port, not a tag. | |
| 38 | + | let (from_image, tag) = match image.rsplit_once(':') { | |
| 39 | + | Some((name, tag)) if !tag.contains('/') => (name.to_string(), tag.to_string()), | |
| 40 | + | _ => (image.to_string(), "latest".to_string()), | |
| 41 | + | }; | |
| 42 | + | ||
| 43 | + | let mut pull = docker.create_image( | |
| 44 | + | Some(CreateImageOptions { | |
| 45 | + | from_image, | |
| 46 | + | tag, | |
| 47 | + | ..Default::default() | |
| 48 | + | }), | |
| 49 | + | None, | |
| 50 | + | None, | |
| 51 | + | ); | |
| 52 | + | let mut pull_error = None; | |
| 53 | + | while let Some(item) = pull.next().await { | |
| 54 | + | if let Err(e) = item { | |
| 55 | + | pull_error = Some(e.to_string()); | |
| 56 | + | break; | |
| 57 | + | } | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | let Some(pull_error) = pull_error else { | |
| 61 | + | return Ok(()); | |
| 62 | + | }; | |
| 63 | + | ||
| 64 | + | // The pull failed. That is fine if the image is already here — the local | |
| 65 | + | // build case — and fatal otherwise. | |
| 66 | + | match docker.inspect_image(image).await { | |
| 67 | + | Ok(_) => { | |
| 68 | + | tracing::debug!("pull of {image} failed ({pull_error}); using the local image"); | |
| 69 | + | Ok(()) | |
| 70 | + | } | |
| 71 | + | Err(_) => Err(format!( | |
| 72 | + | "image {image} is not available locally and could not be pulled: {pull_error}" | |
| 73 | + | )), | |
| 74 | + | } | |
| 75 | + | } |
modifiedcrates/anvil-web/src/agent.rs+196 −73
| ⋯ 201 unchanged lines | |||
| 202 | 202 | .map_err(server_error)?; | |
| 203 | 203 | let enabled = app.config.agent.enabled; | |
| 204 | 204 | let csrf = crate::auth::current_csrf(); | |
| 205 | + | let user_secrets = match &user { | |
| 206 | + | Some(u) => anvil_core::secrets::list_for_user(&app.db, u.id) | |
| 207 | + | .await | |
| 208 | + | .map_err(server_error)?, | |
| 209 | + | None => Vec::new(), | |
| 210 | + | }; | |
| 205 | 211 | ||
| 206 | 212 | Ok(layout( | |
| 207 | - | &format!("{owner}/{name} · agent"), | |
| 213 | + | &format!("{owner}/{name} · Agent"), | |
| 208 | 214 | user.as_ref(), | |
| 209 | 215 | html! { | |
| 210 | - | h1 { "Agent sessions" } | |
| 216 | + | h1 { a href=(format!("/{owner}/{name}")) { (owner) "/" (name) } " · Agent" } | |
| 211 | 217 | @if !enabled { | |
| 212 | - | p.notice { | |
| 218 | + | p.muted { | |
| 213 | 219 | "Agent sessions are disabled. Set " | |
| 214 | 220 | code { "agent.enabled" } | |
| 215 | 221 | " in anvil.toml to turn them on." | |
| 216 | 222 | } | |
| 217 | 223 | } @else { | |
| 218 | - | form method="post" action={"/" (owner) "/" (name) "/-/agent"} { | |
| 224 | + | // hx-boost off: a boosted submit swaps the redirected page's | |
| 225 | + | // markup into the DOM via innerHTML, and per spec an | |
| 226 | + | // <script type="importmap"> inserted that way never takes | |
| 227 | + | // effect — the terminal page's module script would fail to | |
| 228 | + | // resolve "@wterm/core". Starting a session needs a real | |
| 229 | + | // navigation. | |
| 230 | + | form.stack hx-boost="false" method="post" action={"/" (owner) "/" (name) "/-/agent"} style="margin-bottom:24px" { | |
| 219 | 231 | (ui::csrf_input(&csrf)) | |
| 220 | - | label { | |
| 221 | - | "Start from branch " | |
| 222 | - | input type="text" name="base_ref" value="main" required; | |
| 232 | + | p { | |
| 233 | + | label { "Start from branch" br; input type="text" name="base_ref" value="main" required; } | |
| 223 | 234 | } | |
| 224 | - | label { | |
| 225 | - | "Opening prompt (optional — leave empty to drive it yourself)" | |
| 226 | - | textarea name="prompt" rows="3" {} | |
| 235 | + | p { | |
| 236 | + | label { | |
| 237 | + | "Opening prompt (optional — leave empty to drive it yourself)" br; | |
| 238 | + | textarea name="prompt" rows="3" {} | |
| 239 | + | } | |
| 240 | + | } | |
| 241 | + | @if !user_secrets.is_empty() { | |
| 242 | + | p { | |
| 243 | + | "Secrets for this session" | |
| 244 | + | @for secret in &user_secrets { | |
| 245 | + | br; | |
| 246 | + | label { | |
| 247 | + | input type="checkbox" name="secret_names" value=(secret.name); | |
| 248 | + | " " code { (secret.name) } | |
| 249 | + | " (" (secret.kind) | |
| 250 | + | @if !secret.dest_path.is_empty() { " → " code { (secret.dest_path) } } | |
| 251 | + | ")" | |
| 252 | + | } | |
| 253 | + | } | |
| 254 | + | br; | |
| 255 | + | span.muted style="font-size:12px" { | |
| 256 | + | "Only what's checked reaches this session — everything else stays out of reach, " | |
| 257 | + | "even if it's unlocked. Manage them in " a href="/-/settings" { "settings" } "." | |
| 258 | + | } | |
| 259 | + | } | |
| 227 | 260 | } | |
| 228 | - | button type="submit" { "Start session" } | |
| 261 | + | p { button.btn type="submit" { "Start session" } } | |
| 229 | 262 | } | |
| 230 | 263 | } | |
| 231 | 264 | @if sessions.is_empty() { | |
| 232 | - | p { "No sessions yet." } | |
| 265 | + | p.muted { "No sessions yet." } | |
| 233 | 266 | } @else { | |
| 234 | - | ul.sessions { | |
| 267 | + | div.box { | |
| 235 | 268 | @for session in &sessions { | |
| 236 | - | li { | |
| 237 | - | a href={"/" (owner) "/" (name) "/-/agent/" (session.id)} { | |
| 238 | - | "#" (session.id) | |
| 269 | + | div.row { | |
| 270 | + | // Same reasoning as the create form above: this can | |
| 271 | + | // land on a live session's page, which needs a real | |
| 272 | + | // navigation for its import map to take effect. | |
| 273 | + | a.entry hx-boost="false" href={"/" (owner) "/" (name) "/-/agent/" (session.id)} { | |
| 274 | + | (agent_status_badge(&session.status, session.exit_code)) | |
| 275 | + | span.sha { "#" (session.id) } | |
| 276 | + | span { (session.base_ref) } | |
| 239 | 277 | } | |
| 240 | - | " " span.status { (session.status) } | |
| 241 | - | " " span.muted { (session.base_ref) " @ " | |
| 242 | - | (short_commit(&session.base_commit)) } | |
| 243 | - | " " span.muted { (ui::fmt_relative(session.created_at)) } | |
| 278 | + | span.muted { (ui::fmt_relative(session.created_at)) } | |
| 244 | 279 | } | |
| 245 | 280 | } | |
| 246 | 281 | } | |
| ⋯ 2 unchanged lines | |||
| 249 | 284 | )) | |
| 250 | 285 | } | |
| 251 | 286 | ||
| 252 | - | fn short_commit(commit: &str) -> &str { | |
| 253 | - | &commit[..commit.len().min(12)] | |
| 287 | + | /// Like [`ui::status_badge`], but an agent session's ended state splits on the | |
| 288 | + | /// exit code rather than being its own status string: `exited` alone doesn't | |
| 289 | + | /// say whether the agent finished cleanly. | |
| 290 | + | fn agent_status_badge(status: &str, exit_code: i64) -> Markup { | |
| 291 | + | let class = if status == agent::status::EXITED && exit_code != 0 { | |
| 292 | + | "failed" | |
| 293 | + | } else { | |
| 294 | + | status | |
| 295 | + | }; | |
| 296 | + | html! { span class=(format!("st {class}")) { (status) } } | |
| 254 | 297 | } | |
| 255 | 298 | ||
| 256 | 299 | /// `POST /{owner}/{repo}/-/agent` — start a session. | |
| ⋯ 22 unchanged lines | |||
| 279 | 322 | kind::AUTONOMOUS | |
| 280 | 323 | }; | |
| 281 | 324 | ||
| 282 | - | match anvil_agent::start(&app, repo.id, user.id, session_kind, &base_ref, prompt).await { | |
| 325 | + | match anvil_agent::start( | |
| 326 | + | &app, | |
| 327 | + | repo.id, | |
| 328 | + | user.id, | |
| 329 | + | session_kind, | |
| 330 | + | &base_ref, | |
| 331 | + | prompt, | |
| 332 | + | &form.secret_names, | |
| 333 | + | ) | |
| 334 | + | .await | |
| 335 | + | { | |
| 283 | 336 | Ok(id) => Ok(Redirect::to(&format!("/{owner}/{name}/-/agent/{id}")).into_response()), | |
| 284 | 337 | // Being at capacity, or switched off, is a normal answer rather than a | |
| 285 | 338 | // fault — "something went wrong" would send someone hunting a bug that | |
| ⋯ 30 unchanged lines | |||
| 316 | 369 | base_ref: String, | |
| 317 | 370 | #[serde(default)] | |
| 318 | 371 | prompt: String, | |
| 372 | + | /// Names of the signed-in user's own secrets (`secrets::list_for_user`) | |
| 373 | + | /// this session opts into — an HTML checkbox group, so absent means | |
| 374 | + | /// none were checked, not "field missing". | |
| 375 | + | #[serde(default)] | |
| 376 | + | secret_names: Vec<String>, | |
| 319 | 377 | } | |
| 320 | 378 | ||
| 321 | 379 | /// `POST /{owner}/{repo}/-/agent/{id}/stop` | |
| ⋯ 41 unchanged lines | |||
| 363 | 421 | let ws_path = format!("/{owner}/{name}/-/agent/{id}/ws"); | |
| 364 | 422 | ||
| 365 | 423 | Ok(layout( | |
| 366 | - | &format!("{owner}/{name} · agent #{id}"), | |
| 424 | + | &format!("{owner}/{name} · Agent #{id}"), | |
| 367 | 425 | user.as_ref(), | |
| 368 | 426 | html! { | |
| 369 | - | h1 { "Agent session #" (id) } | |
| 427 | + | h1 { | |
| 428 | + | a href=(format!("/{owner}/{name}")) { (owner) "/" (name) } | |
| 429 | + | " · " a href=(format!("/{owner}/{name}/-/agent")) { "Agent" } | |
| 430 | + | " · #" (id) | |
| 431 | + | } | |
| 432 | + | p { | |
| 433 | + | (agent_status_badge(&session.status, session.exit_code)) | |
| 434 | + | " " span.sha { (ui::short_commit(&session.base_commit)) } | |
| 435 | + | " " span.muted { (session.base_ref) } | |
| 436 | + | @if live { | |
| 437 | + | " " form style="display:inline" method="post" action={(ws_path.trim_end_matches("/ws")) "/stop"} { | |
| 438 | + | (ui::csrf_input(&csrf)) | |
| 439 | + | button.linkbtn type="submit" { "stop" } | |
| 440 | + | } | |
| 441 | + | } | |
| 442 | + | } | |
| 370 | 443 | p.muted { | |
| 371 | - | (session.status) " · " (session.base_ref) " @ " | |
| 372 | - | (short_commit(&session.base_commit)) | |
| 444 | + | "started " (ui::fmt_time(session.created_at)) | |
| 445 | + | @if session.finished_at > 0 { " · finished " (ui::fmt_time(session.finished_at)) } | |
| 373 | 446 | @if !session.error.is_empty() { " · " (session.error) } | |
| 374 | 447 | } | |
| 375 | 448 | @if live { | |
| 376 | - | form method="post" action={(ws_path.trim_end_matches("/ws")) "/stop"} { | |
| 377 | - | (ui::csrf_input(&csrf)) | |
| 378 | - | button type="submit" { "Stop session" } | |
| 449 | + | link rel="stylesheet" href="/-/static/wterm/terminal.css"; | |
| 450 | + | div.box style="margin-top:16px" { | |
| 451 | + | div #terminal style="height:70vh" {} | |
| 379 | 452 | } | |
| 380 | - | link rel="stylesheet" href="/-/static/wterm/terminal.css"; | |
| 381 | - | div #terminal style="height:70vh" {} | |
| 382 | 453 | script type="importmap" { | |
| 383 | 454 | (PreEscaped(IMPORT_MAP)) | |
| 384 | 455 | } | |
| ⋯ 3 unchanged lines | |||
| 388 | 459 | TERMINAL_JS))) | |
| 389 | 460 | } | |
| 390 | 461 | } @else { | |
| 391 | - | p { "This session has ended." } | |
| 462 | + | p.muted { "This session has ended." } | |
| 392 | 463 | } | |
| 393 | 464 | }, | |
| 394 | 465 | )) | |
| ⋯ 19 unchanged lines | |||
| 414 | 485 | const encode = new TextEncoder(); | |
| 415 | 486 | let torndown = false; | |
| 416 | 487 | ||
| 488 | + | // A live WebSocket delivers `message`/`close` the instant they arrive — with | |
| 489 | + | // no listener attached yet, per spec that event is dropped, not queued. WASM | |
| 490 | + | // init below (`term.init()`) is async, so the connection can open and the | |
| 491 | + | // server's initial full-screen repaint can land before that finishes. Handlers | |
| 492 | + | // go on immediately; anything that arrives before the terminal exists is | |
| 493 | + | // buffered and flushed once it does, so nothing is silently lost. | |
| 494 | + | let ready = false; | |
| 495 | + | const pending = []; | |
| 496 | + | ||
| 417 | 497 | const sendResize = () => { | |
| 418 | 498 | if (ws.readyState === WebSocket.OPEN) { | |
| 419 | 499 | ws.send(JSON.stringify({ t: "resize", cols: term.cols, rows: term.rows })); | |
| 420 | 500 | } | |
| 421 | 501 | }; | |
| 422 | 502 | ||
| 423 | - | // Callbacks are read off the instance at call time, so setting them here (in | |
| 424 | - | // the constructor options) is the same as setting them later — this is just | |
| 425 | - | // the clearer place. `wasmUrl` is left unset on purpose: the built-in core's | |
| 426 | - | // WASM is inlined as base64, so there is no second request to make. | |
| 427 | - | const term = new WTerm(document.getElementById("terminal"), { | |
| 428 | - | autoResize: true, | |
| 429 | - | cursorBlink: true, | |
| 430 | - | // Keystrokes out. onData hands us a string; the wire carries bytes. | |
| 431 | - | onData: (data) => { | |
| 432 | - | if (ws.readyState === WebSocket.OPEN) ws.send(encode.encode(data)); | |
| 433 | - | }, | |
| 434 | - | // Fires on the ResizeObserver as well as an explicit resize(), so the | |
| 435 | - | // container's pty follows the browser window. | |
| 436 | - | onResize: sendResize, | |
| 437 | - | }); | |
| 438 | - | await term.init(); | |
| 439 | - | ||
| 440 | - | ws.onopen = sendResize; | |
| 441 | - | ||
| 442 | - | ws.onmessage = (event) => { | |
| 443 | - | // Binary is terminal bytes; text is our control channel. | |
| 503 | + | const renderMessage = (event) => { | |
| 444 | 504 | if (event.data instanceof ArrayBuffer) { | |
| 445 | 505 | term.write(new Uint8Array(event.data)); | |
| 446 | 506 | return; | |
| ⋯ 11 unchanged lines | |||
| 458 | 518 | } | |
| 459 | 519 | }; | |
| 460 | 520 | ||
| 521 | + | ws.onopen = sendResize; | |
| 522 | + | ws.onmessage = (event) => { | |
| 523 | + | if (ready) renderMessage(event); | |
| 524 | + | else pending.push(event); | |
| 525 | + | }; | |
| 461 | 526 | ws.onclose = () => { | |
| 462 | - | if (!torndown) term.write("\r\n\x1b[2m[disconnected]\x1b[0m\r\n"); | |
| 527 | + | if (torndown) return; | |
| 528 | + | const note = () => term.write("\r\n\x1b[2m[disconnected]\x1b[0m\r\n"); | |
| 529 | + | if (ready) note(); | |
| 530 | + | else pending.push({ data: null, note }); | |
| 463 | 531 | }; | |
| 464 | 532 | ||
| 533 | + | // Callbacks are read off the instance at call time, so setting them here (in | |
| 534 | + | // the constructor options) is the same as setting them later — this is just | |
| 535 | + | // the clearer place. `wasmUrl` is left unset on purpose: the built-in core's | |
| 536 | + | // WASM is inlined as base64, so there is no second request to make. | |
| 537 | + | const term = new WTerm(document.getElementById("terminal"), { | |
| 538 | + | autoResize: true, | |
| 539 | + | cursorBlink: true, | |
| 540 | + | // Keystrokes out. onData hands us a string; the wire carries bytes. | |
| 541 | + | onData: (data) => { | |
| 542 | + | if (ws.readyState === WebSocket.OPEN) ws.send(encode.encode(data)); | |
| 543 | + | }, | |
| 544 | + | // Fires on the ResizeObserver as well as an explicit resize(), so the | |
| 545 | + | // container's pty follows the browser window. | |
| 546 | + | onResize: sendResize, | |
| 547 | + | }); | |
| 548 | + | await term.init(); | |
| 549 | + | ||
| 550 | + | ready = true; | |
| 551 | + | for (const item of pending.splice(0)) { | |
| 552 | + | if (item.note) item.note(); | |
| 553 | + | else renderMessage(item); | |
| 554 | + | } | |
| 555 | + | ||
| 465 | 556 | // The layout sets hx-boost on <body>, so navigating away swaps the DOM without | |
| 466 | 557 | // a page load. Without this teardown the socket and the WASM instance would | |
| 467 | 558 | // leak on every navigation. | |
| ⋯ 53 unchanged lines | |||
| 521 | 612 | mut input, | |
| 522 | 613 | } = terminal; | |
| 523 | 614 | ||
| 615 | + | tracing::info!("agent: session {session_id} viewer attached (exec {exec_id})"); | |
| 616 | + | ||
| 524 | 617 | // Container -> browser. | |
| 525 | 618 | let mut to_browser = tokio::spawn(async move { | |
| 526 | - | while let Some(chunk) = output.next().await { | |
| 527 | - | let Ok(log) = chunk else { break }; | |
| 528 | - | let bytes = log.into_bytes(); | |
| 529 | - | if bytes.is_empty() { | |
| 530 | - | continue; | |
| 531 | - | } | |
| 532 | - | if sink | |
| 533 | - | .send(Message::Binary(bytes.to_vec().into())) | |
| 534 | - | .await | |
| 535 | - | .is_err() | |
| 536 | - | { | |
| 537 | - | break; | |
| 619 | + | let mut total_bytes: u64 = 0; | |
| 620 | + | loop { | |
| 621 | + | match output.next().await { | |
| 622 | + | Some(Ok(log)) => { | |
| 623 | + | let bytes = log.into_bytes(); | |
| 624 | + | if bytes.is_empty() { | |
| 625 | + | continue; | |
| 626 | + | } | |
| 627 | + | total_bytes += bytes.len() as u64; | |
| 628 | + | if let Err(e) = sink.send(Message::Binary(bytes.to_vec().into())).await { | |
| 629 | + | tracing::info!( | |
| 630 | + | "agent: session {session_id} viewer socket send failed after {total_bytes} byte(s): {e}" | |
| 631 | + | ); | |
| 632 | + | break; | |
| 633 | + | } | |
| 634 | + | } | |
| 635 | + | Some(Err(e)) => { | |
| 636 | + | tracing::warn!( | |
| 637 | + | "agent: session {session_id} exec output ended after {total_bytes} byte(s): {e}" | |
| 638 | + | ); | |
| 639 | + | break; | |
| 640 | + | } | |
| 641 | + | None => { | |
| 642 | + | tracing::info!( | |
| 643 | + | "agent: session {session_id} exec output closed after {total_bytes} byte(s) (tmux client detached or the container is gone)" | |
| 644 | + | ); | |
| 645 | + | break; | |
| 646 | + | } | |
| 538 | 647 | } | |
| 539 | 648 | } | |
| 540 | 649 | let _ = sink.close().await; | |
| ⋯ 3 unchanged lines | |||
| 544 | 653 | let control_docker = docker.clone(); | |
| 545 | 654 | let control_exec = exec_id.clone(); | |
| 546 | 655 | let mut to_container = tokio::spawn(async move { | |
| 547 | - | while let Some(message) = stream.next().await { | |
| 548 | - | match message { | |
| 549 | - | Ok(Message::Binary(data)) => { | |
| 550 | - | if input.write_all(&data).await.is_err() { | |
| 656 | + | loop { | |
| 657 | + | match stream.next().await { | |
| 658 | + | Some(Ok(Message::Binary(data))) => { | |
| 659 | + | if let Err(e) = input.write_all(&data).await { | |
| 660 | + | tracing::info!("agent: session {session_id} exec input closed: {e}"); | |
| 551 | 661 | break; | |
| 552 | 662 | } | |
| 553 | 663 | let _ = input.flush().await; | |
| 554 | 664 | } | |
| 555 | - | Ok(Message::Text(text)) => { | |
| 665 | + | Some(Ok(Message::Text(text))) => { | |
| 556 | 666 | if let Some((cols, rows)) = parse_resize(&text) { | |
| 557 | 667 | anvil_agent::container::resize(&control_docker, &control_exec, cols, rows) | |
| 558 | 668 | .await; | |
| 559 | 669 | } | |
| 560 | 670 | } | |
| 561 | - | Ok(Message::Close(_)) | Err(_) => break, | |
| 671 | + | Some(Ok(Message::Close(frame))) => { | |
| 672 | + | tracing::info!( | |
| 673 | + | "agent: session {session_id} viewer socket sent close: {frame:?}" | |
| 674 | + | ); | |
| 675 | + | break; | |
| 676 | + | } | |
| 677 | + | Some(Err(e)) => { | |
| 678 | + | tracing::info!("agent: session {session_id} viewer socket error: {e}"); | |
| 679 | + | break; | |
| 680 | + | } | |
| 681 | + | None => { | |
| 682 | + | tracing::info!("agent: session {session_id} viewer socket ended"); | |
| 683 | + | break; | |
| 684 | + | } | |
| 562 | 685 | _ => {} | |
| 563 | 686 | } | |
| 564 | 687 | } | |
| ⋯ 45 unchanged lines | |||
modifiedcrates/anvil-web/src/secrets.rs+591 −68
| ⋯ 80 unchanged lines | |||
| 81 | 81 | // Plain form posts from the settings page (no JSON, no plaintext). | |
| 82 | 82 | .route("/{owner}/{repo}/-/secrets/{name}/delete", post(ui_delete)) | |
| 83 | 83 | .route("/{owner}/{repo}/-/secrets/lock", post(ui_lock)) | |
| 84 | + | // User secrets: same shape, no {owner}/{repo} — always the caller's own. | |
| 85 | + | .route( | |
| 86 | + | "/-/api/user/secrets", | |
| 87 | + | get(list_user_secrets).post(put_user_secret), | |
| 88 | + | ) | |
| 89 | + | .route( | |
| 90 | + | "/-/api/user/secrets/{name}", | |
| 91 | + | axum::routing::delete(delete_user_secret), | |
| 92 | + | ) | |
| 93 | + | .route("/-/api/user/secrets/unlock", post(unlock_user)) | |
| 94 | + | .route("/-/api/user/secrets/lock", post(lock_user)) | |
| 95 | + | .route("/-/settings/secrets/{name}/delete", post(ui_delete_user)) | |
| 96 | + | .route("/-/settings/secrets/lock", post(ui_lock_user)) | |
| 84 | 97 | } | |
| 85 | 98 | ||
| 86 | 99 | // --- request plumbing ------------------------------------------------------ | |
| ⋯ 320 unchanged lines | |||
| 407 | 420 | Ok(meta) | |
| 408 | 421 | } | |
| 409 | 422 | ||
| 410 | - | // --- shared helpers -------------------------------------------------------- | |
| 423 | + | // --- user secrets (JSON API) ------------------------------------------------- | |
| 424 | + | // | |
| 425 | + | // Same shape as the repository handlers above, minus the repository: the | |
| 426 | + | // target is always the authenticated caller's own account, so there is no | |
| 427 | + | // resolve-and-check-access step — being signed in (or presenting valid Basic | |
| 428 | + | // credentials) is the only authorization a user's own secrets need. | |
| 429 | + | ||
| 430 | + | /// Resolve the authenticated account, the same two ways [`authorize`] does. | |
| 431 | + | async fn user_authorize( | |
| 432 | + | app: &App, | |
| 433 | + | session_user: Option<User>, | |
| 434 | + | csrf: &Csrf, | |
| 435 | + | headers: &HeaderMap, | |
| 436 | + | ) -> Result<User, Response> { | |
| 437 | + | let authorization = headers | |
| 438 | + | .get(axum::http::header::AUTHORIZATION) | |
| 439 | + | .and_then(|v| v.to_str().ok()); | |
| 440 | + | let user = match authorization { | |
| 441 | + | Some(header) if header.to_ascii_lowercase().starts_with("basic ") => { | |
| 442 | + | basic_auth_user(app, Some(header)).await | |
| 443 | + | } | |
| 444 | + | _ => { | |
| 445 | + | let submitted = headers | |
| 446 | + | .get("x-csrf-token") | |
| 447 | + | .and_then(|v| v.to_str().ok()) | |
| 448 | + | .unwrap_or_default(); | |
| 449 | + | verify_csrf(csrf, submitted)?; | |
| 450 | + | session_user | |
| 451 | + | } | |
| 452 | + | }; | |
| 453 | + | user.ok_or_else(|| (StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response()) | |
| 454 | + | } | |
| 455 | + | ||
| 456 | + | #[derive(Serialize)] | |
| 457 | + | struct UserSecretsResponse { | |
| 458 | + | account: String, | |
| 459 | + | unlocked_until: i64, | |
| 460 | + | recipients: Vec<RecipientJson>, | |
| 461 | + | secrets: Vec<UserSecretJson>, | |
| 462 | + | } | |
| 411 | 463 | ||
| 412 | - | fn split_fingerprints(csv: &str) -> Vec<String> { | |
| 413 | - | csv.split(',') | |
| 414 | - | .filter(|s| !s.is_empty()) | |
| 415 | - | .map(str::to_string) | |
| 416 | - | .collect() | |
| 464 | + | #[derive(Serialize)] | |
| 465 | + | struct UserSecretJson { | |
| 466 | + | name: String, | |
| 467 | + | kind: String, | |
| 468 | + | dest_path: String, | |
| 469 | + | field: String, | |
| 470 | + | envelope: serde_json::Value, | |
| 471 | + | recipients: Vec<String>, | |
| 472 | + | updated_at: i64, | |
| 417 | 473 | } | |
| 418 | 474 | ||
| 419 | - | /// The repository owner's ssh-ed25519 keys, as (recipient, OpenSSH line). | |
| 420 | - | /// Other key types are skipped: they cannot do X25519 key agreement. | |
| 421 | - | async fn recipients_for( | |
| 422 | - | app: &App, | |
| 423 | - | meta: &Repository, | |
| 424 | - | ) -> Result<Vec<(secrets::Recipient, String)>, Response> { | |
| 425 | - | let keys = ssh_keys::list_by_user(&app.db, meta.owner_id) | |
| 426 | - | .await | |
| 427 | - | .map_err(server_error)?; | |
| 428 | - | Ok(keys | |
| 475 | + | /// `GET /-/api/user/secrets`. | |
| 476 | + | async fn list_user_secrets( | |
| 477 | + | State(app): State<App>, | |
| 478 | + | CurrentUser(user): CurrentUser, | |
| 479 | + | csrf: Csrf, | |
| 480 | + | headers: HeaderMap, | |
| 481 | + | ) -> Response { | |
| 482 | + | let user = match user_authorize(&app, user, &csrf, &headers).await { | |
| 483 | + | Ok(u) => u, | |
| 484 | + | Err(resp) => return resp, | |
| 485 | + | }; | |
| 486 | + | let recipients = match recipients_for_user(&app, user.id).await { | |
| 487 | + | Ok(r) => r, | |
| 488 | + | Err(resp) => return resp, | |
| 489 | + | }; | |
| 490 | + | let stored = match secrets::list_for_user(&app.db, user.id).await { | |
| 491 | + | Ok(s) => s, | |
| 492 | + | Err(e) => return server_error(e).into_response(), | |
| 493 | + | }; | |
| 494 | + | let secrets_json = stored | |
| 429 | 495 | .into_iter() | |
| 430 | - | .filter_map(|k| { | |
| 431 | - | secrets::Recipient::from_openssh(&k.content) | |
| 432 | - | .ok() | |
| 433 | - | .map(|r| (r, k.content)) | |
| 496 | + | .map(|s| UserSecretJson { | |
| 497 | + | envelope: serde_json::from_str(&s.envelope).unwrap_or(serde_json::Value::Null), | |
| 498 | + | recipients: split_fingerprints(&s.recipients), | |
| 499 | + | name: s.name, | |
| 500 | + | kind: s.kind, | |
| 501 | + | dest_path: s.dest_path, | |
| 502 | + | field: s.field, | |
| 503 | + | updated_at: s.updated_at, | |
| 434 | 504 | }) | |
| 435 | - | .collect()) | |
| 505 | + | .collect(); | |
| 506 | + | Json(UserSecretsResponse { | |
| 507 | + | account: user.username, | |
| 508 | + | unlocked_until: app | |
| 509 | + | .user_vault | |
| 510 | + | .status(user.id) | |
| 511 | + | .map(|s| s.expires_at) | |
| 512 | + | .unwrap_or_default(), | |
| 513 | + | recipients: recipients | |
| 514 | + | .into_iter() | |
| 515 | + | .map(|(recipient, line)| RecipientJson { | |
| 516 | + | fingerprint: recipient.fingerprint, | |
| 517 | + | key: line, | |
| 518 | + | }) | |
| 519 | + | .collect(), | |
| 520 | + | secrets: secrets_json, | |
| 521 | + | }) | |
| 522 | + | .into_response() | |
| 523 | + | } | |
| 524 | + | ||
| 525 | + | #[derive(Deserialize)] | |
| 526 | + | struct PutUserSecret { | |
| 527 | + | name: String, | |
| 528 | + | #[serde(default)] | |
| 529 | + | kind: String, | |
| 530 | + | #[serde(default)] | |
| 531 | + | dest_path: String, | |
| 532 | + | #[serde(default)] | |
| 533 | + | field: String, | |
| 534 | + | envelope: serde_json::Value, | |
| 436 | 535 | } | |
| 437 | 536 | ||
| 438 | - | /// Coarse countdown phrasing ("3 hours"), since [`fmt_relative`] only ever | |
| 439 | - | /// looks backwards. | |
| 440 | - | fn fmt_duration(secs: i64) -> String { | |
| 441 | - | let plural = |n: i64, unit: &str| { | |
| 442 | - | if n == 1 { | |
| 443 | - | format!("1 {unit}") | |
| 444 | - | } else { | |
| 445 | - | format!("{n} {unit}s") | |
| 446 | - | } | |
| 537 | + | /// `POST /-/api/user/secrets` — store a sealed envelope under a name, | |
| 538 | + | /// replacing any previous value. The body is ciphertext; the server checks | |
| 539 | + | /// only its shape (and, for `kind`/`dest_path`/`field`, that they are | |
| 540 | + | /// internally consistent — see `secrets::put_for_user`). | |
| 541 | + | async fn put_user_secret( | |
| 542 | + | State(app): State<App>, | |
| 543 | + | CurrentUser(user): CurrentUser, | |
| 544 | + | csrf: Csrf, | |
| 545 | + | headers: HeaderMap, | |
| 546 | + | Json(body): Json<PutUserSecret>, | |
| 547 | + | ) -> Response { | |
| 548 | + | let user = match user_authorize(&app, user, &csrf, &headers).await { | |
| 549 | + | Ok(u) => u, | |
| 550 | + | Err(resp) => return resp, | |
| 551 | + | }; | |
| 552 | + | let json = match serde_json::to_string(&body.envelope) { | |
| 553 | + | Ok(j) => j, | |
| 554 | + | Err(e) => return bad_request(e), | |
| 555 | + | }; | |
| 556 | + | let envelope = match Envelope::parse(&json) { | |
| 557 | + | Ok(e) => e, | |
| 558 | + | Err(e) => return bad_request(e), | |
| 559 | + | }; | |
| 560 | + | let current = match recipients_for_user(&app, user.id).await { | |
| 561 | + | Ok(r) => r, | |
| 562 | + | Err(resp) => return resp, | |
| 563 | + | }; | |
| 564 | + | let sealed_to = envelope.recipient_fingerprints(); | |
| 565 | + | if !current | |
| 566 | + | .iter() | |
| 567 | + | .any(|(r, _)| sealed_to.contains(&r.fingerprint)) | |
| 568 | + | { | |
| 569 | + | return bad_request("envelope is not sealed to any registered ssh key"); | |
| 570 | + | } | |
| 571 | + | let kind = if body.kind.is_empty() { | |
| 572 | + | secrets::kind::ENV | |
| 573 | + | } else { | |
| 574 | + | &body.kind | |
| 447 | 575 | }; | |
| 448 | - | match secs { | |
| 449 | - | s if s <= 0 => "moments".to_string(), | |
| 450 | - | s if s < 60 => plural(s, "second"), | |
| 451 | - | s if s < 3600 => plural(s / 60, "minute"), | |
| 452 | - | s if s < 86_400 => plural(s / 3600, "hour"), | |
| 453 | - | s => plural(s / 86_400, "day"), | |
| 576 | + | match secrets::put_for_user( | |
| 577 | + | &app.db, | |
| 578 | + | user.id, | |
| 579 | + | &body.name, | |
| 580 | + | kind, | |
| 581 | + | &body.dest_path, | |
| 582 | + | &body.field, | |
| 583 | + | &envelope, | |
| 584 | + | ) | |
| 585 | + | .await | |
| 586 | + | { | |
| 587 | + | Ok(()) => StatusCode::NO_CONTENT.into_response(), | |
| 588 | + | Err(e) => bad_request(e), | |
| 454 | 589 | } | |
| 455 | 590 | } | |
| 456 | 591 | ||
| 457 | - | /// The secrets section of a repository's settings page. | |
| 458 | - | pub async fn settings_section(app: &App, owner: &str, repo: &str, meta: &Repository) -> Markup { | |
| 459 | - | let recipients = recipients_for(app, meta).await.unwrap_or_default(); | |
| 460 | - | let stored = secrets::list(&app.db, meta.id).await.unwrap_or_default(); | |
| 461 | - | let status = app.vault.status(meta.id); | |
| 462 | - | let csrf = crate::auth::current_csrf(); | |
| 463 | - | ||
| 464 | - | let recipients_json = serde_json::to_string( | |
| 465 | - | &recipients | |
| 466 | - | .iter() | |
| 467 | - | .map(|(r, line)| serde_json::json!({ "fingerprint": r.fingerprint, "key": line })) | |
| 468 | - | .collect::<Vec<_>>(), | |
| 469 | - | ) | |
| 470 | - | .unwrap_or_else(|_| "[]".to_string()); | |
| 471 | - | let current: Vec<&str> = recipients | |
| 472 | - | .iter() | |
| 473 | - | .map(|(r, _)| r.fingerprint.as_str()) | |
| 474 | - | .collect(); | |
| 475 | - | ||
| 476 | - | html! { | |
| 477 | - | h2 style="margin-top:28px" { "Secrets" } | |
| 478 | - | p.muted style="font-size:13px" { | |
| 479 | - | "Encrypted in your browser to your ssh-ed25519 keys before they are sent. " | |
| 480 | - | "anvil stores only the ciphertext and cannot read it — not here, not in a backup. " | |
| 481 | - | "To let CI use them, run " | |
| 482 | - | code { "anvild secret unlock " (owner) "/" (repo) } | |
| 483 | - | " from a machine holding one of those keys." | |
| 484 | - | } | |
| 592 | + | /// `DELETE /-/api/user/secrets/{name}`. | |
| 593 | + | async fn delete_user_secret( | |
| 594 | + | State(app): State<App>, | |
| 595 | + | CurrentUser(user): CurrentUser, | |
| 596 | + | csrf: Csrf, | |
| 597 | + | Path(name): Path<String>, | |
| 598 | + | headers: HeaderMap, | |
| 599 | + | ) -> Response { | |
| 600 | + | let user = match user_authorize(&app, user, &csrf, &headers).await { | |
| 601 | + | Ok(u) => u, | |
| 602 | + | Err(resp) => return resp, | |
| 603 | + | }; | |
| 604 | + | match secrets::delete_for_user(&app.db, user.id, &name).await { | |
| 605 | + | Ok(()) => StatusCode::NO_CONTENT.into_response(), | |
| 606 | + | Err(e) => server_error(e), | |
| 607 | + | } | |
| 608 | + | } | |
| 485 | 609 | ||
| 486 | - | @if let Some(status) = status { | |
| 487 | - | p.secret-unlocked { | |
| 488 | - | "Unlocked for CI — " (status.count) " value(s), expires in " | |
| 489 | - | (fmt_duration(status.expires_at - anvil_core::secrets::now_secs())) "." | |
| 610 | + | /// `POST /-/api/user/secrets/unlock` — hand the server decrypted values to | |
| 611 | + | /// hold in memory for agent sessions until they expire. See [`unlock`]. | |
| 612 | + | async fn unlock_user( | |
| 613 | + | State(app): State<App>, | |
| 614 | + | CurrentUser(user): CurrentUser, | |
| 615 | + | csrf: Csrf, | |
| 616 | + | headers: HeaderMap, | |
| 617 | + | Json(body): Json<UnlockBody>, | |
| 618 | + | ) -> Response { | |
| 619 | + | let user = match user_authorize(&app, user, &csrf, &headers).await { | |
| 620 | + | Ok(u) => u, | |
| 621 | + | Err(resp) => return resp, | |
| 622 | + | }; | |
| 623 | + | for name in body.values.keys() { | |
| 624 | + | if !secrets::valid_name(name) { | |
| 625 | + | return bad_request(format!("invalid secret name `{name}`")); | |
| 626 | + | } | |
| 627 | + | } | |
| 628 | + | let count = body.values.len(); | |
| 629 | + | let ttl = if body.ttl_secs > 0 { | |
| 630 | + | body.ttl_secs | |
| 631 | + | } else { | |
| 632 | + | 8 * 60 * 60 | |
| 633 | + | }; | |
| 634 | + | let unlocked_until = app.user_vault.unlock(user.id, body.values, ttl); | |
| 635 | + | tracing::info!( | |
| 636 | + | "secrets: {}'s user secrets unlocked with {count} value(s) until {unlocked_until}", | |
| 637 | + | user.username | |
| 638 | + | ); | |
| 639 | + | Json(UnlockResponse { | |
| 640 | + | unlocked_until, | |
| 641 | + | count, | |
| 642 | + | }) | |
| 643 | + | .into_response() | |
| 644 | + | } | |
| 645 | + | ||
| 646 | + | /// `POST /-/api/user/secrets/lock` — forget the values now. | |
| 647 | + | async fn lock_user( | |
| 648 | + | State(app): State<App>, | |
| 649 | + | CurrentUser(user): CurrentUser, | |
| 650 | + | csrf: Csrf, | |
| 651 | + | headers: HeaderMap, | |
| 652 | + | ) -> Response { | |
| 653 | + | let user = match user_authorize(&app, user, &csrf, &headers).await { | |
| 654 | + | Ok(u) => u, | |
| 655 | + | Err(resp) => return resp, | |
| 656 | + | }; | |
| 657 | + | app.user_vault.lock(user.id); | |
| 658 | + | StatusCode::NO_CONTENT.into_response() | |
| 659 | + | } | |
| 660 | + | ||
| 661 | + | // --- form posts from the account settings page ------------------------------ | |
| 662 | + | ||
| 663 | + | async fn ui_delete_user( | |
| 664 | + | State(app): State<App>, | |
| 665 | + | CurrentUser(user): CurrentUser, | |
| 666 | + | csrf: Csrf, | |
| 667 | + | Path(name): Path<String>, | |
| 668 | + | axum::Form(form): axum::Form<crate::auth::CsrfForm>, | |
| 669 | + | ) -> Response { | |
| 670 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 671 | + | return resp; | |
| 672 | + | } | |
| 673 | + | let Some(user) = user else { | |
| 674 | + | return (StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response(); | |
| 675 | + | }; | |
| 676 | + | if let Err(e) = secrets::delete_for_user(&app.db, user.id, &name).await { | |
| 677 | + | return server_error(e); | |
| 678 | + | } | |
| 679 | + | Redirect::to("/-/settings").into_response() | |
| 680 | + | } | |
| 681 | + | ||
| 682 | + | async fn ui_lock_user( | |
| 683 | + | State(app): State<App>, | |
| 684 | + | CurrentUser(user): CurrentUser, | |
| 685 | + | csrf: Csrf, | |
| 686 | + | axum::Form(form): axum::Form<crate::auth::CsrfForm>, | |
| 687 | + | ) -> Response { | |
| 688 | + | if let Err(resp) = verify_csrf(&csrf, &form.csrf) { | |
| 689 | + | return resp; | |
| 690 | + | } | |
| 691 | + | let Some(user) = user else { | |
| 692 | + | return (StatusCode::UNAUTHORIZED, "sign in to manage secrets").into_response(); | |
| 693 | + | }; | |
| 694 | + | app.user_vault.lock(user.id); | |
| 695 | + | Redirect::to("/-/settings").into_response() | |
| 696 | + | } | |
| 697 | + | ||
| 698 | + | /// The signed-in user's own ssh-ed25519 keys, as (recipient, OpenSSH line) — | |
| 699 | + | /// same filter as [`recipients_for`], just against an account id directly. | |
| 700 | + | async fn recipients_for_user( | |
| 701 | + | app: &App, | |
| 702 | + | user_id: i64, | |
| 703 | + | ) -> Result<Vec<(secrets::Recipient, String)>, Response> { | |
| 704 | + | let keys = ssh_keys::list_by_user(&app.db, user_id) | |
| 705 | + | .await | |
| 706 | + | .map_err(server_error)?; | |
| 707 | + | Ok(keys | |
| 708 | + | .into_iter() | |
| 709 | + | .filter_map(|k| { | |
| 710 | + | secrets::Recipient::from_openssh(&k.content) | |
| 711 | + | .ok() | |
| 712 | + | .map(|r| (r, k.content)) | |
| 713 | + | }) | |
| 714 | + | .collect()) | |
| 715 | + | } | |
| 716 | + | ||
| 717 | + | // --- shared helpers -------------------------------------------------------- | |
| 718 | + | ||
| 719 | + | fn split_fingerprints(csv: &str) -> Vec<String> { | |
| 720 | + | csv.split(',') | |
| 721 | + | .filter(|s| !s.is_empty()) | |
| 722 | + | .map(str::to_string) | |
| 723 | + | .collect() | |
| 724 | + | } | |
| 725 | + | ||
| 726 | + | /// The repository owner's ssh-ed25519 keys, as (recipient, OpenSSH line). | |
| 727 | + | /// Other key types are skipped: they cannot do X25519 key agreement. | |
| 728 | + | async fn recipients_for( | |
| 729 | + | app: &App, | |
| 730 | + | meta: &Repository, | |
| 731 | + | ) -> Result<Vec<(secrets::Recipient, String)>, Response> { | |
| 732 | + | let keys = ssh_keys::list_by_user(&app.db, meta.owner_id) | |
| 733 | + | .await | |
| 734 | + | .map_err(server_error)?; | |
| 735 | + | Ok(keys | |
| 736 | + | .into_iter() | |
| 737 | + | .filter_map(|k| { | |
| 738 | + | secrets::Recipient::from_openssh(&k.content) | |
| 739 | + | .ok() | |
| 740 | + | .map(|r| (r, k.content)) | |
| 741 | + | }) | |
| 742 | + | .collect()) | |
| 743 | + | } | |
| 744 | + | ||
| 745 | + | /// Coarse countdown phrasing ("3 hours"), since [`fmt_relative`] only ever | |
| 746 | + | /// looks backwards. | |
| 747 | + | fn fmt_duration(secs: i64) -> String { | |
| 748 | + | let plural = |n: i64, unit: &str| { | |
| 749 | + | if n == 1 { | |
| 750 | + | format!("1 {unit}") | |
| 751 | + | } else { | |
| 752 | + | format!("{n} {unit}s") | |
| 753 | + | } | |
| 754 | + | }; | |
| 755 | + | match secs { | |
| 756 | + | s if s <= 0 => "moments".to_string(), | |
| 757 | + | s if s < 60 => plural(s, "second"), | |
| 758 | + | s if s < 3600 => plural(s / 60, "minute"), | |
| 759 | + | s if s < 86_400 => plural(s / 3600, "hour"), | |
| 760 | + | s => plural(s / 86_400, "day"), | |
| 761 | + | } | |
| 762 | + | } | |
| 763 | + | ||
| 764 | + | /// The secrets section of a repository's settings page. | |
| 765 | + | pub async fn settings_section(app: &App, owner: &str, repo: &str, meta: &Repository) -> Markup { | |
| 766 | + | let recipients = recipients_for(app, meta).await.unwrap_or_default(); | |
| 767 | + | let stored = secrets::list(&app.db, meta.id).await.unwrap_or_default(); | |
| 768 | + | let status = app.vault.status(meta.id); | |
| 769 | + | let csrf = crate::auth::current_csrf(); | |
| 770 | + | ||
| 771 | + | let recipients_json = serde_json::to_string( | |
| 772 | + | &recipients | |
| 773 | + | .iter() | |
| 774 | + | .map(|(r, line)| serde_json::json!({ "fingerprint": r.fingerprint, "key": line })) | |
| 775 | + | .collect::<Vec<_>>(), | |
| 776 | + | ) | |
| 777 | + | .unwrap_or_else(|_| "[]".to_string()); | |
| 778 | + | let current: Vec<&str> = recipients | |
| 779 | + | .iter() | |
| 780 | + | .map(|(r, _)| r.fingerprint.as_str()) | |
| 781 | + | .collect(); | |
| 782 | + | ||
| 783 | + | html! { | |
| 784 | + | h2 style="margin-top:28px" { "Secrets" } | |
| 785 | + | p.muted style="font-size:13px" { | |
| 786 | + | "Encrypted in your browser to your ssh-ed25519 keys before they are sent. " | |
| 787 | + | "anvil stores only the ciphertext and cannot read it — not here, not in a backup. " | |
| 788 | + | "To let CI use them, run " | |
| 789 | + | code { "anvild secret unlock " (owner) "/" (repo) } | |
| 790 | + | " from a machine holding one of those keys." | |
| 791 | + | } | |
| 792 | + | ||
| 793 | + | @if let Some(status) = status { | |
| 794 | + | p.secret-unlocked { | |
| 795 | + | "Unlocked for CI — " (status.count) " value(s), expires in " | |
| 796 | + | (fmt_duration(status.expires_at - anvil_core::secrets::now_secs())) "." | |
| 490 | 797 | form method="post" action=(format!("/{owner}/{repo}/-/secrets/lock")) style="display:inline;margin-left:8px" { | |
| 491 | 798 | (csrf_input(&csrf)) | |
| 492 | 799 | button.btn.btn-secondary type="submit" { "Lock now" } | |
| ⋯ 68 unchanged lines | |||
| 561 | 868 | } | |
| 562 | 869 | } | |
| 563 | 870 | ||
| 871 | + | /// The secrets section of the account settings page (`/-/settings`). | |
| 872 | + | /// Mirrors [`settings_section`] — see there for the general shape and the | |
| 873 | + | /// "why not a `<form>`" note — but sealed to the account's own keys | |
| 874 | + | /// (`user:{username}` rather than `{owner}/{repo}` as the AAD scope) and | |
| 875 | + | /// consumed by that account's own agent sessions rather than CI. A secret | |
| 876 | + | /// also carries a kind (env/file/json — [`secrets::kind`]) and, for | |
| 877 | + | /// file/json, a destination path under the session's `$HOME`. | |
| 878 | + | pub async fn user_settings_section(app: &App, user: &User) -> Markup { | |
| 879 | + | let recipients = recipients_for_user(app, user.id).await.unwrap_or_default(); | |
| 880 | + | let stored = secrets::list_for_user(&app.db, user.id) | |
| 881 | + | .await | |
| 882 | + | .unwrap_or_default(); | |
| 883 | + | let status = app.user_vault.status(user.id); | |
| 884 | + | let csrf = crate::auth::current_csrf(); | |
| 885 | + | ||
| 886 | + | let recipients_json = serde_json::to_string( | |
| 887 | + | &recipients | |
| 888 | + | .iter() | |
| 889 | + | .map(|(r, line)| serde_json::json!({ "fingerprint": r.fingerprint, "key": line })) | |
| 890 | + | .collect::<Vec<_>>(), | |
| 891 | + | ) | |
| 892 | + | .unwrap_or_else(|_| "[]".to_string()); | |
| 893 | + | let current: Vec<&str> = recipients | |
| 894 | + | .iter() | |
| 895 | + | .map(|(r, _)| r.fingerprint.as_str()) | |
| 896 | + | .collect(); | |
| 897 | + | ||
| 898 | + | html! { | |
| 899 | + | h2 style="margin-top:28px" { "Secrets" } | |
| 900 | + | p.muted style="font-size:13px" { | |
| 901 | + | "Encrypted in your browser to your own ssh-ed25519 keys before they are sent. " | |
| 902 | + | "For your own agent sessions to use one, it opts in by name when you start it — " | |
| 903 | + | "and it needs unlocking first: run " | |
| 904 | + | code { "anvild secret user unlock" } | |
| 905 | + | " from a machine holding one of those keys." | |
| 906 | + | } | |
| 907 | + | ||
| 908 | + | @if let Some(status) = status { | |
| 909 | + | p.secret-unlocked { | |
| 910 | + | "Unlocked — " (status.count) " value(s), expires in " | |
| 911 | + | (fmt_duration(status.expires_at - secrets::now_secs())) "." | |
| 912 | + | form method="post" action="/-/settings/secrets/lock" style="display:inline;margin-left:8px" { | |
| 913 | + | (csrf_input(&csrf)) | |
| 914 | + | button.btn.btn-secondary type="submit" { "Lock now" } | |
| 915 | + | } | |
| 916 | + | } | |
| 917 | + | } @else { | |
| 918 | + | p.muted style="font-size:13px" { "Sealed: sessions that opt into one of these will fail to start until you unlock." } | |
| 919 | + | } | |
| 920 | + | ||
| 921 | + | @if stored.is_empty() { | |
| 922 | + | p.muted { "No secrets yet." } | |
| 923 | + | } @else { | |
| 924 | + | div.box { | |
| 925 | + | @for s in &stored { | |
| 926 | + | div.row { | |
| 927 | + | span { | |
| 928 | + | code { (s.name) } | |
| 929 | + | " (" (s.kind) | |
| 930 | + | @if !s.dest_path.is_empty() { " → " code { (s.dest_path) } } | |
| 931 | + | @if !s.field.is_empty() { " " code { (s.field) } } | |
| 932 | + | ")" | |
| 933 | + | @let sealed_to = split_fingerprints(&s.recipients); | |
| 934 | + | @let missing = current.iter().filter(|fp| !sealed_to.iter().any(|s| s == **fp)).count(); | |
| 935 | + | @if missing > 0 { | |
| 936 | + | span.secret-stale title="Sealed before these keys were added" { | |
| 937 | + | (missing) " key(s) cannot open this — rekey" | |
| 938 | + | } | |
| 939 | + | } | |
| 940 | + | } | |
| 941 | + | span.muted style="margin-left:auto;font-size:13px" { | |
| 942 | + | "updated " (fmt_relative(s.updated_at)) | |
| 943 | + | } | |
| 944 | + | form method="post" style="margin-left:12px" | |
| 945 | + | action=(format!("/-/settings/secrets/{}/delete", s.name)) { | |
| 946 | + | (csrf_input(&csrf)) | |
| 947 | + | button.btn.btn-secondary type="submit" { "Delete" } | |
| 948 | + | } | |
| 949 | + | } | |
| 950 | + | } | |
| 951 | + | } | |
| 952 | + | } | |
| 953 | + | ||
| 954 | + | @if recipients.is_empty() { | |
| 955 | + | p.secret-warn { "No ssh-ed25519 key registered, so there is nothing to encrypt to. Add one above first." } | |
| 956 | + | } @else { | |
| 957 | + | div #user-secrets-form.stack | |
| 958 | + | data-scope=(format!("user:{}", user.username)) | |
| 959 | + | data-endpoint="/-/api/user/secrets" | |
| 960 | + | data-csrf=(csrf) | |
| 961 | + | style="margin-top:16px" { | |
| 962 | + | script #user-secret-recipients type="application/json" { (PreEscaped(recipients_json)) } | |
| 963 | + | p { | |
| 964 | + | label { "Name" br; input #user-secret-name type="text" placeholder="CLAUDE_CREDS" autocomplete="off"; } | |
| 965 | + | } | |
| 966 | + | p { | |
| 967 | + | label { "Kind" br; | |
| 968 | + | select #user-secret-kind { | |
| 969 | + | option value="env" { "env — an environment variable named after this secret" } | |
| 970 | + | option value="file" { "file — write the whole value at a path" } | |
| 971 | + | option value="json" { "json — set one field of a JSON file at a path" } | |
| 972 | + | } | |
| 973 | + | } | |
| 974 | + | } | |
| 975 | + | p #user-secret-path-row style="display:none" { | |
| 976 | + | label { "Path (under $HOME in the session)" br; | |
| 977 | + | input #user-secret-path type="text" placeholder=".claude/.credentials.json" autocomplete="off"; | |
| 978 | + | } | |
| 979 | + | } | |
| 980 | + | p #user-secret-field-row style="display:none" { | |
| 981 | + | label { "Field (jq path within that file)" br; | |
| 982 | + | input #user-secret-field type="text" placeholder=".oauthAccount.token" autocomplete="off"; | |
| 983 | + | } | |
| 984 | + | } | |
| 985 | + | p { | |
| 986 | + | label { "Value" br; textarea #user-secret-value rows="3" autocomplete="off" spellcheck="false" {} } | |
| 987 | + | br; | |
| 988 | + | span.muted style="font-size:12px" { | |
| 989 | + | "Sealed to " (recipients.len()) " key(s) in this browser. The value never leaves the page in the clear." | |
| 990 | + | } | |
| 991 | + | } | |
| 992 | + | p { | |
| 993 | + | button.btn #user-secret-save type="button" { "Encrypt and save" } | |
| 994 | + | span #user-secret-status.muted style="margin-left:10px;font-size:13px" {} | |
| 995 | + | } | |
| 996 | + | } | |
| 997 | + | script { (PreEscaped(SEAL_JS)) } | |
| 998 | + | script { (PreEscaped(USER_FORM_JS)) } | |
| 999 | + | } | |
| 1000 | + | } | |
| 1001 | + | } | |
| 1002 | + | ||
| 564 | 1003 | /// Browser-side sealing, exposed as `anvilSealSecret(repo, name, value, | |
| 565 | 1004 | /// recipients)`. | |
| 566 | 1005 | /// | |
| ⋯ 152 unchanged lines | |||
| 719 | 1158 | }); | |
| 720 | 1159 | })(); | |
| 721 | 1160 | "#; | |
| 1161 | + | ||
| 1162 | + | /// Wires the account settings form to [`SEAL_JS`], same as [`FORM_JS`] but | |
| 1163 | + | /// for a user secret: a kind selector (env/file/json) that shows/hides the | |
| 1164 | + | /// path and field inputs, both included in the POST body alongside the | |
| 1165 | + | /// envelope. The envelope itself is sealed exactly the same way — `kind`, | |
| 1166 | + | /// `dest_path`, and `field` are metadata the server stores next to it, never | |
| 1167 | + | /// part of what gets encrypted. | |
| 1168 | + | const USER_FORM_JS: &str = r#" | |
| 1169 | + | (function () { | |
| 1170 | + | var root = document.getElementById('user-secrets-form'); | |
| 1171 | + | if (!root) return; | |
| 1172 | + | var nameEl = document.getElementById('user-secret-name'); | |
| 1173 | + | var kindEl = document.getElementById('user-secret-kind'); | |
| 1174 | + | var pathRow = document.getElementById('user-secret-path-row'); | |
| 1175 | + | var pathEl = document.getElementById('user-secret-path'); | |
| 1176 | + | var fieldRow = document.getElementById('user-secret-field-row'); | |
| 1177 | + | var fieldEl = document.getElementById('user-secret-field'); | |
| 1178 | + | var valueEl = document.getElementById('user-secret-value'); | |
| 1179 | + | var button = document.getElementById('user-secret-save'); | |
| 1180 | + | var statusEl = document.getElementById('user-secret-status'); | |
| 1181 | + | var recipients = JSON.parse(document.getElementById('user-secret-recipients').textContent); | |
| 1182 | + | ||
| 1183 | + | function syncKindFields() { | |
| 1184 | + | var kind = kindEl.value; | |
| 1185 | + | pathRow.style.display = (kind === 'file' || kind === 'json') ? '' : 'none'; | |
| 1186 | + | fieldRow.style.display = (kind === 'json') ? '' : 'none'; | |
| 1187 | + | } | |
| 1188 | + | kindEl.addEventListener('change', syncKindFields); | |
| 1189 | + | syncKindFields(); | |
| 1190 | + | ||
| 1191 | + | function fail(message) { | |
| 1192 | + | statusEl.textContent = message; | |
| 1193 | + | statusEl.style.color = 'var(--error)'; | |
| 1194 | + | button.disabled = false; | |
| 1195 | + | } | |
| 1196 | + | ||
| 1197 | + | button.addEventListener('click', async function () { | |
| 1198 | + | var name = nameEl.value.trim(); | |
| 1199 | + | var kind = kindEl.value; | |
| 1200 | + | var path = pathEl.value.trim(); | |
| 1201 | + | var field = fieldEl.value.trim(); | |
| 1202 | + | var value = valueEl.value; | |
| 1203 | + | statusEl.style.color = ''; | |
| 1204 | + | if (!/^[A-Z_][A-Z0-9_]*$/.test(name)) return fail('Name must be A-Z, 0-9 and _, not starting with a digit.'); | |
| 1205 | + | if ((kind === 'file' || kind === 'json') && !path) return fail('Path is required for this kind.'); | |
| 1206 | + | if (kind === 'json' && !field) return fail('Field (jq path) is required for the json kind.'); | |
| 1207 | + | if (!value) return fail('Value is empty.'); | |
| 1208 | + | if (!crypto.subtle || !window.BigInt) return fail('This browser cannot encrypt here; use `anvild secret user set`.'); | |
| 1209 | + | ||
| 1210 | + | button.disabled = true; | |
| 1211 | + | statusEl.textContent = 'Encrypting…'; | |
| 1212 | + | var envelope; | |
| 1213 | + | try { | |
| 1214 | + | envelope = await anvilSealSecret(root.dataset.scope, name, value, recipients); | |
| 1215 | + | } catch (e) { | |
| 1216 | + | // Most likely cause: a browser without WebCrypto X25519. | |
| 1217 | + | return fail('Encryption failed (' + e.message + '). Use `anvild secret user set` instead.'); | |
| 1218 | + | } | |
| 1219 | + | statusEl.textContent = 'Saving…'; | |
| 1220 | + | try { | |
| 1221 | + | var res = await fetch(root.dataset.endpoint, { | |
| 1222 | + | method: 'POST', | |
| 1223 | + | headers: { 'Content-Type': 'application/json', 'X-CSRF-Token': root.dataset.csrf }, | |
| 1224 | + | body: JSON.stringify({ | |
| 1225 | + | name: name, | |
| 1226 | + | kind: kind, | |
| 1227 | + | dest_path: (kind === 'file' || kind === 'json') ? path : '', | |
| 1228 | + | field: (kind === 'json') ? field : '', | |
| 1229 | + | envelope: envelope, | |
| 1230 | + | }), | |
| 1231 | + | }); | |
| 1232 | + | if (!res.ok) return fail('Server rejected it: ' + (await res.text())); | |
| 1233 | + | } catch (e) { | |
| 1234 | + | return fail('Could not reach the server: ' + e.message); | |
| 1235 | + | } | |
| 1236 | + | // Clear the plaintext out of the DOM before the page goes away. | |
| 1237 | + | valueEl.value = ''; | |
| 1238 | + | nameEl.value = ''; | |
| 1239 | + | pathEl.value = ''; | |
| 1240 | + | fieldEl.value = ''; | |
| 1241 | + | location.reload(); | |
| 1242 | + | }); | |
| 1243 | + | })(); | |
| 1244 | + | "#; | |
modifiedcrates/anvil-web/src/ui.rs+55 −28
| ⋯ 154 unchanged lines | |||
| 155 | 155 | .linkbtn:hover { text-decoration:underline; } | |
| 156 | 156 | .btn { display:inline-block; background:var(--accent); color:#fff; border:1px solid var(--accent); border-radius:6px; padding:5px 12px; font-size:13px; cursor:pointer; } | |
| 157 | 157 | .btn:hover { text-decoration:none; opacity:.92; } | |
| 158 | - | /* Repo header: title (+ visibility badge) on the left, quick-nav on the right; | |
| 159 | - | wraps cleanly to its own line on narrow viewports instead of floating. */ | |
| 160 | - | .repo-head { display:flex; flex-wrap:wrap; align-items:baseline; justify-content:space-between; gap:6px 16px; margin:24px 0 4px; } | |
| 158 | + | /* Repo header: title (+ visibility badge), then a tab strip below it with a | |
| 159 | + | full-width rule; the active tab's own bottom border sits on top of that | |
| 160 | + | rule so it reads as "attached" to the panel underneath. */ | |
| 161 | + | .repo-head { display:flex; flex-wrap:wrap; align-items:baseline; gap:6px 16px; margin:24px 0 14px; } | |
| 161 | 162 | .repo-title { display:flex; align-items:baseline; flex-wrap:wrap; gap:8px; min-width:0; } | |
| 162 | 163 | .repo-title h1 { margin:0; } | |
| 163 | 164 | .repo-title .pill { font-size:11px; text-transform:uppercase; letter-spacing:.04em; align-self:center; } | |
| 164 | - | .repo-nav { font-size:13px; display:flex; align-items:baseline; gap:8px; color:var(--muted); } | |
| 165 | - | .repo-nav a { color:var(--muted); } | |
| 166 | - | .repo-nav a:hover { color:var(--accent); text-decoration:none; } | |
| 167 | - | .repo-nav .sep { color:var(--border); } | |
| 165 | + | .repo-tabs { display:flex; flex-wrap:wrap; gap:20px; font-size:14px; border-bottom:1px solid var(--border); margin-bottom:16px; } | |
| 166 | + | .repo-tabs a { display:inline-block; padding:8px 1px 10px; margin-bottom:-1px; color:var(--muted); border-bottom:2px solid transparent; } | |
| 167 | + | .repo-tabs a:hover { color:var(--fg); text-decoration:none; } | |
| 168 | + | .repo-tabs a.active { color:var(--fg); font-weight:600; border-bottom-color:var(--accent); } | |
| 168 | 169 | .repo-meta { display:flex; gap:8px; margin:8px 0; color:var(--muted); font-size:13px; } | |
| 169 | 170 | .repo-meta b { font-weight:600; color:var(--fg); } | |
| 170 | 171 | .pill-group { display:inline-flex; } | |
| ⋯ 109 unchanged lines | |||
| 280 | 281 | .st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; } | |
| 281 | 282 | .st.queued { background:var(--code-bg); color:var(--muted); } .st.running { background:var(--warning-bg); color:var(--warning); } | |
| 282 | 283 | .st.success { background:var(--success-bg); color:var(--success); } .st.failure, .st.error { background:var(--error-bg); color:var(--error); } | |
| 284 | + | /* Agent sessions reuse .st: starting looks like queued, running is shared, | |
| 285 | + | exited/failed/reaped are their own (an ended session isn't a failure). */ | |
| 286 | + | .st.starting { background:var(--code-bg); color:var(--muted); } | |
| 287 | + | .st.exited { background:var(--success-bg); color:var(--success); } | |
| 288 | + | .st.failed { background:var(--error-bg); color:var(--error); } | |
| 289 | + | .st.reaped { background:var(--warning-bg); color:var(--warning); } | |
| 283 | 290 | .log { background:var(--code-bg); color:var(--fg); border-radius:6px; padding:14px 16px; overflow-x:auto; font:12px/1.5 ui-monospace,SFMono-Regular,Menlo,Consolas,monospace; white-space:pre-wrap; word-break:break-word; margin:0; border:1px solid var(--border); } | |
| 284 | 291 | @media (prefers-color-scheme: dark) { | |
| 285 | 292 | .log { background:#0d1117; color:#e6edf3; border:0; } | |
| ⋯ 399 unchanged lines | |||
| 685 | 692 | Err(e) => return server_error(e), | |
| 686 | 693 | }; | |
| 687 | 694 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 688 | - | account_page(&user, &keys, &tokens, None, None, &csrf.0).into_response() | |
| 695 | + | let secrets = crate::secrets::user_settings_section(&app, &user).await; | |
| 696 | + | account_page(&user, &keys, &tokens, None, None, &csrf.0, secrets).into_response() | |
| 689 | 697 | } | |
| 690 | 698 | ||
| 691 | 699 | /// `POST /settings/keys` — register an SSH public key for the current user. | |
| ⋯ 24 unchanged lines | |||
| 716 | 724 | .await | |
| 717 | 725 | .unwrap_or_default(); | |
| 718 | 726 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 727 | + | let secrets = crate::secrets::user_settings_section(&app, &user).await; | |
| 719 | 728 | ( | |
| 720 | 729 | StatusCode::BAD_REQUEST, | |
| 721 | - | account_page(&user, &keys, &tokens, None, Some(&e.to_string()), &csrf.0), | |
| 730 | + | account_page( | |
| 731 | + | &user, | |
| 732 | + | &keys, | |
| 733 | + | &tokens, | |
| 734 | + | None, | |
| 735 | + | Some(&e.to_string()), | |
| 736 | + | &csrf.0, | |
| 737 | + | secrets, | |
| 738 | + | ), | |
| 722 | 739 | ) | |
| 723 | 740 | .into_response() | |
| 724 | 741 | } | |
| ⋯ 34 unchanged lines | |||
| 759 | 776 | .await | |
| 760 | 777 | .unwrap_or_default(); | |
| 761 | 778 | let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default(); | |
| 762 | - | account_page(&user, &keys, &tokens, Some(&plaintext), None, &csrf.0).into_response() | |
| 779 | + | let secrets = crate::secrets::user_settings_section(&app, &user).await; | |
| 780 | + | account_page( | |
| 781 | + | &user, | |
| 782 | + | &keys, | |
| 783 | + | &tokens, | |
| 784 | + | Some(&plaintext), | |
| 785 | + | None, | |
| 786 | + | &csrf.0, | |
| 787 | + | secrets, | |
| 788 | + | ) | |
| 789 | + | .into_response() | |
| 763 | 790 | } | |
| 764 | 791 | ||
| 765 | 792 | /// `POST /settings/tokens/{id}/delete` — revoke one of the current user's | |
| ⋯ 47 unchanged lines | |||
| 813 | 840 | new_token: Option<&str>, | |
| 814 | 841 | error: Option<&str>, | |
| 815 | 842 | csrf: &str, | |
| 843 | + | secrets: Markup, | |
| 816 | 844 | ) -> Markup { | |
| 817 | 845 | layout( | |
| 818 | 846 | "Account settings", | |
| ⋯ 67 unchanged lines | |||
| 886 | 914 | p { label { "Name" br; input type="text" name="name" placeholder="claude"; } } | |
| 887 | 915 | p { button.btn type="submit" { "Create token" } } | |
| 888 | 916 | } | |
| 917 | + | ||
| 918 | + | (secrets) | |
| 889 | 919 | }, | |
| 890 | 920 | ) | |
| 891 | 921 | } | |
| ⋯ 126 unchanged lines | |||
| 1018 | 1048 | ||
| 1019 | 1049 | h2 { "…or push an existing repository" } | |
| 1020 | 1050 | p.muted { "Pushing to a name that doesn't exist yet creates the repository (private). No need for the form above." } | |
| 1021 | - | pre.cmds { (format!("git remote add origin {remote}\ngit push -u origin main")) } | |
| 1051 | + | pre.cmds { (format!("git remote add anvil {remote}\ngit push -u anvil main")) } | |
| 1022 | 1052 | }, | |
| 1023 | 1053 | ) | |
| 1024 | 1054 | } | |
| ⋯ 155 unchanged lines | |||
| 1180 | 1210 | h1 { a href=(format!("/{owner}")) { (owner) } " / " (repo) } | |
| 1181 | 1211 | @if meta.is_private { span.pill { "private" } } | |
| 1182 | 1212 | } | |
| 1183 | - | nav.repo-nav { | |
| 1184 | - | a href=(format!("/{owner}/{repo}/blob/{}/TODO.md", enc_ref(overview.default_branch.as_deref().unwrap_or("main")))) { "Todo" } | |
| 1185 | - | span.sep { "·" } | |
| 1186 | - | a href=(format!("/{owner}/{repo}/ci")) { "CI" } | |
| 1187 | - | span.sep { "·" } | |
| 1188 | - | a href=(format!("/{owner}/{repo}/pages")) { "Pages" } | |
| 1189 | - | @if can_write { | |
| 1190 | - | // Agent sessions start containers and (from M2) push, so | |
| 1191 | - | // they are an owner action — hidden from readers entirely. | |
| 1192 | - | @if app.config.agent.enabled { | |
| 1193 | - | span.sep { "·" } | |
| 1194 | - | a href=(format!("/{owner}/{repo}/-/agent")) { "Agent" } | |
| 1195 | - | } | |
| 1196 | - | span.sep { "·" } | |
| 1197 | - | a href=(format!("/{owner}/{repo}/settings")) { "Settings" } | |
| 1213 | + | } | |
| 1214 | + | nav.repo-tabs { | |
| 1215 | + | a.active href=(format!("/{owner}/{repo}")) { "Code" } | |
| 1216 | + | a href=(format!("/{owner}/{repo}/blob/{}/TODO.md", enc_ref(overview.default_branch.as_deref().unwrap_or("main")))) { "Todo" } | |
| 1217 | + | a href=(format!("/{owner}/{repo}/ci")) { "CI" } | |
| 1218 | + | a href=(format!("/{owner}/{repo}/pages")) { "Pages" } | |
| 1219 | + | @if can_write { | |
| 1220 | + | // Agent sessions start containers and (from M2) push, so | |
| 1221 | + | // they are an owner action — hidden from readers entirely. | |
| 1222 | + | @if app.config.agent.enabled { | |
| 1223 | + | a href=(format!("/{owner}/{repo}/-/agent")) { "Agent" } | |
| 1198 | 1224 | } | |
| 1225 | + | a href=(format!("/{owner}/{repo}/settings")) { "Settings" } | |
| 1199 | 1226 | } | |
| 1200 | 1227 | } | |
| 1201 | 1228 | @if !meta.description.is_empty() { p.muted { (meta.description) } } | |
| ⋯ 1310 unchanged lines | |||
| 2512 | 2539 | } | |
| 2513 | 2540 | ||
| 2514 | 2541 | /// A coloured status pill for a CI run status string. | |
| 2515 | - | fn status_badge(status: &str) -> Markup { | |
| 2542 | + | pub(crate) fn status_badge(status: &str) -> Markup { | |
| 2516 | 2543 | html! { span class=(format!("st {status}")) { (status) } } | |
| 2517 | 2544 | } | |
| 2518 | 2545 | ||
| 2519 | 2546 | /// First 8 hex chars of a commit oid (for compact display). | |
| 2520 | - | fn short_commit(commit: &str) -> &str { | |
| 2547 | + | pub(crate) fn short_commit(commit: &str) -> &str { | |
| 2521 | 2548 | &commit[..commit.len().min(8)] | |
| 2522 | 2549 | } | |
| 2523 | 2550 | ||
| ⋯ 337 unchanged lines | |||
modifieddeploy/runner/Dockerfile+63 −9
| ⋯ 7 unchanged lines | |||
| 8 | 8 | # Parameterized by base so the same recipe produces a small general runner and | |
| 9 | 9 | # a toolchain-carrying one: | |
| 10 | 10 | # | |
| 11 | - | # anvil-runner:latest BASE=debian:bookworm-slim (the default) | |
| 12 | - | # anvil-runner:rust BASE=rust:1.95-bookworm (builds anvil itself) | |
| 11 | + | # anvil-runner:latest BASE=ubuntu:26.04 (the default) | |
| 12 | + | # anvil-runner:rust BASE=rust:1.95-bookworm (builds anvil itself; the | |
| 13 | + | # official Rust image has no | |
| 14 | + | # Ubuntu variant, and the | |
| 15 | + | # tmux/locale fixes below | |
| 16 | + | # only matter for the | |
| 17 | + | # interactive sessions this | |
| 18 | + | # tag never runs) | |
| 13 | 19 | # | |
| 14 | 20 | # Build both with deploy/runner/build.sh. There is NO registry behind this | |
| 15 | 21 | # image — it lives only in the host's local image store, which is why anvil | |
| 16 | 22 | # treats a failed pull of the default image as non-fatal. | |
| 17 | - | ARG BASE=debian:bookworm-slim | |
| 23 | + | ARG BASE=ubuntu:26.04 | |
| 18 | 24 | FROM ${BASE} | |
| 19 | 25 | ||
| 20 | 26 | # Which Claude Code release channel to track. `stable` is roughly a week behind | |
| ⋯ 2 unchanged lines | |||
| 23 | 29 | ||
| 24 | 30 | ENV DEBIAN_FRONTEND=noninteractive | |
| 25 | 31 | ||
| 32 | + | # A TUI (Claude Code's own, or anything a session runs) that thinks it's stuck | |
| 33 | + | # on a 7-bit terminal falls back to ASCII line-drawing instead of real box- | |
| 34 | + | # drawing/bullet glyphs. glibc's built-in C.UTF-8 needs no locale-gen step and | |
| 35 | + | # is present on both Ubuntu and Debian bases. | |
| 36 | + | ENV LANG=C.UTF-8 | |
| 37 | + | ENV LC_ALL=C.UTF-8 | |
| 38 | + | ||
| 26 | 39 | # Fingerprint of the Claude Code release signing key, checked below so a | |
| 27 | 40 | # substituted key fails the build rather than silently installing. Published at | |
| 28 | 41 | # https://code.claude.com/docs/en/setup. Deliberately inlined rather than an | |
| 29 | 42 | # ARG: a build arg could be overridden on the command line, which would defeat | |
| 30 | 43 | # the pin it exists to enforce. | |
| 31 | 44 | ||
| 32 | - | # tmux — session persistence; the whole point of the agent design | |
| 45 | + | # tmux — session persistence; the whole point of the agent design. Needs | |
| 46 | + | # 3.4+ for OSC 8 hyperlink passthrough (a Claude Code TUI's login | |
| 47 | + | # URL, most visibly) — see tmux.conf for the other half (telling | |
| 48 | + | # tmux the attached client accepts it). Ubuntu 26.04 ships 3.6a; | |
| 49 | + | # this is the reason BASE moved off Debian bookworm (stuck on | |
| 50 | + | # 3.3a, pre-dating that support entirely). | |
| 33 | 51 | # git — the container clones/pushes for itself (anvil's own code never | |
| 34 | 52 | # shells out to git, but what a job runs is its own tooling) | |
| 35 | 53 | # fish — the interactive shell you actually get when you attach | |
| 54 | + | # neovim — a sensible $EDITOR for anything a session or an attached human | |
| 55 | + | # shells out to (git commit messages, etc.) | |
| 36 | 56 | # ripgrep — Claude Code's search backend | |
| 37 | 57 | # curl/gnupg/ca-certificates — fetching and verifying the apt repo key | |
| 38 | 58 | RUN apt-get update \ | |
| ⋯ 4 unchanged lines | |||
| 43 | 63 | git \ | |
| 44 | 64 | gnupg \ | |
| 45 | 65 | less \ | |
| 66 | + | neovim \ | |
| 46 | 67 | ripgrep \ | |
| 47 | 68 | tmux \ | |
| 48 | 69 | && install -d -m 0755 /etc/apt/keyrings \ | |
| ⋯ 13 unchanged lines | |||
| 62 | 83 | # the check is pure noise in a container whose version is pinned by the image. | |
| 63 | 84 | ENV DISABLE_AUTOUPDATER=1 | |
| 64 | 85 | ||
| 86 | + | # What `git commit` (no -m) and anything else honouring $EDITOR drops you | |
| 87 | + | # into. Ubuntu's neovim package doesn't register update-alternatives entries | |
| 88 | + | # for vi/vim/editor, so this is the only thing that makes it the default. | |
| 89 | + | ENV EDITOR=nvim | |
| 90 | + | ENV VISUAL=nvim | |
| 91 | + | ||
| 65 | 92 | # An unprivileged user for agent sessions to run as. Deliberately NOT set as | |
| 66 | 93 | # the image's USER: CI pipelines inherit this image's default user, and plenty | |
| 67 | - | # of them expect root for apt-get. anvil passes `user: agent` explicitly when | |
| 94 | + | # of them expect root for apt-get. anvil passes `user: ubuntu` explicitly when | |
| 68 | 95 | # it creates a *session* container, so CI keeps the behaviour it has today. | |
| 69 | - | RUN useradd --create-home --shell /usr/bin/fish --uid 1000 agent \ | |
| 70 | - | && mkdir -p /workspace \ | |
| 71 | - | && chown agent:agent /workspace | |
| 96 | + | # | |
| 97 | + | # Reusing the base image's own `ubuntu` user (uid 1000, matching | |
| 98 | + | # [`container::RUN_AS_UID`]) rather than making a purpose-built account: it's | |
| 99 | + | # already there, so this is just pointing its shell at fish and giving it a | |
| 100 | + | # workspace under its own home ([`container::WORKDIR`]) — under `$HOME` rather | |
| 101 | + | # than a bare `/workspace` so tools that assume a project lives there behave. | |
| 102 | + | RUN usermod --shell /usr/bin/fish ubuntu \ | |
| 103 | + | && mkdir -p /home/ubuntu/workspace \ | |
| 104 | + | && chown ubuntu:ubuntu /home/ubuntu/workspace | |
| 105 | + | ||
| 106 | + | # Baseline Claude Code config for a session: auto theme (so it reads fine | |
| 107 | + | # however the browser terminal is themed) and bypass-permissions, matching the | |
| 108 | + | # `--dangerously-skip-permissions` flag the session launches with (see | |
| 109 | + | # anvil-agent/src/supervisor.rs) — belt and suspenders for anything that reads | |
| 110 | + | # the setting rather than the flag. `agent.credentials_dir`, when configured, | |
| 111 | + | # uploads over `~/.claude` and can add to or override this file. | |
| 112 | + | COPY claude-settings.json /home/ubuntu/.claude/settings.json | |
| 113 | + | RUN chown -R ubuntu:ubuntu /home/ubuntu/.claude | |
| 114 | + | ||
| 115 | + | # settings.json's `theme` only sets the *value*; it does not mark the | |
| 116 | + | # first-run wizard as done, and that state lives in a separate file. Without | |
| 117 | + | # this, every session replays the theme picker and the per-project trust | |
| 118 | + | # dialog regardless of what settings.json says. Keyed on [`container::WORKDIR`] | |
| 119 | + | # — fixed for every session, so this is safe to bake in rather than derive at | |
| 120 | + | # container-creation time. Login still prompts (there | |
| 121 | + | # is nothing to skip: a fresh session has no credentials until | |
| 122 | + | # `agent.credentials_dir` is configured), and that dialog's OSC 8 link is the | |
| 123 | + | # one tmux.conf's `terminal-features` line exists for. | |
| 124 | + | COPY claude-onboarding.json /home/ubuntu/.claude.json | |
| 125 | + | RUN chown ubuntu:ubuntu /home/ubuntu/.claude.json | |
| 72 | 126 | ||
| 73 | 127 | COPY tmux.conf /etc/anvil/tmux.conf | |
| 74 | 128 | COPY session-entrypoint.sh /usr/local/bin/anvil-session | |
| 75 | 129 | RUN chmod 0755 /usr/local/bin/anvil-session | |
| 76 | 130 | ||
| 77 | - | WORKDIR /workspace | |
| 131 | + | WORKDIR /home/ubuntu/workspace | |
modifieddeploy/runner/build.sh+3 −3
| ⋯ 3 unchanged lines | |||
| 4 | 4 | # | |
| 5 | 5 | # Two tags from one Dockerfile, differing only in base: | |
| 6 | 6 | # | |
| 7 | - | # anvil-runner:latest debian:bookworm-slim general purpose, the default | |
| 7 | + | # anvil-runner:latest ubuntu:26.04 general purpose, the default | |
| 8 | 8 | # anvil-runner:rust rust:1.95-bookworm carries the Rust toolchain | |
| 9 | 9 | # | |
| 10 | 10 | # There is deliberately no registry push: anvil resolves its default image from | |
| ⋯ 22 unchanged lines | |||
| 33 | 33 | } | |
| 34 | 34 | ||
| 35 | 35 | case "${1:-all}" in | |
| 36 | - | latest) build latest debian:bookworm-slim ;; | |
| 36 | + | latest) build latest ubuntu:26.04 ;; | |
| 37 | 37 | rust) build rust rust:1.95-bookworm ;; | |
| 38 | 38 | all) | |
| 39 | - | build latest debian:bookworm-slim | |
| 39 | + | build latest ubuntu:26.04 | |
| 40 | 40 | build rust rust:1.95-bookworm | |
| 41 | 41 | ;; | |
| 42 | 42 | *) | |
| ⋯ 14 unchanged lines | |||
addeddeploy/runner/claude-onboarding.json+9 −0
| 1 | + | { | |
| 2 | + | "hasCompletedOnboarding": true, | |
| 3 | + | "projects": { | |
| 4 | + | "/home/ubuntu/workspace": { | |
| 5 | + | "hasTrustDialogAccepted": true, | |
| 6 | + | "hasCompletedProjectOnboarding": true | |
| 7 | + | } | |
| 8 | + | } | |
| 9 | + | } |
addeddeploy/runner/claude-settings.json+8 −0
| 1 | + | { | |
| 2 | + | "theme": "auto", | |
| 3 | + | "tui": "fullscreen", | |
| 4 | + | "permissions": { | |
| 5 | + | "defaultMode": "bypassPermissions" | |
| 6 | + | }, | |
| 7 | + | "skipDangerousModePermissionPrompt": true | |
| 8 | + | } |
modifieddeploy/runner/session-entrypoint.sh+1 −1
| ⋯ 12 unchanged lines | |||
| 13 | 13 | ||
| 14 | 14 | SESSION="${ANVIL_TMUX_SESSION:-agent}" | |
| 15 | 15 | TRANSCRIPT="${ANVIL_TRANSCRIPT:-/tmp/anvil-transcript}" | |
| 16 | - | WORKDIR="${ANVIL_WORKDIR:-/workspace}" | |
| 16 | + | WORKDIR="${ANVIL_WORKDIR:-/home/ubuntu/workspace}" | |
| 17 | 17 | EXIT_FILE="${ANVIL_EXIT_FILE:-/tmp/anvil-exit}" | |
| 18 | 18 | CMD_FILE="${ANVIL_CMD_FILE:-/tmp/anvil-cmd.sh}" | |
| 19 | 19 | ||
| ⋯ 50 unchanged lines | |||
modifieddeploy/runner/tmux.conf+29 −3
| ⋯ 4 unchanged lines | |||
| 5 | 5 | # behaves like a real terminal for a TUI and that scrollback is deep enough to | |
| 6 | 6 | # replay on reconnect. | |
| 7 | 7 | ||
| 8 | - | # No status bar: the web UI already shows session state around the terminal, | |
| 9 | - | # and a status line would eat a row and confuse `capture-pane` replay. | |
| 10 | - | set -g status off | |
| 8 | + | # Status bar at the top. Note: `capture-pane` (the reconnect snapshot) only | |
| 9 | + | # ever sees pane content, never this bar — it's client chrome — so a browser | |
| 10 | + | # that reconnects gets a one-row jump versus the live view until the next | |
| 11 | + | # repaint. Cosmetic, not a correctness issue. | |
| 12 | + | set -g status on | |
| 13 | + | set -g status-position top | |
| 11 | 14 | ||
| 12 | 15 | # Mouse reporting through to the application, so a TUI's click targets and | |
| 13 | 16 | # scroll work in the browser. | |
| 14 | 17 | set -g mouse on | |
| 15 | 18 | ||
| 19 | + | # Forward focus in/out (DECSET 1004) to the pane. Without it Claude Code warns | |
| 20 | + | # on startup and can't tell when the browser tab is focused — wterm's input | |
| 21 | + | # layer already tracks real DOM focus/blur on the terminal element and turns | |
| 22 | + | # it into the same escape sequences, this is what lets tmux pass them through. | |
| 23 | + | set -g focus-events on | |
| 24 | + | ||
| 16 | 25 | # Deep scrollback — `capture-pane -S -` replays this on reconnect, and an agent | |
| 17 | 26 | # session produces a lot of output before anyone looks at it. | |
| 18 | 27 | set -g history-limit 50000 | |
| ⋯ 3 unchanged lines | |||
| 22 | 31 | set -g default-terminal "tmux-256color" | |
| 23 | 32 | set -ga terminal-overrides ",*256col*:Tc" | |
| 24 | 33 | ||
| 34 | + | # The attached client is always anvil's own websocket bridge (see | |
| 35 | + | # container.rs's `attach`), never a real terminal tmux can query — so rather | |
| 36 | + | # than rely on tmux's built-in detection for the client's TERM (xterm-256color, | |
| 37 | + | # set in `attach`'s exec env), say explicitly that it understands OSC 8. wterm | |
| 38 | + | # already parses OSC 8 (docs/agent-sessions.md); this is what lets those | |
| 39 | + | # sequences reach it instead of being dropped in tmux. | |
| 40 | + | set -ga terminal-features ",*:hyperlinks" | |
| 41 | + | ||
| 25 | 42 | # Keep the pane after its command exits, so the browser can still read the last | |
| 26 | 43 | # screen; the entrypoint decides when the container is actually done. | |
| 27 | 44 | set -g remain-on-exit on | |
| 28 | 45 | ||
| 46 | + | # With remain-on-exit on, a dead pane never actually closes — it just stops | |
| 47 | + | # accepting input — so tmux never re-selects for it the way it would if the | |
| 48 | + | # pane were destroyed. `pane-died` is the hook that fires for exactly that | |
| 49 | + | # case (see tmux.1: "the program running in a pane exits, but remain-on-exit | |
| 50 | + | # is on so the pane has not closed"). Only matters once something splits the | |
| 51 | + | # window — a single-pane session has nowhere else to go — which is what a | |
| 52 | + | # Claude Code teammate spawned with `teammateMode: tmux` would do. | |
| 53 | + | set-hook -g pane-died 'select-pane -t :.+' | |
| 54 | + | ||
| 29 | 55 | # Do not renumber or rename out from under the supervisor, which addresses the | |
| 30 | 56 | # session by name. | |
| 31 | 57 | set -g allow-rename off | |
| ⋯ 6 unchanged lines | |||
modifieddocs/agent-sessions.md+1 −1
| ⋯ 51 unchanged lines | |||
| 52 | 52 | ||
| 53 | 53 | | Tag | Base | For | | |
| 54 | 54 | | -------------------- | --------------------- | -------------------------- | | |
| 55 | - | | `anvil-runner:latest`| `debian:bookworm-slim`| the default, general work | | |
| 55 | + | | `anvil-runner:latest`| `ubuntu:26.04` | the default, general work | | |
| 56 | 56 | | `anvil-runner:rust` | `rust:1.95-bookworm` | pipelines needing the toolchain | | |
| 57 | 57 | ||
| 58 | 58 | **There is no registry behind this image.** It is built straight into the | |
| ⋯ 91 unchanged lines | |||