anvilsign in

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
5353 sips -Z 1100 -s formatOptions 70 /tmp/att --out /tmp/att-small.jpg # then Read that
5454 ```
5555
56+note that we can't use portless for this app because of a websocets bug with http/2
57+
5658 Current status, resume notes, the agreed next steps (a/b/c), and the roadmap live
5759 in the TODO. Read it first:
5860
⋯ 1 unchanged line
modifiedCargo.lock+72 −1
⋯ 122 unchanged lines
123123 name = "anvil-agent"
124124 version = "0.0.0"
125125 dependencies = [
126- "anvil-ci",
127126 "anvil-core",
127+ "anvil-docker",
128128 "anvil-git",
129129 "async-trait",
130130 "bollard",
⋯ 8 unchanged lines
139139 version = "0.0.0"
140140 dependencies = [
141141 "anvil-core",
142+ "anvil-docker",
142143 "anvil-git",
143144 "bollard",
144145 "flate2",
⋯ 40 unchanged lines
185186 "curve25519-dalek",
186187 "gix",
187188 "hmac 0.12.1",
189+ "jaq-core",
190+ "jaq-json",
191+ "jaq-std",
188192 "pulldown-cmark",
189193 "rusqlite",
190194 "serde",
⋯ 10 unchanged lines
201205 ]
202206
203207 [[package]]
208+name = "anvil-docker"
209+version = "0.0.0"
210+dependencies = [
211+ "bollard",
212+ "futures-util",
213+ "tracing",
214+]
215+
216+[[package]]
204217 name = "anvil-git"
205218 version = "0.0.0"
206219 dependencies = [
⋯ 2068 unchanged lines
22752288 checksum = "e712f64ec3850b98572bffac52e2c6f282b29fe6c5fa6d42334b30be438d95c1"
22762289
22772290 [[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]]
22782297 name = "hkdf"
22792298 version = "0.13.0"
22802299 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 385 unchanged lines
26662685 checksum = "8f42a60cbdf9a97f5d2305f08a87dc4e09308d1276d28c869c684d7777685682"
26672686
26682687 [[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]]
26692728 name = "jiff"
26702729 version = "0.2.28"
26712730 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 1403 unchanged lines
40754134 ]
40764135
40774136 [[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]]
40784143 name = "semver"
40794144 version = "1.0.28"
40804145 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 898 unchanged lines
49795044 ]
49805045
49815046 [[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]]
49825053 name = "typenum"
49835054 version = "1.20.1"
49845055 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 736 unchanged lines
modifiedCargo.toml+1 −0
⋯ 15 unchanged lines
1616 # Internal crates
1717 anvil-core = { path = "crates/anvil-core" }
1818 anvil-ci = { path = "crates/anvil-ci" }
19+anvil-docker = { path = "crates/anvil-docker" }
1920 anvil-agent = { path = "crates/anvil-agent" }
2021 anvil-git = { path = "crates/anvil-git" }
2122 anvil-web = { path = "crates/anvil-web" }
⋯ 83 unchanged lines
modifiedDockerfile+1 −1
⋯ 5 unchanged lines
66 # So building this image is a fast `COPY` — no QEMU-emulated release build, and
77 # the VPS never compiles anything.
88
9-FROM debian:bookworm-slim
9+FROM ubuntu:26.04
1010
1111 # No git in the image: anvil is pure gitoxide (see CLAUDE.md), including the
1212 # push-mirroring client.
⋯ 21 unchanged lines
modifiedcrates/anvil-agent/Cargo.toml+2 −2
⋯ 7 unchanged lines
88 description = "Agent sessions for anvil: a tmux-hosted agent CLI per repository, in a container, attachable from the browser."
99
1010 [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
1212 # duplicated — both start containers from the same runner image.
13-anvil-ci.workspace = true
13+anvil-docker.workspace = true
1414 anvil-core.workspace = true
1515 async-trait.workspace = true
1616 anvil-git.workspace = true
⋯ 5 unchanged lines
modifiedcrates/anvil-agent/src/container.rs+51 −14
⋯ 13 unchanged lines
1414 container::{
1515 Config,
1616 CreateContainerOptions,
17+ DownloadFromContainerOptions,
1718 RemoveContainerOptions,
1819 StartContainerOptions,
1920 UploadToContainerOptions,
⋯ 6 unchanged lines
2627 },
2728 models::HostConfig,
2829 };
30+use futures_util::StreamExt;
2931
3032 /// Label carrying the session id, so containers can be found again after a
3133 /// restart — the registry is in-memory and does not survive one.
3234 pub const LABEL_SESSION: &str = "anvil.session";
3335
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";
3641
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";
4149
4250 /// That user's uid/gid, needed when building tars so the uploaded files are
4351 /// owned by the account that has to write them.
4452 const RUN_AS_UID: u64 = 1000;
4553
4654 /// 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";
4856
4957 /// tmux session name, matching `session-entrypoint.sh`.
5058 pub const TMUX_SESSION: &str = "agent";
⋯ 30 unchanged lines
8189 env: Some(
8290 env.iter()
8391 .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+ ])
8597 .collect(),
8698 ),
8799 working_dir: Some(WORKDIR.to_string()),
⋯ 33 unchanged lines
121133 .map_err(|e| format!("upload to session container: {e}"))
122134 }
123135
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+
124161 /// Build a tar of `(path, contents, executable)` entries, rooted at `prefix`
125162 /// (no leading slash) and owned by the session user.
126163 ///
127164 /// Emits an explicit entry for every intermediate **directory**, which matters
128165 /// 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.
132169 pub fn build_tar(prefix: &str, files: &[(String, Vec<u8>, bool)]) -> Vec<u8> {
133170 let mut builder = tar::Builder::new(Vec::new());
134171 let mtime = anvil_core::agent::now_secs().max(0) as u64;
⋯ 3 unchanged lines
138175 header.set_size(size);
139176 header.set_mode(mode);
140177 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.
143180 header.set_uid(RUN_AS_UID);
144181 header.set_gid(RUN_AS_UID);
145182 // Without this every file lands in 1970, which upsets anything that
⋯ 21 unchanged lines
167204 }
168205 dirs.sort_by_key(|d| d.matches('/').count());
169206
170- // The root itself, so `/workspace` is agent-owned even when empty.
207+ // The root itself, so `prefix` is agent-owned even when empty.
171208 let mut header = header_for(0, 0o755, tar::EntryType::Directory);
172209 let _ = builder.append_data(&mut header, format!("{prefix}/"), std::io::empty());
173210 for dir in &dirs {
⋯ 390 unchanged lines
modifiedcrates/anvil-agent/src/lib.rs+9 −2
⋯ 3 unchanged lines
44 //! Deliberately a sibling of `anvil-ci` rather than part of it. The CI runner
55 //! is a single task processing one job at a time; a session lives for hours and
66 //! 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.
88 //!
99 //! The shape:
1010 //!
⋯ 49 unchanged lines
6060
6161 /// Start a session against `repo_id` at `base_ref`, returning its id.
6262 ///
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+///
6368 /// Creates the row first so a failure part-way through is still visible in the
6469 /// UI, then hands off to a supervisor task. Returns as soon as the container is
6570 /// up — the caller redirects to the session page and attaches from there.
⋯ 4 unchanged lines
7075 kind: &str,
7176 base_ref: &str,
7277 prompt: &str,
78+ secret_names: &[String],
7379 ) -> Result<i64, StartError> {
7480 let cfg = &app.config.agent;
7581 if !cfg.enabled {
⋯ 17 unchanged lines
9399 &base_commit,
94100 prompt,
95101 &cfg.image,
102+ &secret_names.join(","),
96103 )
97104 .await
98105 .map_err(|e| StartError::Failed(e.to_string()))?;
⋯ 32 unchanged lines
131138 /// does for interrupted runs — except a terminal session cannot be resumed, so
132139 /// it ends rather than re-queues.
133140 pub async fn reconcile(app: &App) {
134- let docker = match anvil_ci::docker::connect() {
141+ let docker = match anvil_docker::connect() {
135142 Ok(docker) => docker,
136143 Err(e) => {
137144 tracing::warn!("agent: skipping reconcile, {e}");
⋯ 72 unchanged lines
modifiedcrates/anvil-agent/src/supervisor.rs+129 −6
⋯ 11 unchanged lines
1212 },
1313 models::AgentSession,
1414 repos,
15+ secrets,
1516 storage,
1617 users,
1718 };
⋯ 33 unchanged lines
5152 /// session page, which attaches over a websocket.
5253 pub async fn launch(app: App, session: AgentSession, repo_path: PathBuf) -> Result<(), String> {
5354 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?;
5657
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+
5767 // The agent CLI, run interactively under tmux. `--dangerously-skip-
5868 // permissions` is defensible precisely because the container *is* the
5969 // sandbox: all capabilities dropped, no socket, no mounts, nothing of
⋯ 4 unchanged lines
6474 "--dangerously-skip-permissions".to_string(),
6575 ];
6676
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?;
6878
6979 // Seed the workspace. M1 uploads the commit's files, exactly as CI does —
7080 // no `.git`, so no credential is needed anywhere yet.
⋯ 16 unchanged lines
8797 return Err(e);
8898 }
8999
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+
90110 if let Err(e) = container::start(&docker, &container_id).await {
91111 container::remove(&docker, &container_id).await;
92112 return Err(e);
⋯ 23 unchanged lines
116136 Ok(())
117137 }
118138
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(&current, &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+
119242 /// Upload the configured credentials directory to the session user's home.
120243 ///
121244 /// Empty config means sessions start unauthenticated, which is a legitimate
⋯ 30 unchanged lines
152275 mut shutdown: tokio::sync::watch::Receiver<bool>,
153276 prompt: String,
154277 ) {
155- let docker = match anvil_ci::docker::connect() {
278+ let docker = match anvil_docker::connect() {
156279 Ok(docker) => docker,
157280 Err(e) => {
158281 finish(&app, session_id, &container_id, status::FAILED, 0, &e).await;
⋯ 129 unchanged lines
288411 exit_code: i64,
289412 error: &str,
290413 ) {
291- if let Ok(docker) = anvil_ci::docker::connect() {
414+ if let Ok(docker) = anvil_docker::connect() {
292415 container::remove(&docker, container_id).await;
293416 }
294417 app.sessions.remove(session_id);
⋯ 18 unchanged lines
313436 .get(session_id)
314437 .ok_or("session is not running")?;
315438
316- let docker = anvil_ci::docker::connect()?;
439+ let docker = anvil_docker::connect()?;
317440 let terminal = container::attach(&docker, &handle.container_id, cols, rows).await?;
318441
319442 handle
⋯ 17 unchanged lines
modifiedcrates/anvil-ci/Cargo.toml+1 −0
⋯ 8 unchanged lines
99
1010 [dependencies]
1111 anvil-core.workspace = true
12+anvil-docker.workspace = true
1213 anvil-git.workspace = true
1314 bollard.workspace = true
1415 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
1414 //! pids/memory/cpu caps plus a wall-clock timeout and an optional image
1515 //! allowlist ([`anvil_core::config::CiConfig`]).
1616
17-pub mod docker;
18-
1917 use std::{
2018 collections::BTreeMap,
2119 io::Read,
⋯ 378 unchanged lines
400398 "image {image} is not permitted by ci.allowed_images"
401399 ));
402400 }
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?;
405403
406404 // Build a single `set -e` script from the steps. The steps run in a
407405 // 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
1919 Identity,
2020 Recipient,
2121 body_aad,
22+ kind,
2223 seal,
24+ user_aad,
2325 };
2426 use anyhow::{
2527 Context,
⋯ 39 unchanged lines
6567 /// Re-seal every secret to the owner's current ssh keys — run this after
6668 /// adding a key, which otherwise cannot open anything sealed before it.
6769 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),
6874 }
6975
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+
70133 /// Connection and identity options shared by every `secret` subcommand.
71134 #[derive(clap::Args)]
72135 pub struct SecretOpts {
⋯ 28 unchanged lines
101164 Ok(())
102165 }
103166 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,
104210 }
105211 }
106212
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+
107227 // --- commands --------------------------------------------------------------
108228
109229 async fn list(client: &Client, repo: &str) -> Result<()> {
⋯ 149 unchanged lines
259379 Ok(())
260380 }
261381
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+
262533 // --- identity --------------------------------------------------------------
263534
264535 fn load_identity(opts: &SecretOpts) -> Result<Identity> {
⋯ 91 unchanged lines
356627 }
357628 }
358629
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+
359665 impl Client {
360666 fn new(opts: &SecretOpts, config_base_url: &str) -> Result<Self> {
361667 // HTTPS needs a crypto provider installed; the build deliberately has
⋯ 94 unchanged lines
456762 check(response).await?;
457763 Ok(())
458764 }
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+ }
459846 }
460847
461848 async fn check(response: reqwest::Response) -> Result<reqwest::Response> {
⋯ 62 unchanged lines
modifiedcrates/anvil-core/Cargo.toml+3 −0
⋯ 26 unchanged lines
2727 tracing.workspace = true
2828 tokio.workspace = true
2929 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"] }
3033
3134 [dev-dependencies]
3235 tokio = { workspace = true }
⋯ 5 unchanged lines
modifiedcrates/anvil-core/src/agent.rs+2 −0
⋯ 52 unchanged lines
5353 base_commit: &str,
5454 prompt: &str,
5555 image: &str,
56+ secret_names: &str,
5657 ) -> Result<AgentSession> {
5758 let mut conn = db.clone();
5859 let now = crate::now();
⋯ 14 unchanged lines
7374 last_attach_at: 0,
7475 exit_code: 0,
7576 error: "",
77+ secret_names: secret_names,
7678 })
7779 .exec(&mut conn)
7880 .await?;
⋯ 212 unchanged lines
modifiedcrates/anvil-core/src/db.rs+25 −2
⋯ 17 unchanged lines
1818 Session,
1919 SshKey,
2020 User,
21+ UserSecret,
2122 },
2223 };
2324
⋯ 25 unchanged lines
4950 ApiToken,
5051 AdminCache,
5152 RepoSecret,
52- AgentSession
53+ AgentSession,
54+ UserSecret
5355 ))
5456 .connect(&url)
5557 .await?;
⋯ 29 unchanged lines
8587 AGENT_SESSIONS_DDL,
8688 r#"CREATE INDEX IF NOT EXISTS "index_agent_sessions_by_repo_id" ON "agent_sessions" ("repo_id")"#,
8789 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")"#,
8892 ];
8993
9094 const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
⋯ 79 unchanged lines
170174 "finished_at" BIGINT NOT NULL,
171175 "last_attach_at" BIGINT NOT NULL,
172176 "exit_code" BIGINT NOT NULL,
173-"error" TEXT NOT NULL )"#;
177+"error" TEXT NOT NULL,
178+"secret_names" TEXT NOT NULL )"#;
174179
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+
175192 /// Columns added to existing tables after deployment, applied as
176193 /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS`
177194 /// for columns, so presence is checked via `pragma_table_info`). The model
⋯ 26 unchanged lines
204221 "sso_sub",
205222 r#"ALTER TABLE "users" ADD COLUMN "sso_sub" TEXT NOT NULL DEFAULT ''"#,
206223 ),
224+ (
225+ "agent_sessions",
226+ "secret_names",
227+ r#"ALTER TABLE "agent_sessions" ADD COLUMN "secret_names" TEXT NOT NULL DEFAULT ''"#,
228+ ),
207229 ];
208230
209231 /// Apply [`SCHEMA_SHIMS`] and [`COLUMN_SHIMS`] to an existing database. Uses
⋯ 35 unchanged lines
245267 "admin_caches",
246268 "repo_secrets",
247269 "agent_sessions",
270+ "user_secrets",
248271 ];
249272
250273 /// Every schema object (table + indexes) for `table`, normalized.
⋯ 129 unchanged lines
modifiedcrates/anvil-core/src/lib.rs+7 −0
⋯ 68 unchanged lines
6969 /// Plaintext repo secrets for CI, held in memory only and lost on
7070 /// restart — see [`secrets::Vault`].
7171 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,
7278 /// Agent sessions live in this process, keyed by session id. Empty after a
7379 /// restart, which is why startup reconciles rows against containers — see
7480 /// [`agent::Registry`].
⋯ 18 unchanged lines
9399 db,
94100 ci_tx: None,
95101 vault: secrets::Vault::default(),
102+ user_vault: secrets::Vault::default(),
96103 sessions: agent::Registry::default(),
97104 csrf_secret,
98105 })
⋯ 53 unchanged lines
modifiedcrates/anvil-core/src/models.rs+43 −0
⋯ 155 unchanged lines
156156 pub exit_code: i64,
157157 /// Supervisor-facing failure detail, empty when there is none.
158158 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,
159165 }
160166
161167 /// An issue on a repository. `number` is the user-facing per-repo sequence
⋯ 134 unchanged lines
296302 pub updated_at: i64,
297303 }
298304
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+
299342 /// Cached admin metrics computed periodically (e.g., disk usage snapshot).
300343 #[derive(Clone, Debug, toasty::Model)]
301344 pub struct AdminCache {
⋯ 10 unchanged lines
modifiedcrates/anvil-core/src/secrets.rs+355 −1
⋯ 51 unchanged lines
5252 Error,
5353 Result,
5454 },
55- models::RepoSecret,
55+ models::{
56+ RepoSecret,
57+ UserSecret,
58+ },
5659 };
5760
5861 /// Algorithm identifier carried in every envelope.
⋯ 268 unchanged lines
327330 format!("anvil-secret-v1\n{owner}/{repo}\n{name}").into_bytes()
328331 }
329332
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+
330353 /// Whether `name` is usable as a shell environment variable: uppercase,
331354 /// digits, and underscores, not starting with a digit.
332355 pub fn valid_name(name: &str) -> bool {
⋯ 91 unchanged lines
424447 scalar
425448 }
426449
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+
427563 // --- persistence -----------------------------------------------------------
428564
429565 /// List a repository's secrets, oldest first. Envelopes are opaque here.
⋯ 72 unchanged lines
502638 Ok(())
503639 }
504640
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+
505770 // --- the unlock vault ------------------------------------------------------
506771
507772 /// Plaintext secrets for unlocked repositories, held in memory only.
⋯ 126 unchanged lines
634899
635900 use super::*;
636901
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+
637941 fn keypair() -> (PrivateKey, Recipient) {
638942 let key = PrivateKey::from(Ed25519Keypair::from_seed(&random_bytes()));
639943 let line = key.public_key().to_openssh().unwrap();
⋯ 40 unchanged lines
680984 }
681985
682986 #[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]
6831037 fn tampering_with_the_body_is_detected() {
6841038 let (key, r) = keypair();
6851039 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
202202 .map_err(server_error)?;
203203 let enabled = app.config.agent.enabled;
204204 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+ };
205211
206212 Ok(layout(
207- &format!("{owner}/{name} · agent"),
213+ &format!("{owner}/{name} · Agent"),
208214 user.as_ref(),
209215 html! {
210- h1 { "Agent sessions" }
216+ h1 { a href=(format!("/{owner}/{name}")) { (owner) "/" (name) } " · Agent" }
211217 @if !enabled {
212- p.notice {
218+ p.muted {
213219 "Agent sessions are disabled. Set "
214220 code { "agent.enabled" }
215221 " in anvil.toml to turn them on."
216222 }
217223 } @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" {
219231 (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; }
223234 }
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+ }
227260 }
228- button type="submit" { "Start session" }
261+ p { button.btn type="submit" { "Start session" } }
229262 }
230263 }
231264 @if sessions.is_empty() {
232- p { "No sessions yet." }
265+ p.muted { "No sessions yet." }
233266 } @else {
234- ul.sessions {
267+ div.box {
235268 @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) }
239277 }
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)) }
244279 }
245280 }
246281 }
⋯ 2 unchanged lines
249284 ))
250285 }
251286
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) } }
254297 }
255298
256299 /// `POST /{owner}/{repo}/-/agent` — start a session.
⋯ 22 unchanged lines
279322 kind::AUTONOMOUS
280323 };
281324
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+ {
283336 Ok(id) => Ok(Redirect::to(&format!("/{owner}/{name}/-/agent/{id}")).into_response()),
284337 // Being at capacity, or switched off, is a normal answer rather than a
285338 // fault — "something went wrong" would send someone hunting a bug that
⋯ 30 unchanged lines
316369 base_ref: String,
317370 #[serde(default)]
318371 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>,
319377 }
320378
321379 /// `POST /{owner}/{repo}/-/agent/{id}/stop`
⋯ 41 unchanged lines
363421 let ws_path = format!("/{owner}/{name}/-/agent/{id}/ws");
364422
365423 Ok(layout(
366- &format!("{owner}/{name} · agent #{id}"),
424+ &format!("{owner}/{name} · Agent #{id}"),
367425 user.as_ref(),
368426 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+ }
370443 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)) }
373446 @if !session.error.is_empty() { " · " (session.error) }
374447 }
375448 @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" {}
379452 }
380- link rel="stylesheet" href="/-/static/wterm/terminal.css";
381- div #terminal style="height:70vh" {}
382453 script type="importmap" {
383454 (PreEscaped(IMPORT_MAP))
384455 }
⋯ 3 unchanged lines
388459 TERMINAL_JS)))
389460 }
390461 } @else {
391- p { "This session has ended." }
462+ p.muted { "This session has ended." }
392463 }
393464 },
394465 ))
⋯ 19 unchanged lines
414485 const encode = new TextEncoder();
415486 let torndown = false;
416487
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+
417497 const sendResize = () => {
418498 if (ws.readyState === WebSocket.OPEN) {
419499 ws.send(JSON.stringify({ t: "resize", cols: term.cols, rows: term.rows }));
420500 }
421501 };
422502
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) => {
444504 if (event.data instanceof ArrayBuffer) {
445505 term.write(new Uint8Array(event.data));
446506 return;
⋯ 11 unchanged lines
458518 }
459519 };
460520
521+ws.onopen = sendResize;
522+ws.onmessage = (event) => {
523+ if (ready) renderMessage(event);
524+ else pending.push(event);
525+};
461526 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 });
463531 };
464532
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+
465556 // The layout sets hx-boost on <body>, so navigating away swaps the DOM without
466557 // a page load. Without this teardown the socket and the WASM instance would
467558 // leak on every navigation.
⋯ 53 unchanged lines
521612 mut input,
522613 } = terminal;
523614
615+ tracing::info!("agent: session {session_id} viewer attached (exec {exec_id})");
616+
524617 // Container -> browser.
525618 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+ }
538647 }
539648 }
540649 let _ = sink.close().await;
⋯ 3 unchanged lines
544653 let control_docker = docker.clone();
545654 let control_exec = exec_id.clone();
546655 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}");
551661 break;
552662 }
553663 let _ = input.flush().await;
554664 }
555- Ok(Message::Text(text)) => {
665+ Some(Ok(Message::Text(text))) => {
556666 if let Some((cols, rows)) = parse_resize(&text) {
557667 anvil_agent::container::resize(&control_docker, &control_exec, cols, rows)
558668 .await;
559669 }
560670 }
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+ }
562685 _ => {}
563686 }
564687 }
⋯ 45 unchanged lines
modifiedcrates/anvil-web/src/secrets.rs+591 −68
⋯ 80 unchanged lines
8181 // Plain form posts from the settings page (no JSON, no plaintext).
8282 .route("/{owner}/{repo}/-/secrets/{name}/delete", post(ui_delete))
8383 .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))
8497 }
8598
8699 // --- request plumbing ------------------------------------------------------
⋯ 320 unchanged lines
407420 Ok(meta)
408421 }
409422
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+}
411463
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,
417473 }
418474
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
429495 .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,
434504 })
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,
436535 }
437536
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
447575 };
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),
454589 }
455590 }
456591
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+}
485609
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())) "."
490797 form method="post" action=(format!("/{owner}/{repo}/-/secrets/lock")) style="display:inline;margin-left:8px" {
491798 (csrf_input(&csrf))
492799 button.btn.btn-secondary type="submit" { "Lock now" }
⋯ 68 unchanged lines
561868 }
562869 }
563870
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+
5641003 /// Browser-side sealing, exposed as `anvilSealSecret(repo, name, value,
5651004 /// recipients)`.
5661005 ///
⋯ 152 unchanged lines
7191158 });
7201159 })();
7211160 "#;
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
155155 .linkbtn:hover { text-decoration:underline; }
156156 .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; }
157157 .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; }
161162 .repo-title { display:flex; align-items:baseline; flex-wrap:wrap; gap:8px; min-width:0; }
162163 .repo-title h1 { margin:0; }
163164 .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); }
168169 .repo-meta { display:flex; gap:8px; margin:8px 0; color:var(--muted); font-size:13px; }
169170 .repo-meta b { font-weight:600; color:var(--fg); }
170171 .pill-group { display:inline-flex; }
⋯ 109 unchanged lines
280281 .st { font-size:11px; border-radius:2em; padding:1px 9px; font-weight:600; text-transform:capitalize; }
281282 .st.queued { background:var(--code-bg); color:var(--muted); } .st.running { background:var(--warning-bg); color:var(--warning); }
282283 .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); }
283290 .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); }
284291 @media (prefers-color-scheme: dark) {
285292 .log { background:#0d1117; color:#e6edf3; border:0; }
⋯ 399 unchanged lines
685692 Err(e) => return server_error(e),
686693 };
687694 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()
689697 }
690698
691699 /// `POST /settings/keys` — register an SSH public key for the current user.
⋯ 24 unchanged lines
716724 .await
717725 .unwrap_or_default();
718726 let tokens = api_tokens::list(&app.db, user.id).await.unwrap_or_default();
727+ let secrets = crate::secrets::user_settings_section(&app, &user).await;
719728 (
720729 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+ ),
722739 )
723740 .into_response()
724741 }
⋯ 34 unchanged lines
759776 .await
760777 .unwrap_or_default();
761778 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()
763790 }
764791
765792 /// `POST /settings/tokens/{id}/delete` — revoke one of the current user's
⋯ 47 unchanged lines
813840 new_token: Option<&str>,
814841 error: Option<&str>,
815842 csrf: &str,
843+ secrets: Markup,
816844 ) -> Markup {
817845 layout(
818846 "Account settings",
⋯ 67 unchanged lines
886914 p { label { "Name" br; input type="text" name="name" placeholder="claude"; } }
887915 p { button.btn type="submit" { "Create token" } }
888916 }
917+
918+ (secrets)
889919 },
890920 )
891921 }
⋯ 126 unchanged lines
10181048
10191049 h2 { "…or push an existing repository" }
10201050 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")) }
10221052 },
10231053 )
10241054 }
⋯ 155 unchanged lines
11801210 h1 { a href=(format!("/{owner}")) { (owner) } " / " (repo) }
11811211 @if meta.is_private { span.pill { "private" } }
11821212 }
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" }
11981224 }
1225+ a href=(format!("/{owner}/{repo}/settings")) { "Settings" }
11991226 }
12001227 }
12011228 @if !meta.description.is_empty() { p.muted { (meta.description) } }
⋯ 1310 unchanged lines
25122539 }
25132540
25142541 /// 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 {
25162543 html! { span class=(format!("st {status}")) { (status) } }
25172544 }
25182545
25192546 /// 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 {
25212548 &commit[..commit.len().min(8)]
25222549 }
25232550
⋯ 337 unchanged lines
modifieddeploy/runner/Dockerfile+63 −9
⋯ 7 unchanged lines
88 # Parameterized by base so the same recipe produces a small general runner and
99 # a toolchain-carrying one:
1010 #
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)
1319 #
1420 # Build both with deploy/runner/build.sh. There is NO registry behind this
1521 # image — it lives only in the host's local image store, which is why anvil
1622 # treats a failed pull of the default image as non-fatal.
17-ARG BASE=debian:bookworm-slim
23+ARG BASE=ubuntu:26.04
1824 FROM ${BASE}
1925
2026 # Which Claude Code release channel to track. `stable` is roughly a week behind
⋯ 2 unchanged lines
2329
2430 ENV DEBIAN_FRONTEND=noninteractive
2531
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+
2639 # Fingerprint of the Claude Code release signing key, checked below so a
2740 # substituted key fails the build rather than silently installing. Published at
2841 # https://code.claude.com/docs/en/setup. Deliberately inlined rather than an
2942 # ARG: a build arg could be overridden on the command line, which would defeat
3043 # the pin it exists to enforce.
3144
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).
3351 # git — the container clones/pushes for itself (anvil's own code never
3452 # shells out to git, but what a job runs is its own tooling)
3553 # 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.)
3656 # ripgrep — Claude Code's search backend
3757 # curl/gnupg/ca-certificates — fetching and verifying the apt repo key
3858 RUN apt-get update \
⋯ 4 unchanged lines
4363 git \
4464 gnupg \
4565 less \
66+ neovim \
4667 ripgrep \
4768 tmux \
4869 && install -d -m 0755 /etc/apt/keyrings \
⋯ 13 unchanged lines
6283 # the check is pure noise in a container whose version is pinned by the image.
6384 ENV DISABLE_AUTOUPDATER=1
6485
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+
6592 # An unprivileged user for agent sessions to run as. Deliberately NOT set as
6693 # 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
6895 # 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
72126
73127 COPY tmux.conf /etc/anvil/tmux.conf
74128 COPY session-entrypoint.sh /usr/local/bin/anvil-session
75129 RUN chmod 0755 /usr/local/bin/anvil-session
76130
77-WORKDIR /workspace
131+WORKDIR /home/ubuntu/workspace
modifieddeploy/runner/build.sh+3 −3
⋯ 3 unchanged lines
44 #
55 # Two tags from one Dockerfile, differing only in base:
66 #
7-# anvil-runner:latest debian:bookworm-slim general purpose, the default
7+# anvil-runner:latest ubuntu:26.04 general purpose, the default
88 # anvil-runner:rust rust:1.95-bookworm carries the Rust toolchain
99 #
1010 # There is deliberately no registry push: anvil resolves its default image from
⋯ 22 unchanged lines
3333 }
3434
3535 case "${1:-all}" in
36- latest) build latest debian:bookworm-slim ;;
36+ latest) build latest ubuntu:26.04 ;;
3737 rust) build rust rust:1.95-bookworm ;;
3838 all)
39- build latest debian:bookworm-slim
39+ build latest ubuntu:26.04
4040 build rust rust:1.95-bookworm
4141 ;;
4242 *)
⋯ 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
1313
1414 SESSION="${ANVIL_TMUX_SESSION:-agent}"
1515 TRANSCRIPT="${ANVIL_TRANSCRIPT:-/tmp/anvil-transcript}"
16-WORKDIR="${ANVIL_WORKDIR:-/workspace}"
16+WORKDIR="${ANVIL_WORKDIR:-/home/ubuntu/workspace}"
1717 EXIT_FILE="${ANVIL_EXIT_FILE:-/tmp/anvil-exit}"
1818 CMD_FILE="${ANVIL_CMD_FILE:-/tmp/anvil-cmd.sh}"
1919
⋯ 50 unchanged lines
modifieddeploy/runner/tmux.conf+29 −3
⋯ 4 unchanged lines
55 # behaves like a real terminal for a TUI and that scrollback is deep enough to
66 # replay on reconnect.
77
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
1114
1215 # Mouse reporting through to the application, so a TUI's click targets and
1316 # scroll work in the browser.
1417 set -g mouse on
1518
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+
1625 # Deep scrollback — `capture-pane -S -` replays this on reconnect, and an agent
1726 # session produces a lot of output before anyone looks at it.
1827 set -g history-limit 50000
⋯ 3 unchanged lines
2231 set -g default-terminal "tmux-256color"
2332 set -ga terminal-overrides ",*256col*:Tc"
2433
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+
2542 # Keep the pane after its command exits, so the browser can still read the last
2643 # screen; the entrypoint decides when the container is actually done.
2744 set -g remain-on-exit on
2845
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+
2955 # Do not renumber or rename out from under the supervisor, which addresses the
3056 # session by name.
3157 set -g allow-rename off
⋯ 6 unchanged lines
modifieddocs/agent-sessions.md+1 −1
⋯ 51 unchanged lines
5252
5353 | Tag | Base | For |
5454 | -------------------- | --------------------- | -------------------------- |
55-| `anvil-runner:latest`| `debian:bookworm-slim`| the default, general work |
55+| `anvil-runner:latest`| `ubuntu:26.04` | the default, general work |
5656 | `anvil-runner:rust` | `rust:1.95-bookworm` | pipelines needing the toolchain |
5757
5858 **There is no registry behind this image.** It is built straight into the
⋯ 91 unchanged lines