anvilsign in

collin/anvil · 9711016c

Run agent sessions in tmux, in the image CI already uses

Collin Richards · 2026-08-18 16:06 UTC · 9711016c694d9ebef20b00b2126660305e91b132 · parent eaa68dd7 · browse files

modifiedCargo.lock+98 −2
⋯ 119 unchanged lines
120120 ]
121121
122122 [[package]]
123+name = "anvil-agent"
124+version = "0.0.0"
125+dependencies = [
126+ "anvil-ci",
127+ "anvil-core",
128+ "anvil-git",
129+ "async-trait",
130+ "bollard",
131+ "futures-util",
132+ "tar",
133+ "tokio",
134+ "tracing",
135+]
136+
137+[[package]]
123138 name = "anvil-ci"
124139 version = "0.0.0"
125140 dependencies = [
⋯ 14 unchanged lines
140155 name = "anvil-cli"
141156 version = "0.0.0"
142157 dependencies = [
158+ "anvil-agent",
143159 "anvil-ci",
144160 "anvil-core",
145161 "anvil-ssh",
⋯ 70 unchanged lines
216232 name = "anvil-web"
217233 version = "0.0.0"
218234 dependencies = [
235+ "anvil-agent",
219236 "anvil-core",
220237 "anvil-git",
221238 "argon2 0.5.3",
222239 "axum",
223240 "axum-extra",
224241 "base64",
242+ "futures-util",
225243 "lru",
226244 "maud",
227245 "pulldown-cmark",
⋯ 91 unchanged lines
319337 checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90"
320338 dependencies = [
321339 "axum-core",
340+ "base64",
322341 "bytes",
323342 "form_urlencoded",
324343 "futures-util",
⋯ 12 unchanged lines
337356 "serde_json",
338357 "serde_path_to_error",
339358 "serde_urlencoded",
359+ "sha1 0.10.6",
340360 "sync_wrapper",
341361 "tokio",
362+ "tokio-tungstenite",
342363 "tower",
343364 "tower-layer",
344365 "tower-service",
⋯ 1020 unchanged lines
13651386
13661387 [[package]]
13671388 name = "getrandom"
1389+version = "0.3.4"
1390+source = "registry+https://github.com/rust-lang/crates.io-index"
1391+checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
1392+dependencies = [
1393+ "cfg-if",
1394+ "libc",
1395+ "r-efi 5.3.0",
1396+ "wasip2",
1397+]
1398+
1399+[[package]]
1400+name = "getrandom"
13681401 version = "0.4.2"
13691402 source = "registry+https://github.com/rust-lang/crates.io-index"
13701403 checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555"
⋯ 1 unchanged line
13721405 "cfg-if",
13731406 "js-sys",
13741407 "libc",
1375- "r-efi",
1408+ "r-efi 6.0.0",
13761409 "rand_core 0.10.1",
13771410 "wasip2",
13781411 "wasip3",
⋯ 2089 unchanged lines
34683501
34693502 [[package]]
34703503 name = "r-efi"
3504+version = "5.3.0"
3505+source = "registry+https://github.com/rust-lang/crates.io-index"
3506+checksum = "69cdb34c158ceb288df11e18b4bd39de994f6657d83847bdffdbd7f346754b0f"
3507+
3508+[[package]]
3509+name = "r-efi"
34713510 version = "6.0.0"
34723511 source = "registry+https://github.com/rust-lang/crates.io-index"
34733512 checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf"
⋯ 4 unchanged lines
34783517 source = "registry+https://github.com/rust-lang/crates.io-index"
34793518 checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a"
34803519 dependencies = [
3481- "rand_chacha",
3520+ "rand_chacha 0.3.1",
34823521 "rand_core 0.6.4",
34833522 ]
34843523
34853524 [[package]]
34863525 name = "rand"
3526+version = "0.9.5"
3527+source = "registry+https://github.com/rust-lang/crates.io-index"
3528+checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
3529+dependencies = [
3530+ "rand_chacha 0.9.0",
3531+ "rand_core 0.9.5",
3532+]
3533+
3534+[[package]]
3535+name = "rand"
34873536 version = "0.10.1"
34883537 source = "registry+https://github.com/rust-lang/crates.io-index"
34893538 checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207"
⋯ 14 unchanged lines
35043553 ]
35053554
35063555 [[package]]
3556+name = "rand_chacha"
3557+version = "0.9.0"
3558+source = "registry+https://github.com/rust-lang/crates.io-index"
3559+checksum = "d3022b5f1df60f26e1ffddd6c66e8aa15de382ae63b3a0c1bfc0e4d3e3f325cb"
3560+dependencies = [
3561+ "ppv-lite86",
3562+ "rand_core 0.9.5",
3563+]
3564+
3565+[[package]]
35073566 name = "rand_core"
35083567 version = "0.6.4"
35093568 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 4 unchanged lines
35143573
35153574 [[package]]
35163575 name = "rand_core"
3576+version = "0.9.5"
3577+source = "registry+https://github.com/rust-lang/crates.io-index"
3578+checksum = "76afc826de14238e6e8c374ddcc1fa19e374fd8dd986b0d2af0d02377261d83c"
3579+dependencies = [
3580+ "getrandom 0.3.4",
3581+]
3582+
3583+[[package]]
3584+name = "rand_core"
35173585 version = "0.10.1"
35183586 source = "registry+https://github.com/rust-lang/crates.io-index"
35193587 checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
⋯ 1185 unchanged lines
47054773 ]
47064774
47074775 [[package]]
4776+name = "tokio-tungstenite"
4777+version = "0.29.0"
4778+source = "registry+https://github.com/rust-lang/crates.io-index"
4779+checksum = "8f72a05e828585856dacd553fba484c242c46e391fb0e58917c942ee9202915c"
4780+dependencies = [
4781+ "futures-util",
4782+ "log",
4783+ "tokio",
4784+ "tungstenite",
4785+]
4786+
4787+[[package]]
47084788 name = "tokio-util"
47094789 version = "0.7.18"
47104790 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 172 unchanged lines
48834963 checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b"
48844964
48854965 [[package]]
4966+name = "tungstenite"
4967+version = "0.29.0"
4968+source = "registry+https://github.com/rust-lang/crates.io-index"
4969+checksum = "6c01152af293afb9c7c2a57e4b559c5620b421f6d133261c60dd2d0cdb38e6b8"
4970+dependencies = [
4971+ "bytes",
4972+ "data-encoding",
4973+ "http",
4974+ "httparse",
4975+ "log",
4976+ "rand 0.9.5",
4977+ "sha1 0.10.6",
4978+ "thiserror",
4979+]
4980+
4981+[[package]]
48864982 name = "typenum"
48874983 version = "1.20.1"
48884984 source = "registry+https://github.com/rust-lang/crates.io-index"
⋯ 736 unchanged lines
modifiedCargo.toml+2 −1
⋯ 15 unchanged lines
1616 # Internal crates
1717 anvil-core = { path = "crates/anvil-core" }
1818 anvil-ci = { path = "crates/anvil-ci" }
19+anvil-agent = { path = "crates/anvil-agent" }
1920 anvil-git = { path = "crates/anvil-git" }
2021 anvil-web = { path = "crates/anvil-web" }
2122 anvil-ssh = { path = "crates/anvil-ssh" }
⋯ 12 unchanged lines
3435 aes-gcm = "=0.11.0-rc.4"
3536 curve25519-dalek = "=5.0.0-rc.0"
3637 async-trait = "0.1"
37-axum = "0.8"
38+axum = { version = "0.8", features = ["ws"] }
3839 axum-extra = { version = "0.10", features = ["cookie"] }
3940 base64 = "0.22"
4041 bollard = "0.18"
⋯ 63 unchanged lines
modifiedanvil.example.toml+37 −0
⋯ 64 unchanged lines
6565 network = true
6666 # User inside the job container, e.g. "1000:1000". Empty keeps the image default.
6767 run_as = ""
68+# Image for a pipeline that omits `image:`. This is anvil's own runner, shared
69+# with agent sessions and built by deploy/runner/build.sh — it carries tmux,
70+# git, fish and Claude Code. It is a LOCAL image with no registry behind it, so
71+# anvil falls back to the local copy when the pull fails. Always allowed,
72+# whatever allowed_images says.
73+default_image = "anvil-runner:latest"
74+
75+[agent]
76+# Agent sessions: a container per session running tmux plus an agent CLI
77+# against one repository, attachable from a terminal in the browser.
78+#
79+# OFF by default, and deliberately so. CI runs code the pusher wrote; an agent
80+# session runs a model that reads repository content, issue text and TODO items
81+# as instructions, with network access it cannot do without. Prompt injection in
82+# a README is therefore a code-execution path. See docs/untrusted-mode.md before
83+# turning this on, and keep it to repositories you trust.
84+enabled = false
85+# Same image as [ci] default_image above.
86+image = "anvil-runner:latest"
87+# Directory holding the agent CLI's credentials (a ~/.claude for Claude Code).
88+# Its contents are uploaded into each session container as a tar — never bind
89+# mounted, so the container still cannot reach anvil's data directory. Empty
90+# starts sessions unauthenticated.
91+credentials_dir = ""
92+# Session sandbox. Same shape as [ci]: all capabilities dropped, no socket, no
93+# mounts. Memory defaults higher than CI's because Claude Code asks for 4 GB.
94+memory_mb = 4096
95+cpus = 2.0
96+pids_limit = 1024
97+# Reap a session after this long with no viewer attached AND no output. A
98+# session someone is watching is never idle, however quiet the agent is.
99+idle_timeout_secs = 3600
100+# Hard cap regardless of activity.
101+max_lifetime_secs = 86400
102+# How many sessions may run at once across the instance. Each holds a container
103+# open, so this is the real resource bound.
104+max_concurrent = 4
68105
69106 # Artifact caps (MiB, 0 = unlimited): one artifact / one run's total / the
70107 # rolling per-repo budget. Over the repo budget, the oldest commits' artifacts
⋯ 12 unchanged lines
addedcrates/anvil-agent/Cargo.toml+21 −0
1+[package]
2+name = "anvil-agent"
3+version.workspace = true
4+edition.workspace = true
5+license.workspace = true
6+repository.workspace = true
7+rust-version.workspace = true
8+description = "Agent sessions for anvil: a tmux-hosted agent CLI per repository, in a container, attachable from the browser."
9+
10+[dependencies]
11+# The Docker connect/pull plumbing is shared with the CI runner rather than
12+# duplicated — both start containers from the same runner image.
13+anvil-ci.workspace = true
14+anvil-core.workspace = true
15+async-trait.workspace = true
16+anvil-git.workspace = true
17+bollard.workspace = true
18+futures-util.workspace = true
19+tar.workspace = true
20+tokio.workspace = true
21+tracing.workspace = true
addedcrates/anvil-agent/src/container.rs+420 −0
1+//! Docker specifics for an agent session: creating the container, seeding it,
2+//! attaching a terminal to the tmux session inside, and tearing it down.
3+//!
4+//! The containment story is deliberately the CI one (see
5+//! `docs/untrusted-mode.md` §1): all capabilities dropped, `no-new-privileges`,
6+//! pids/memory/cpu caps, **no Docker socket, no bind mounts, no volumes**.
7+//! Everything the container needs — the checkout and the agent's credentials —
8+//! is uploaded as a tar through the Docker API, so nothing on the anvil host is
9+//! ever exposed to it.
10+
11+use anvil_core::config::AgentConfig;
12+use bollard::{
13+ Docker,
14+ container::{
15+ Config,
16+ CreateContainerOptions,
17+ RemoveContainerOptions,
18+ StartContainerOptions,
19+ UploadToContainerOptions,
20+ },
21+ exec::{
22+ CreateExecOptions,
23+ ResizeExecOptions,
24+ StartExecOptions,
25+ StartExecResults,
26+ },
27+ models::HostConfig,
28+};
29+
30+/// Label carrying the session id, so containers can be found again after a
31+/// restart — the registry is in-memory and does not survive one.
32+pub const LABEL_SESSION: &str = "anvil.session";
33+
34+/// Working directory inside the container, matching the CI runner's.
35+pub const WORKDIR: &str = "/workspace";
36+
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";
41+
42+/// That user's uid/gid, needed when building tars so the uploaded files are
43+/// owned by the account that has to write them.
44+const RUN_AS_UID: u64 = 1000;
45+
46+/// Home directory of [`RUN_AS`]; the agent CLI's config lives under it.
47+pub const HOME: &str = "/home/agent";
48+
49+/// tmux session name, matching `session-entrypoint.sh`.
50+pub const TMUX_SESSION: &str = "agent";
51+
52+/// Create (but do not start) a session container.
53+pub async fn create(
54+ docker: &Docker,
55+ cfg: &AgentConfig,
56+ session_id: i64,
57+ env: &[(String, String)],
58+ command: &[String],
59+) -> Result<String, String> {
60+ // The same sandbox CI uses. Limits of 0 mean "unlimited" and omit the cap.
61+ //
62+ // Note there is no `network_mode` opt-out: unlike a CI job, a session is
63+ // useless without network — it has to reach the model API, and from M2 it
64+ // clones and pushes back to anvil.
65+ let host_config = HostConfig {
66+ cap_drop: Some(vec!["ALL".to_string()]),
67+ security_opt: Some(vec!["no-new-privileges:true".to_string()]),
68+ pids_limit: (cfg.pids_limit > 0).then_some(cfg.pids_limit),
69+ memory: (cfg.memory_mb > 0).then(|| cfg.memory_mb * 1024 * 1024),
70+ memory_swap: (cfg.memory_mb > 0).then(|| cfg.memory_mb * 1024 * 1024),
71+ nano_cpus: (cfg.cpus > 0.0).then_some((cfg.cpus * 1e9) as i64),
72+ ..Default::default()
73+ };
74+
75+ let mut cmd = vec!["anvil-session".to_string()];
76+ cmd.extend(command.iter().cloned());
77+
78+ let config = Config {
79+ image: Some(cfg.image.clone()),
80+ cmd: Some(cmd),
81+ env: Some(
82+ env.iter()
83+ .map(|(k, v)| format!("{k}={v}"))
84+ .chain([format!("HOME={HOME}"), "TERM=xterm-256color".to_string()])
85+ .collect(),
86+ ),
87+ working_dir: Some(WORKDIR.to_string()),
88+ user: Some(RUN_AS.to_string()),
89+ labels: Some(
90+ [(LABEL_SESSION.to_string(), session_id.to_string())]
91+ .into_iter()
92+ .collect(),
93+ ),
94+ // A terminal program needs a tty even before anyone attaches, or tmux
95+ // starts with a 80x24 dumb terminal and never recovers.
96+ tty: Some(true),
97+ open_stdin: Some(true),
98+ host_config: Some(host_config),
99+ ..Default::default()
100+ };
101+
102+ let created = docker
103+ .create_container(None::<CreateContainerOptions<String>>, config)
104+ .await
105+ .map_err(|e| format!("create session container: {e}"))?;
106+ Ok(created.id)
107+}
108+
109+/// Upload a tar into the container, rooted at `/`.
110+pub async fn upload(docker: &Docker, id: &str, tar: Vec<u8>) -> Result<(), String> {
111+ docker
112+ .upload_to_container(
113+ id,
114+ Some(UploadToContainerOptions {
115+ path: "/".to_string(),
116+ ..Default::default()
117+ }),
118+ tar.into(),
119+ )
120+ .await
121+ .map_err(|e| format!("upload to session container: {e}"))
122+}
123+
124+/// Build a tar of `(path, contents, executable)` entries, rooted at `prefix`
125+/// (no leading slash) and owned by the session user.
126+pub fn build_tar(prefix: &str, files: &[(String, Vec<u8>, bool)]) -> Vec<u8> {
127+ let mut builder = tar::Builder::new(Vec::new());
128+ for (path, content, executable) in files {
129+ let mut header = tar::Header::new_gnu();
130+ header.set_size(content.len() as u64);
131+ header.set_mode(if *executable { 0o755 } else { 0o644 });
132+ // Ownership matters: the container runs as `agent`, and a checkout it
133+ // cannot write is not a workspace.
134+ header.set_uid(RUN_AS_UID);
135+ header.set_gid(RUN_AS_UID);
136+ header.set_cksum();
137+ let full = format!("{prefix}/{path}");
138+ if builder
139+ .append_data(&mut header, &full, content.as_slice())
140+ .is_err()
141+ {
142+ continue;
143+ }
144+ }
145+ builder.into_inner().unwrap_or_default()
146+}
147+
148+/// Read a host directory into `(relative path, bytes, executable)` entries.
149+///
150+/// Used for the agent CLI's credentials directory, which is uploaded rather
151+/// than bind-mounted so the no-mounts invariant survives.
152+pub fn read_dir_recursive(root: &std::path::Path) -> Result<Vec<(String, Vec<u8>, bool)>, String> {
153+ fn walk(
154+ base: &std::path::Path,
155+ dir: &std::path::Path,
156+ out: &mut Vec<(String, Vec<u8>, bool)>,
157+ ) -> std::io::Result<()> {
158+ for entry in std::fs::read_dir(dir)? {
159+ let entry = entry?;
160+ let path = entry.path();
161+ let meta = entry.metadata()?;
162+ if meta.is_dir() {
163+ walk(base, &path, out)?;
164+ } else if meta.is_file() {
165+ let Ok(rel) = path.strip_prefix(base) else {
166+ continue;
167+ };
168+ let executable = {
169+ #[cfg(unix)]
170+ {
171+ use std::os::unix::fs::PermissionsExt;
172+ meta.permissions().mode() & 0o111 != 0
173+ }
174+ #[cfg(not(unix))]
175+ {
176+ false
177+ }
178+ };
179+ out.push((
180+ rel.to_string_lossy().replace('\\', "/"),
181+ std::fs::read(&path)?,
182+ executable,
183+ ));
184+ }
185+ }
186+ Ok(())
187+ }
188+
189+ let mut out = Vec::new();
190+ walk(root, root, &mut out).map_err(|e| format!("reading {}: {e}", root.display()))?;
191+ Ok(out)
192+}
193+
194+/// Start a created container.
195+pub async fn start(docker: &Docker, id: &str) -> Result<(), String> {
196+ docker
197+ .start_container(id, None::<StartContainerOptions<String>>)
198+ .await
199+ .map_err(|e| format!("start session container: {e}"))
200+}
201+
202+/// A live terminal attached to the container's tmux session.
203+pub struct Terminal {
204+ /// Docker exec id, needed to resize the pty.
205+ pub exec_id: String,
206+ /// Bytes the terminal produces.
207+ pub output: std::pin::Pin<
208+ Box<
209+ dyn futures_util::Stream<
210+ Item = Result<bollard::container::LogOutput, bollard::errors::Error>,
211+ > + Send,
212+ >,
213+ >,
214+ /// Keystrokes go here.
215+ pub input: std::pin::Pin<Box<dyn tokio::io::AsyncWrite + Send>>,
216+}
217+
218+/// Attach a new tmux client to the container's session.
219+///
220+/// Each caller gets its own `docker exec`, which is the point: a dropped
221+/// websocket kills that client only, never the agent, because the agent is a
222+/// process inside tmux rather than a child of the exec.
223+pub async fn attach(docker: &Docker, id: &str, cols: u16, rows: u16) -> Result<Terminal, String> {
224+ let exec = docker
225+ .create_exec(
226+ id,
227+ CreateExecOptions {
228+ attach_stdin: Some(true),
229+ attach_stdout: Some(true),
230+ attach_stderr: Some(true),
231+ tty: Some(true),
232+ user: Some(RUN_AS.to_string()),
233+ env: Some(vec![
234+ "TERM=xterm-256color".to_string(),
235+ format!("HOME={HOME}"),
236+ ]),
237+ cmd: Some(vec![
238+ "tmux".to_string(),
239+ "-f".to_string(),
240+ "/etc/anvil/tmux.conf".to_string(),
241+ "attach-session".to_string(),
242+ "-t".to_string(),
243+ TMUX_SESSION.to_string(),
244+ ]),
245+ ..Default::default()
246+ },
247+ )
248+ .await
249+ .map_err(|e| format!("create attach exec: {e}"))?;
250+
251+ let started = docker
252+ .start_exec(
253+ &exec.id,
254+ Some(StartExecOptions {
255+ detach: false,
256+ tty: true,
257+ ..Default::default()
258+ }),
259+ )
260+ .await
261+ .map_err(|e| format!("start attach exec: {e}"))?;
262+
263+ let StartExecResults::Attached { output, input } = started else {
264+ return Err("attach exec detached unexpectedly".to_string());
265+ };
266+
267+ // Size the pty before the first byte, so the TUI lays out correctly rather
268+ // than redrawing from an 80x24 assumption.
269+ let terminal = Terminal {
270+ exec_id: exec.id,
271+ output,
272+ input,
273+ };
274+ resize(docker, &terminal.exec_id, cols, rows).await;
275+ Ok(terminal)
276+}
277+
278+/// Resize an attached terminal. Best-effort: a resize racing the exec's exit is
279+/// routine and not worth failing an attach over.
280+pub async fn resize(docker: &Docker, exec_id: &str, cols: u16, rows: u16) {
281+ if cols == 0 || rows == 0 {
282+ return;
283+ }
284+ if let Err(e) = docker
285+ .resize_exec(
286+ exec_id,
287+ ResizeExecOptions {
288+ height: rows,
289+ width: cols,
290+ },
291+ )
292+ .await
293+ {
294+ tracing::debug!("resize exec {exec_id}: {e}");
295+ }
296+}
297+
298+/// Run a one-shot command in the container and collect its stdout.
299+///
300+/// This is the `capture-pane` / `send-keys` path: short, non-interactive tmux
301+/// control commands rather than a terminal.
302+pub async fn exec_capture(docker: &Docker, id: &str, cmd: &[&str]) -> Result<Vec<u8>, String> {
303+ use futures_util::StreamExt;
304+
305+ let exec = docker
306+ .create_exec(
307+ id,
308+ CreateExecOptions {
309+ attach_stdout: Some(true),
310+ attach_stderr: Some(true),
311+ tty: Some(false),
312+ user: Some(RUN_AS.to_string()),
313+ env: Some(vec![format!("HOME={HOME}")]),
314+ cmd: Some(cmd.iter().map(|s| s.to_string()).collect()),
315+ ..Default::default()
316+ },
317+ )
318+ .await
319+ .map_err(|e| format!("create exec {cmd:?}: {e}"))?;
320+
321+ let started = docker
322+ .start_exec(&exec.id, None)
323+ .await
324+ .map_err(|e| format!("start exec {cmd:?}: {e}"))?;
325+
326+ let StartExecResults::Attached { mut output, .. } = started else {
327+ return Ok(Vec::new());
328+ };
329+
330+ let mut buf = Vec::new();
331+ while let Some(chunk) = output.next().await {
332+ match chunk {
333+ Ok(log) => buf.extend_from_slice(log.into_bytes().as_ref()),
334+ Err(e) => return Err(format!("exec {cmd:?}: {e}")),
335+ }
336+ }
337+ Ok(buf)
338+}
339+
340+/// The tmux pane's current screen plus scrollback, with escape sequences kept.
341+///
342+/// This is what a reconnecting browser is sent before the live stream, so it
343+/// resumes with the screen it left rather than a blank one.
344+pub async fn capture_pane(docker: &Docker, id: &str) -> Result<Vec<u8>, String> {
345+ exec_capture(
346+ docker,
347+ id,
348+ &[
349+ "tmux",
350+ "capture-pane",
351+ "-p",
352+ "-e",
353+ "-S",
354+ "-",
355+ "-t",
356+ TMUX_SESSION,
357+ ],
358+ )
359+ .await
360+}
361+
362+/// Type a line into the tmux session, as if the user had.
363+///
364+/// This is how an autonomous session is driven: rather than a headless
365+/// `-p` invocation, the same interactive agent gets its prompt typed at it, so
366+/// a human can take over mid-run just by attaching.
367+pub async fn send_keys(docker: &Docker, id: &str, text: &str) -> Result<(), String> {
368+ exec_capture(
369+ docker,
370+ id,
371+ &["tmux", "send-keys", "-t", TMUX_SESSION, text, "Enter"],
372+ )
373+ .await
374+ .map(|_| ())
375+}
376+
377+/// Force-remove the container. Best-effort; a container already gone is a
378+/// success as far as callers are concerned.
379+pub async fn remove(docker: &Docker, id: &str) {
380+ if let Err(e) = docker
381+ .remove_container(
382+ id,
383+ Some(RemoveContainerOptions {
384+ force: true,
385+ ..Default::default()
386+ }),
387+ )
388+ .await
389+ {
390+ tracing::debug!("removing session container {id}: {e}");
391+ }
392+}
393+
394+/// Session containers Docker still knows about, as `(container id, session id)`.
395+///
396+/// Startup uses this to reconcile rows against reality: the registry is
397+/// in-memory, so after a restart every one of these is an orphan.
398+pub async fn list_sessions(docker: &Docker) -> Result<Vec<(String, i64)>, String> {
399+ use bollard::container::ListContainersOptions;
400+
401+ let containers = docker
402+ .list_containers(Some(ListContainersOptions::<String> {
403+ all: true,
404+ filters: [("label".to_string(), vec![LABEL_SESSION.to_string()])]
405+ .into_iter()
406+ .collect(),
407+ ..Default::default()
408+ }))
409+ .await
410+ .map_err(|e| format!("listing session containers: {e}"))?;
411+
412+ Ok(containers
413+ .into_iter()
414+ .filter_map(|c| {
415+ let id = c.id?;
416+ let session_id = c.labels?.get(LABEL_SESSION)?.parse().ok()?;
417+ Some((id, session_id))
418+ })
419+ .collect())
420+}
addedcrates/anvil-agent/src/lib.rs+209 −0
1+//! Agent sessions: a long-lived container per session running tmux plus an
2+//! agent CLI against one repository, attachable from the browser.
3+//!
4+//! Deliberately a sibling of `anvil-ci` rather than part of it. The CI runner
5+//! is a single task processing one job at a time; a session lives for hours and
6+//! must never sit in that queue. Sharing the runner image and the Docker
7+//! plumbing (`anvil_ci::docker`) is the extent of the overlap.
8+//!
9+//! The shape:
10+//!
11+//! ```text
12+//! browser <--ws--> anvil-web <--broadcast--,
13+//! |
14+//! supervisor task --docker exec (tty)--> tmux client
15+//! | |
16+//! '--> transcript on disk the agent
17+//! ```
18+//!
19+//! One supervisor per session owns the container and a broadcast channel.
20+//! Attached browsers are subscribers, and so is the transcript writer — which
21+//! is why output is durable whether or not anyone is watching.
22+
23+pub mod container;
24+mod supervisor;
25+
26+use anvil_core::{
27+ App,
28+ agent::{
29+ self,
30+ status,
31+ },
32+};
33+pub use supervisor::{
34+ attach_stream,
35+ detached,
36+};
37+
38+/// Why a session could not be started.
39+#[derive(Debug)]
40+pub enum StartError {
41+ /// `agent.enabled` is false.
42+ Disabled,
43+ /// `agent.max_concurrent` sessions are already running.
44+ AtCapacity(usize),
45+ /// Anything else, with detail.
46+ Failed(String),
47+}
48+
49+impl std::fmt::Display for StartError {
50+ fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
51+ match self {
52+ Self::Disabled => write!(f, "agent sessions are disabled (set agent.enabled)"),
53+ Self::AtCapacity(n) => {
54+ write!(f, "already running {n} sessions (agent.max_concurrent)")
55+ }
56+ Self::Failed(e) => write!(f, "{e}"),
57+ }
58+ }
59+}
60+
61+/// Start a session against `repo_id` at `base_ref`, returning its id.
62+///
63+/// Creates the row first so a failure part-way through is still visible in the
64+/// UI, then hands off to a supervisor task. Returns as soon as the container is
65+/// up — the caller redirects to the session page and attaches from there.
66+pub async fn start(
67+ app: &App,
68+ repo_id: i64,
69+ user_id: i64,
70+ kind: &str,
71+ base_ref: &str,
72+ prompt: &str,
73+) -> Result<i64, StartError> {
74+ let cfg = &app.config.agent;
75+ if !cfg.enabled {
76+ return Err(StartError::Disabled);
77+ }
78+ let live = app.sessions.len();
79+ if cfg.max_concurrent > 0 && live >= cfg.max_concurrent {
80+ return Err(StartError::AtCapacity(live));
81+ }
82+
83+ let (repo_path, base_commit) = supervisor::resolve_base(app, repo_id, base_ref)
84+ .await
85+ .map_err(StartError::Failed)?;
86+
87+ let session = agent::create(
88+ &app.db,
89+ repo_id,
90+ user_id,
91+ kind,
92+ base_ref,
93+ &base_commit,
94+ prompt,
95+ &cfg.image,
96+ )
97+ .await
98+ .map_err(|e| StartError::Failed(e.to_string()))?;
99+
100+ let id = session.id;
101+ match supervisor::launch(app.clone(), session, repo_path).await {
102+ Ok(()) => Ok(id),
103+ Err(e) => {
104+ let _ = agent::finish(&app.db, id, status::FAILED, 0, &e).await;
105+ Err(StartError::Failed(e))
106+ }
107+ }
108+}
109+
110+/// Ask a session to wind up. Idempotent, and safe for a session this process
111+/// does not have a handle for (it just marks the row).
112+pub async fn stop(app: &App, session_id: i64) {
113+ if let Some(handle) = app.sessions.get(session_id) {
114+ let _ = handle.shutdown.send(true);
115+ return;
116+ }
117+ // No live handle: either it already ended, or it belongs to a previous
118+ // process. Either way the row should not claim to be running.
119+ if let Ok(Some(session)) = agent::get(&app.db, session_id).await
120+ && status::is_live(&session.status)
121+ {
122+ let _ = agent::finish(&app.db, session_id, status::REAPED, 0, "").await;
123+ }
124+}
125+
126+/// Reconcile `agent_sessions` rows against the containers Docker still has.
127+///
128+/// The registry is in-memory, so after a restart this process has a handle for
129+/// nothing: every labelled container is an orphan and every row claiming to be
130+/// live is stale. Both are reaped, which mirrors what `ci::requeue_interrupted`
131+/// does for interrupted runs — except a terminal session cannot be resumed, so
132+/// it ends rather than re-queues.
133+pub async fn reconcile(app: &App) {
134+ let docker = match anvil_ci::docker::connect() {
135+ Ok(docker) => docker,
136+ Err(e) => {
137+ tracing::warn!("agent: skipping reconcile, {e}");
138+ return;
139+ }
140+ };
141+
142+ match container::list_sessions(&docker).await {
143+ Ok(found) => {
144+ for (container_id, session_id) in found {
145+ tracing::info!("agent: reaping orphaned container for session {session_id}");
146+ container::remove(&docker, &container_id).await;
147+ }
148+ }
149+ Err(e) => tracing::warn!("agent: listing session containers: {e}"),
150+ }
151+
152+ match agent::live(&app.db).await {
153+ Ok(stale) => {
154+ for session in stale {
155+ let _ = agent::finish(
156+ &app.db,
157+ session.id,
158+ status::REAPED,
159+ 0,
160+ "anvil restarted while this session was running",
161+ )
162+ .await;
163+ }
164+ }
165+ Err(e) => tracing::error!("agent: reconciling session rows: {e}"),
166+ }
167+}
168+
169+/// Periodic job enforcing the idle and wall-clock caps.
170+///
171+/// A session with an attached viewer is never idle, however quiet the agent is
172+/// — otherwise watching a long think would kill it.
173+pub struct SweepJob;
174+
175+#[async_trait::async_trait]
176+impl anvil_core::periodic::PeriodicJob for SweepJob {
177+ fn name(&self) -> &str {
178+ "agent session sweep"
179+ }
180+
181+ async fn run(&self, app: &App) -> anvil_core::Result<()> {
182+ let cfg = &app.config.agent;
183+ let now = anvil_core::agent::now_secs();
184+
185+ for session in agent::live(&app.db).await? {
186+ let Some(handle) = app.sessions.get(session.id) else {
187+ // Live row, no handle: a leftover this process cannot drive.
188+ let _ = agent::finish(&app.db, session.id, status::REAPED, 0, "").await;
189+ continue;
190+ };
191+
192+ let lifetime = now - session.started_at.max(session.created_at);
193+ if cfg.max_lifetime_secs > 0 && lifetime as u64 > cfg.max_lifetime_secs {
194+ tracing::info!("agent: session {} hit the lifetime cap", session.id);
195+ let _ = handle.shutdown.send(true);
196+ continue;
197+ }
198+
199+ if cfg.idle_timeout_secs > 0
200+ && !handle.has_viewers()
201+ && handle.idle_secs() as u64 > cfg.idle_timeout_secs
202+ {
203+ tracing::info!("agent: session {} idle, reaping", session.id);
204+ let _ = handle.shutdown.send(true);
205+ }
206+ }
207+ Ok(())
208+ }
209+}
addedcrates/anvil-agent/src/supervisor.rs+336 −0
1+//! Per-session supervision: bring a container up, keep a durable transcript,
2+//! and wind everything down exactly once.
3+
4+use std::path::PathBuf;
5+
6+use anvil_core::{
7+ App,
8+ agent::{
9+ self,
10+ Handle,
11+ status,
12+ },
13+ models::AgentSession,
14+ repos,
15+ storage,
16+ users,
17+};
18+use futures_util::StreamExt;
19+use tokio::io::AsyncWriteExt;
20+
21+use crate::container;
22+
23+/// Where the entrypoint tees the pane, matching `session-entrypoint.sh`.
24+const TRANSCRIPT_IN_CONTAINER: &str = "/tmp/anvil-transcript";
25+
26+/// Resolve a repository and starting ref to `(bare repo path, commit sha)`.
27+pub async fn resolve_base(
28+ app: &App,
29+ repo_id: i64,
30+ base_ref: &str,
31+) -> Result<(PathBuf, String), String> {
32+ let repo = repos::find_by_id(&app.db, repo_id)
33+ .await
34+ .map_err(|e| e.to_string())?
35+ .ok_or("repository not found")?;
36+ let owner = users::find_by_id(&app.db, repo.owner_id)
37+ .await
38+ .map_err(|e| e.to_string())?
39+ .ok_or("owner not found")?;
40+ let repo_path = storage::repo_path(&app.config.repositories_dir(), &owner.username, &repo.name);
41+
42+ let commit = anvil_git::browse::resolve_commit(&repo_path, base_ref)
43+ .map_err(|e| format!("resolving {base_ref}: {e}"))?;
44+ Ok((repo_path, commit))
45+}
46+
47+/// Create, seed and start the session's container, then hand it to a
48+/// supervisor task.
49+///
50+/// Returns once the container is running; the caller sends the user to the
51+/// session page, which attaches over a websocket.
52+pub async fn launch(app: App, session: AgentSession, repo_path: PathBuf) -> Result<(), String> {
53+ let cfg = &app.config.agent;
54+ let docker = anvil_ci::docker::connect()?;
55+ anvil_ci::docker::ensure_image(&docker, &cfg.image).await?;
56+
57+ // The agent CLI, run interactively under tmux. `--dangerously-skip-
58+ // permissions` is defensible precisely because the container *is* the
59+ // sandbox: all capabilities dropped, no socket, no mounts, nothing of
60+ // anvil's on the filesystem. A permission prompt inside a container the
61+ // agent already fully owns buys nothing.
62+ let command = vec![
63+ "claude".to_string(),
64+ "--dangerously-skip-permissions".to_string(),
65+ ];
66+
67+ let container_id = container::create(&docker, cfg, session.id, &[], &command).await?;
68+
69+ // Seed the workspace. M1 uploads the commit's files, exactly as CI does —
70+ // no `.git`, so no credential is needed anywhere yet.
71+ let files = anvil_git::browse::read_tree_files(&repo_path, &session.base_commit)
72+ .map_err(|e| format!("reading tree {}: {e}", session.base_commit))?;
73+ let entries: Vec<_> = files
74+ .into_iter()
75+ .map(|f| (f.path, f.content, f.executable))
76+ .collect();
77+ let workspace_tar = container::build_tar(container::WORKDIR.trim_start_matches('/'), &entries);
78+ if let Err(e) = container::upload(&docker, &container_id, workspace_tar).await {
79+ container::remove(&docker, &container_id).await;
80+ return Err(e);
81+ }
82+
83+ // Seed the agent's credentials the same way — uploaded, never mounted, so
84+ // the no-bind-mounts invariant in docs/untrusted-mode.md still holds.
85+ if let Err(e) = seed_credentials(&docker, &container_id, cfg).await {
86+ container::remove(&docker, &container_id).await;
87+ return Err(e);
88+ }
89+
90+ if let Err(e) = container::start(&docker, &container_id).await {
91+ container::remove(&docker, &container_id).await;
92+ return Err(e);
93+ }
94+
95+ let (shutdown, shutdown_rx) = tokio::sync::watch::channel(false);
96+ let handle = Handle {
97+ container_id: container_id.clone(),
98+ shutdown,
99+ last_activity: std::sync::Arc::new(std::sync::atomic::AtomicI64::new(agent::now_secs())),
100+ attached: std::sync::Arc::new(std::sync::atomic::AtomicUsize::new(0)),
101+ };
102+ app.sessions.insert(session.id, handle);
103+
104+ agent::mark_running(&app.db, session.id, &container_id)
105+ .await
106+ .map_err(|e| e.to_string())?;
107+
108+ let prompt = session.prompt.clone();
109+ tokio::spawn(supervise(
110+ app,
111+ session.id,
112+ container_id,
113+ shutdown_rx,
114+ prompt,
115+ ));
116+ Ok(())
117+}
118+
119+/// Upload the configured credentials directory to the session user's home.
120+///
121+/// Empty config means sessions start unauthenticated, which is a legitimate
122+/// choice (and the default) rather than an error.
123+async fn seed_credentials(
124+ docker: &bollard::Docker,
125+ container_id: &str,
126+ cfg: &anvil_core::config::AgentConfig,
127+) -> Result<(), String> {
128+ if cfg.credentials_dir.as_os_str().is_empty() {
129+ return Ok(());
130+ }
131+ if !cfg.credentials_dir.is_dir() {
132+ return Err(format!(
133+ "agent.credentials_dir {} is not a directory",
134+ cfg.credentials_dir.display()
135+ ));
136+ }
137+ let entries = container::read_dir_recursive(&cfg.credentials_dir)?;
138+ if entries.is_empty() {
139+ return Ok(());
140+ }
141+ let prefix = format!("{}/.claude", container::HOME.trim_start_matches('/'));
142+ let tar = container::build_tar(&prefix, &entries);
143+ container::upload(docker, container_id, tar).await
144+}
145+
146+/// Own one session until it ends: keep the transcript, honour a shutdown
147+/// request, and close the row out exactly once.
148+async fn supervise(
149+ app: App,
150+ session_id: i64,
151+ container_id: String,
152+ mut shutdown: tokio::sync::watch::Receiver<bool>,
153+ prompt: String,
154+) {
155+ let docker = match anvil_ci::docker::connect() {
156+ Ok(docker) => docker,
157+ Err(e) => {
158+ finish(&app, session_id, &container_id, status::FAILED, 0, &e).await;
159+ return;
160+ }
161+ };
162+
163+ // An autonomous session is the *interactive* agent with its prompt typed
164+ // at it, rather than a headless run. That way a human can take over
165+ // mid-flight just by attaching — there is no separate mode to bail out of.
166+ if !prompt.is_empty() {
167+ let docker = docker.clone();
168+ let container_id = container_id.clone();
169+ let prompt = prompt.clone();
170+ tokio::spawn(async move {
171+ // Let the CLI finish drawing before typing into it.
172+ tokio::time::sleep(std::time::Duration::from_secs(5)).await;
173+ if let Err(e) = container::send_keys(&docker, &container_id, &prompt).await {
174+ tracing::warn!("agent: sending prompt to session {session_id}: {e}");
175+ }
176+ });
177+ }
178+
179+ let transcript = tokio::spawn(pump_transcript(
180+ docker.clone(),
181+ app.clone(),
182+ session_id,
183+ container_id.clone(),
184+ ));
185+
186+ // Wait for whichever comes first: the container exiting on its own, or a
187+ // shutdown request from the sweep / an operator.
188+ let mut wait = docker.wait_container(
189+ &container_id,
190+ None::<bollard::container::WaitContainerOptions<String>>,
191+ );
192+
193+ let (final_status, exit_code) = tokio::select! {
194+ result = wait.next() => match result {
195+ Some(Ok(response)) => (status::EXITED, response.status_code),
196+ Some(Err(e)) => {
197+ tracing::warn!("agent: waiting on session {session_id}: {e}");
198+ (status::FAILED, 0)
199+ }
200+ None => (status::EXITED, 0),
201+ },
202+ _ = shutdown.changed() => (status::REAPED, 0),
203+ };
204+
205+ transcript.abort();
206+ finish(&app, session_id, &container_id, final_status, exit_code, "").await;
207+}
208+
209+/// Follow the in-container transcript and append it to the session's file on
210+/// disk, so a finished session can be replayed without the container.
211+///
212+/// Reads the pipe-pane output rather than attaching a tmux client: an extra
213+/// client would take part in tmux's window sizing and shrink everyone else's
214+/// terminal.
215+async fn pump_transcript(docker: bollard::Docker, app: App, session_id: i64, container_id: String) {
216+ let dir = app.config.sessions_dir();
217+ if let Err(e) = tokio::fs::create_dir_all(&dir).await {
218+ tracing::warn!("agent: creating {}: {e}", dir.display());
219+ return;
220+ }
221+ let path = storage::session_transcript_path(&dir, session_id);
222+ let mut file = match tokio::fs::File::create(&path).await {
223+ Ok(file) => file,
224+ Err(e) => {
225+ tracing::warn!("agent: creating {}: {e}", path.display());
226+ return;
227+ }
228+ };
229+
230+ // `tail -F` from the start, so nothing emitted before this task got going
231+ // is lost and a not-yet-created file is not fatal.
232+ let exec = docker
233+ .create_exec(
234+ &container_id,
235+ bollard::exec::CreateExecOptions {
236+ attach_stdout: Some(true),
237+ attach_stderr: Some(false),
238+ tty: Some(false),
239+ user: Some(container::RUN_AS.to_string()),
240+ cmd: Some(vec![
241+ "tail".to_string(),
242+ "-n".to_string(),
243+ "+1".to_string(),
244+ "-F".to_string(),
245+ TRANSCRIPT_IN_CONTAINER.to_string(),
246+ ]),
247+ ..Default::default()
248+ },
249+ )
250+ .await;
251+ let Ok(exec) = exec else {
252+ tracing::warn!("agent: transcript exec for session {session_id} failed");
253+ return;
254+ };
255+
256+ let Ok(bollard::exec::StartExecResults::Attached { mut output, .. }) =
257+ docker.start_exec(&exec.id, None).await
258+ else {
259+ return;
260+ };
261+
262+ let handle = app.sessions.get(session_id);
263+ while let Some(chunk) = output.next().await {
264+ let Ok(log) = chunk else { break };
265+ let bytes = log.into_bytes();
266+ if bytes.is_empty() {
267+ continue;
268+ }
269+ // Output counts as activity: an agent thinking out loud for an hour
270+ // with nobody watching is working, not idle.
271+ if let Some(handle) = &handle {
272+ handle.touch();
273+ }
274+ if let Err(e) = file.write_all(bytes.as_ref()).await {
275+ tracing::warn!("agent: writing transcript for session {session_id}: {e}");
276+ break;
277+ }
278+ let _ = file.flush().await;
279+ }
280+}
281+
282+/// Close a session out: remove the container, drop the handle, stamp the row.
283+async fn finish(
284+ app: &App,
285+ session_id: i64,
286+ container_id: &str,
287+ final_status: &str,
288+ exit_code: i64,
289+ error: &str,
290+) {
291+ if let Ok(docker) = anvil_ci::docker::connect() {
292+ container::remove(&docker, container_id).await;
293+ }
294+ app.sessions.remove(session_id);
295+ if let Err(e) = agent::finish(&app.db, session_id, final_status, exit_code, error).await {
296+ tracing::error!("agent: closing out session {session_id}: {e}");
297+ }
298+ tracing::info!("agent: session {session_id} {final_status}");
299+}
300+
301+/// Attach a browser to a session's tmux, returning the duplex terminal.
302+///
303+/// Bumps the attached count for the idle sweep; the caller must call
304+/// [`detached`] when the websocket closes.
305+pub async fn attach_stream(
306+ app: &App,
307+ session_id: i64,
308+ cols: u16,
309+ rows: u16,
310+) -> Result<(container::Terminal, bollard::Docker), String> {
311+ let handle = app
312+ .sessions
313+ .get(session_id)
314+ .ok_or("session is not running")?;
315+
316+ let docker = anvil_ci::docker::connect()?;
317+ let terminal = container::attach(&docker, &handle.container_id, cols, rows).await?;
318+
319+ handle
320+ .attached
321+ .fetch_add(1, std::sync::atomic::Ordering::Relaxed);
322+ handle.touch();
323+ let _ = agent::touch_attach(&app.db, session_id).await;
324+
325+ Ok((terminal, docker))
326+}
327+
328+/// Note that a browser disconnected.
329+pub fn detached(app: &App, session_id: i64) {
330+ if let Some(handle) = app.sessions.get(session_id) {
331+ handle
332+ .attached
333+ .fetch_sub(1, std::sync::atomic::Ordering::Relaxed);
334+ handle.touch();
335+ }
336+}
addedcrates/anvil-ci/src/docker.rs+65 −0
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+14 −26
⋯ 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+
1719 use std::{
1820 collections::BTreeMap,
1921 io::Read,
⋯ 28 unchanged lines
4850 UploadToContainerOptions,
4951 WaitContainerOptions,
5052 },
51- image::CreateImageOptions,
5253 models::HostConfig,
5354 };
5455 use futures_util::StreamExt;
⋯ 74 unchanged lines
129130 let short = &run.commit[..run.commit.len().min(12)];
130131 let mut log = format!(
131132 "anvil ci · {}/{} · {} @ {short}\nimage: {}\n",
132- owner.username, repo.name, run.ref_name, pipeline.image
133+ owner.username,
134+ repo.name,
135+ run.ref_name,
136+ app.config.ci.resolve_image(&pipeline.image)
133137 );
134138
135139 // Artifacts are collected into a scratch directory next to their final
⋯ 252 unchanged lines
388392 scratch: &Path,
389393 env: &[(String, String)],
390394 ) -> Result<(i64, Vec<Collected>), String> {
391- if !cfg.image_allowed(&pipeline.image) {
395+ // What the pipeline asked for, or the shared runner image when it omitted
396+ // `image:` entirely.
397+ let image = cfg.resolve_image(&pipeline.image);
398+ if !cfg.image_allowed(image) {
392399 return Err(format!(
393- "image {} is not permitted by ci.allowed_images",
394- pipeline.image
400+ "image {image} is not permitted by ci.allowed_images"
395401 ));
396402 }
397- let docker = Docker::connect_with_socket_defaults()
398- .map_err(|e| format!("docker unavailable (is the socket mounted?): {e}"))?;
403+ let docker = docker::connect()?;
404+ docker::ensure_image(&docker, image).await?;
399405
400- // Pull the image (split name:tag so we don't accidentally pull all tags).
401- let (from_image, tag) = match pipeline.image.rsplit_once(':') {
402- Some((name, tag)) if !tag.contains('/') => (name.to_string(), tag.to_string()),
403- _ => (pipeline.image.clone(), "latest".to_string()),
404- };
405- let mut pull = docker.create_image(
406- Some(CreateImageOptions {
407- from_image,
408- tag,
409- ..Default::default()
410- }),
411- None,
412- None,
413- );
414- while let Some(item) = pull.next().await {
415- item.map_err(|e| format!("pull {}: {e}", pipeline.image))?;
416- }
417-
418406 // Build a single `set -e` script from the steps. The steps run in a
419407 // subshell so the meta-extractor trailer still runs (and the original
420408 // exit code is preserved) when a step fails — failure artifacts like test
⋯ 35 unchanged lines
456444 ..Default::default()
457445 };
458446 let config = Config {
459- image: Some(pipeline.image.clone()),
447+ image: Some(image.to_string()),
460448 cmd: Some(vec!["sh".to_string(), "-c".to_string(), script]),
461449 env: (!env.is_empty()).then(|| env.iter().map(|(k, v)| format!("{k}={v}")).collect()),
462450 working_dir: Some(WORKDIR.to_string()),
⋯ 425 unchanged lines
modifiedcrates/anvil-cli/Cargo.toml+1 −0
⋯ 15 unchanged lines
1616 anvil-core.workspace = true
1717 anvil-web.workspace = true
1818 anvil-ssh.workspace = true
19+anvil-agent.workspace = true
1920 anvil-ci.workspace = true
2021 tokio.workspace = true
2122 clap.workspace = true
⋯ 12 unchanged lines
modifiedcrates/anvil-cli/src/main.rs+17 −1
⋯ 163 unchanged lines
164164 app.ci_tx = Some(ci_tx);
165165 tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx));
166166
167+ // Agent sessions outlive the process that started them: the container is
168+ // the daemon's, not ours. Reconcile before anything can create more, so a
169+ // restart never leaves an orphan running or a row claiming to be live.
170+ if app.config.agent.enabled {
171+ anvil_agent::reconcile(&app).await;
172+ }
173+
167174 // Start periodic background jobs (language detection, preview images, disk usage cache).
168- let periodic_jobs = vec![
175+ let mut periodic_jobs = vec![
169176 (
170177 std::time::Duration::from_secs(app.config.periodic.language_detection_interval_secs),
171178 Box::new(anvil_core::periodic::LanguageDetectionJob)
⋯ 15 unchanged lines
187194 as Box<dyn anvil_core::periodic::PeriodicJob>,
188195 ),
189196 ];
197+ if app.config.agent.enabled {
198+ // Enforces the idle and wall-clock caps. A minute is fine: both
199+ // thresholds are measured in hours, and a session with a viewer
200+ // attached is never idle anyway.
201+ periodic_jobs.push((
202+ std::time::Duration::from_secs(60),
203+ Box::new(anvil_agent::SweepJob) as Box<dyn anvil_core::periodic::PeriodicJob>,
204+ ));
205+ }
190206 anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await;
191207
192208 if app.config.ssh.enabled {
⋯ 139 unchanged lines
addedcrates/anvil-core/src/agent.rs+290 −0
1+//! Agent sessions: persistence and lifecycle for a tmux-hosted agent CLI
2+//! running against a repository. Execution (Docker, tmux, the terminal I/O
3+//! pump) lives in `anvil-agent`; this module owns only the rows.
4+
5+use crate::{
6+ error::Result,
7+ models::AgentSession,
8+};
9+
10+/// Current Unix time in seconds, so `anvil-agent` measures idle and lifetime
11+/// against the same clock the rows are stamped with.
12+pub fn now_secs() -> i64 {
13+ crate::now()
14+}
15+
16+/// Status values stored in [`AgentSession::status`].
17+pub mod status {
18+ /// The row exists and the container is being created.
19+ pub const STARTING: &str = "starting";
20+ /// The container is up and the tmux session is live.
21+ pub const RUNNING: &str = "running";
22+ /// The agent finished on its own; see `exit_code`.
23+ pub const EXITED: &str = "exited";
24+ /// The supervisor could not start or keep the session; see `error`.
25+ pub const FAILED: &str = "failed";
26+ /// A timeout, an operator stop, or a server restart took it.
27+ pub const REAPED: &str = "reaped";
28+
29+ /// Whether `status` is one a session can still leave (i.e. it should have
30+ /// a live container behind it).
31+ pub fn is_live(status: &str) -> bool {
32+ status == STARTING || status == RUNNING
33+ }
34+}
35+
36+/// Session kinds stored in [`AgentSession::kind`].
37+pub mod kind {
38+ /// A human drives the terminal.
39+ pub const INTERACTIVE: &str = "interactive";
40+ /// Started with a prompt and left to run.
41+ pub const AUTONOMOUS: &str = "autonomous";
42+}
43+
44+/// Create a session row in [`status::STARTING`]. The container does not exist
45+/// yet — the supervisor fills in `container_id` once it does.
46+#[allow(clippy::too_many_arguments)]
47+pub async fn create(
48+ db: &toasty::Db,
49+ repo_id: i64,
50+ user_id: i64,
51+ kind: &str,
52+ base_ref: &str,
53+ base_commit: &str,
54+ prompt: &str,
55+ image: &str,
56+) -> Result<AgentSession> {
57+ let mut conn = db.clone();
58+ let now = crate::now();
59+ let session = toasty::create!(AgentSession {
60+ repo_id: repo_id,
61+ user_id: user_id,
62+ status: status::STARTING,
63+ kind: kind,
64+ base_ref: base_ref,
65+ base_commit: base_commit,
66+ branch: "",
67+ prompt: prompt,
68+ container_id: "",
69+ image: image,
70+ created_at: now,
71+ started_at: 0,
72+ finished_at: 0,
73+ last_attach_at: 0,
74+ exit_code: 0,
75+ error: "",
76+ })
77+ .exec(&mut conn)
78+ .await?;
79+ Ok(session)
80+}
81+
82+/// One session by id.
83+pub async fn get(db: &toasty::Db, id: i64) -> Result<Option<AgentSession>> {
84+ let mut conn = db.clone();
85+ let session = AgentSession::filter(AgentSession::fields().id().eq(id))
86+ .first()
87+ .exec(&mut conn)
88+ .await?;
89+ Ok(session)
90+}
91+
92+/// A repository's sessions, newest first, capped at `limit`.
93+pub async fn list_by_repo(
94+ db: &toasty::Db,
95+ repo_id: i64,
96+ limit: usize,
97+) -> Result<Vec<AgentSession>> {
98+ let mut conn = db.clone();
99+ let sessions = AgentSession::filter(AgentSession::fields().repo_id().eq(repo_id))
100+ .order_by(AgentSession::fields().id().desc())
101+ .exec(&mut conn)
102+ .await?;
103+ Ok(sessions.into_iter().take(limit).collect())
104+}
105+
106+/// Every session that believes it still has a container — used by the sweep
107+/// and by the startup reconcile.
108+pub async fn live(db: &toasty::Db) -> Result<Vec<AgentSession>> {
109+ let mut conn = db.clone();
110+ let starting = AgentSession::filter(AgentSession::fields().status().eq(status::STARTING))
111+ .exec(&mut conn)
112+ .await?;
113+ let mut conn = db.clone();
114+ let running = AgentSession::filter(AgentSession::fields().status().eq(status::RUNNING))
115+ .exec(&mut conn)
116+ .await?;
117+ let mut all: Vec<_> = starting.into_iter().chain(running).collect();
118+ all.sort_by_key(|s| s.id);
119+ Ok(all)
120+}
121+
122+/// How many sessions are currently live, for the concurrency cap.
123+pub async fn live_count(db: &toasty::Db) -> Result<usize> {
124+ Ok(live(db).await?.len())
125+}
126+
127+/// Record the container backing a session and move it to
128+/// [`status::RUNNING`].
129+pub async fn mark_running(db: &toasty::Db, id: i64, container_id: &str) -> Result<()> {
130+ let Some(mut session) = get(db, id).await? else {
131+ return Ok(());
132+ };
133+ let now = crate::now();
134+ let mut conn = db.clone();
135+ session
136+ .update()
137+ .status(status::RUNNING)
138+ .container_id(container_id)
139+ .started_at(now)
140+ .last_attach_at(now)
141+ .exec(&mut conn)
142+ .await?;
143+ Ok(())
144+}
145+
146+/// Note that a viewer attached, which is what holds off the idle sweep.
147+pub async fn touch_attach(db: &toasty::Db, id: i64) -> Result<()> {
148+ let Some(mut session) = get(db, id).await? else {
149+ return Ok(());
150+ };
151+ let mut conn = db.clone();
152+ session
153+ .update()
154+ .last_attach_at(crate::now())
155+ .exec(&mut conn)
156+ .await?;
157+ Ok(())
158+}
159+
160+/// Record the branch the agent's work is on, once there is one.
161+pub async fn set_branch(db: &toasty::Db, id: i64, branch: &str) -> Result<()> {
162+ let Some(mut session) = get(db, id).await? else {
163+ return Ok(());
164+ };
165+ let mut conn = db.clone();
166+ session.update().branch(branch).exec(&mut conn).await?;
167+ Ok(())
168+}
169+
170+/// Close a session out with a terminal status. `error` is empty unless the
171+/// status is [`status::FAILED`].
172+pub async fn finish(
173+ db: &toasty::Db,
174+ id: i64,
175+ status_value: &str,
176+ exit_code: i64,
177+ error: &str,
178+) -> Result<()> {
179+ let Some(mut session) = get(db, id).await? else {
180+ return Ok(());
181+ };
182+ let mut conn = db.clone();
183+ session
184+ .update()
185+ .status(status_value)
186+ .exit_code(exit_code)
187+ .error(error)
188+ .finished_at(crate::now())
189+ .exec(&mut conn)
190+ .await?;
191+ Ok(())
192+}
193+
194+// --- the live-session registry ---------------------------------------------
195+
196+/// Handles to the sessions running right now, keyed by session id.
197+///
198+/// Mirrors [`secrets::Vault`](crate::secrets::Vault): in-memory only, lives on
199+/// [`App`](crate::App), and empty after a restart — which is exactly why
200+/// startup reconciles the `agent_sessions` rows against the containers Docker
201+/// still has (see `anvil_agent::reconcile`).
202+///
203+/// Deliberately free of any Docker type so it can live in this crate: the
204+/// registry stores a container *id*, and `anvil-agent` owns everything that
205+/// knows what to do with one.
206+#[derive(Clone, Default)]
207+pub struct Registry {
208+ inner: std::sync::Arc<std::sync::Mutex<std::collections::HashMap<i64, Handle>>>,
209+}
210+
211+/// One live session's shared state.
212+///
213+/// Note what is *not* here: a fan-out channel for terminal output. tmux already
214+/// multiplexes — every attached browser opens its own tmux client, and tmux
215+/// repaints each one — so a broadcast in front of it would duplicate work the
216+/// terminal multiplexer exists to do. The durable transcript comes from
217+/// `tmux pipe-pane` inside the container instead, which keeps recording
218+/// whether or not anyone is attached.
219+#[derive(Clone)]
220+pub struct Handle {
221+ /// Docker container backing the session.
222+ pub container_id: String,
223+ /// Flipped to `true` to ask the supervisor to wind the session up.
224+ pub shutdown: tokio::sync::watch::Sender<bool>,
225+ /// Unix seconds of the last attach or output byte, driving the idle sweep.
226+ pub last_activity: std::sync::Arc<std::sync::atomic::AtomicI64>,
227+ /// How many browsers are attached right now. A session with viewers is
228+ /// never idle, however quiet the agent is.
229+ pub attached: std::sync::Arc<std::sync::atomic::AtomicUsize>,
230+}
231+
232+impl Handle {
233+ /// Note that something happened, holding off the idle sweep.
234+ pub fn touch(&self) {
235+ self.last_activity
236+ .store(crate::now(), std::sync::atomic::Ordering::Relaxed);
237+ }
238+
239+ /// Unix seconds since the last attach or output byte.
240+ pub fn idle_secs(&self) -> i64 {
241+ crate::now()
242+ - self
243+ .last_activity
244+ .load(std::sync::atomic::Ordering::Relaxed)
245+ }
246+
247+ /// Whether a browser is currently attached.
248+ pub fn has_viewers(&self) -> bool {
249+ self.attached.load(std::sync::atomic::Ordering::Relaxed) > 0
250+ }
251+}
252+
253+impl Registry {
254+ /// Register a live session.
255+ pub fn insert(&self, session_id: i64, handle: Handle) {
256+ if let Ok(mut map) = self.inner.lock() {
257+ map.insert(session_id, handle);
258+ }
259+ }
260+
261+ /// The handle for a session, if it is live in *this* process.
262+ pub fn get(&self, session_id: i64) -> Option<Handle> {
263+ self.inner.lock().ok()?.get(&session_id).cloned()
264+ }
265+
266+ /// Drop a session's handle, e.g. once its container is gone.
267+ pub fn remove(&self, session_id: i64) -> Option<Handle> {
268+ self.inner.lock().ok()?.remove(&session_id)
269+ }
270+
271+ /// Every live session id.
272+ pub fn ids(&self) -> Vec<i64> {
273+ self.inner
274+ .lock()
275+ .map(|m| m.keys().copied().collect())
276+ .unwrap_or_default()
277+ }
278+
279+ /// How many sessions are live, for the concurrency cap. Counted from the
280+ /// registry rather than the database because it is the containers, not the
281+ /// rows, that consume the host.
282+ pub fn len(&self) -> usize {
283+ self.inner.lock().map(|m| m.len()).unwrap_or(0)
284+ }
285+
286+ /// Whether no session is live.
287+ pub fn is_empty(&self) -> bool {
288+ self.len() == 0
289+ }
290+}
modifiedcrates/anvil-core/src/ci.rs+6 −1
⋯ 29 unchanged lines
3030 /// artifacts to collect afterwards.
3131 #[derive(Clone, Debug, Deserialize)]
3232 pub struct Pipeline {
33- /// Docker image the steps run in, e.g. `rust:1.95-bookworm`.
33+ /// Docker image the steps run in, e.g. `rust:1.95-bookworm`. Optional:
34+ /// omitting it selects `ci.default_image`, the shared anvil runner that
35+ /// agent sessions also use. Resolve it with
36+ /// [`CiConfig::resolve_image`](crate::config::CiConfig::resolve_image)
37+ /// rather than reading this field directly — it is empty when omitted.
38+ #[serde(default)]
3439 pub image: String,
3540 #[serde(default)]
3641 pub steps: Vec<Step>,
⋯ 471 unchanged lines
modifiedcrates/anvil-core/src/config.rs+137 −2
⋯ 28 unchanged lines
2929 pub ssh: SshConfig,
3030 /// Continuous-deployment settings (the single-repo redeploy webhook).
3131 pub ci: CiConfig,
32+ /// Agent sessions (tmux + an agent CLI in a container, attachable from the
33+ /// browser). Off unless `agent.enabled` is set.
34+ pub agent: AgentConfig,
3235 /// Periodic background job settings.
3336 pub periodic: PeriodicConfig,
3437 /// Single sign-on against an OpenID Connect provider.
⋯ 36 unchanged lines
7174 pub sso_logout: bool,
7275 }
7376
77+/// The image both CI jobs and agent sessions default to: anvil's own runner,
78+/// built by `deploy/runner/build.sh` from `deploy/runner/Dockerfile`. It lives
79+/// only in the host's local Docker image store — there is no registry to pull
80+/// it from, so anything that starts a container from it must treat a failed
81+/// pull as non-fatal when the image is already present locally.
82+pub const DEFAULT_RUNNER_IMAGE: &str = "anvil-runner:latest";
83+
84+/// Agent sessions: a long-lived container per session running tmux plus an
85+/// agent CLI, attachable from the browser (see `docs/agent-sessions.md`).
86+///
87+/// Deliberately separate from [`CiConfig`] despite sharing the image and the
88+/// sandbox shape: sessions are long-lived and interactive where CI jobs are
89+/// short and headless, so the limits that matter differ (idle timeout and a
90+/// concurrency cap here; a wall-clock job timeout there).
91+#[derive(Clone, Debug, Deserialize, Serialize)]
92+#[serde(default)]
93+pub struct AgentConfig {
94+ /// Whether agent sessions can be started at all. Off by default: a session
95+ /// runs a model that reads repository content as instructions, which is a
96+ /// different exposure from CI running code the pusher wrote. See
97+ /// `docs/untrusted-mode.md`.
98+ pub enabled: bool,
99+ /// Image sessions run in. Defaults to [`DEFAULT_RUNNER_IMAGE`].
100+ pub image: String,
101+ /// Directory on the anvil host holding the agent CLI's credentials and
102+ /// settings (a `~/.claude` for Claude Code). Its contents are uploaded into
103+ /// each session container as a tar, exactly as the checkout is — no bind
104+ /// mount, so the container still cannot reach anvil's data directory.
105+ /// Empty means sessions start without credentials.
106+ pub credentials_dir: PathBuf,
107+ /// Memory cap per session container, in MiB (swap capped to the same).
108+ /// `0` means unlimited. Defaults to 4096 — Claude Code asks for 4 GB, so
109+ /// CI's 2048 is not enough.
110+ pub memory_mb: i64,
111+ /// CPU cap per session container. `0` means unlimited. Defaults to 2.
112+ pub cpus: f64,
113+ /// Process-count cap inside a session container. tmux plus an agent plus
114+ /// its subprocesses needs more headroom than a CI job. `0` means
115+ /// unlimited. Defaults to 1024.
116+ pub pids_limit: i64,
117+ /// Seconds a session may go without an attached viewer *and* without
118+ /// producing output before it is reaped. `0` disables the idle sweep.
119+ /// Defaults to 3600.
120+ pub idle_timeout_secs: u64,
121+ /// Hard wall-clock cap on a session, in seconds, regardless of activity.
122+ /// `0` disables it. Defaults to 86400 (24 hours).
123+ pub max_lifetime_secs: u64,
124+ /// How many sessions may run at once across the whole instance. Each holds
125+ /// a container open, so this is the real resource bound. Defaults to 4.
126+ pub max_concurrent: usize,
127+}
128+
74129 /// CI configuration: job sandbox limits and the single-repo redeploy webhook.
75130 ///
76131 /// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the
⋯ 19 unchanged lines
96151 /// Images a pipeline may run in. Empty allows any image. An entry without a
97152 /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag
98153 /// (e.g. `rust:1.95-bookworm`) allows exactly that image.
154+ ///
155+ /// [`default_image`](CiConfig::default_image) is always permitted, whatever
156+ /// this says — otherwise an allowlist would break every pipeline that
157+ /// simply omits `image:`.
99158 pub allowed_images: Vec<String>,
159+ /// Image used by a pipeline that omits `image:`. This is the shared anvil
160+ /// runner (`deploy/runner/Dockerfile`) — the same image agent sessions run
161+ /// in, carrying tmux, git, fish and Claude Code. Built locally rather than
162+ /// pulled, which is why [`resolve_image`](CiConfig::resolve_image)'s caller
163+ /// must tolerate a failed pull.
164+ pub default_image: String,
100165 /// Memory cap for a job container, in MiB (swap is capped to the same
101166 /// value). `0` means unlimited. Defaults to 2048.
102167 pub memory_mb: i64,
⋯ 90 unchanged lines
193258 http: HttpConfig::default(),
194259 ssh: SshConfig::default(),
195260 ci: CiConfig::default(),
261+ agent: AgentConfig::default(),
196262 periodic: PeriodicConfig::default(),
197263 oidc: OidcConfig::default(),
198264 }
⋯ 48 unchanged lines
247313 }
248314 }
249315
316+impl Default for AgentConfig {
317+ fn default() -> Self {
318+ Self {
319+ enabled: false,
320+ image: DEFAULT_RUNNER_IMAGE.to_string(),
321+ credentials_dir: PathBuf::new(),
322+ memory_mb: 4096,
323+ cpus: 2.0,
324+ pids_limit: 1024,
325+ idle_timeout_secs: 3600,
326+ max_lifetime_secs: 86400,
327+ max_concurrent: 4,
328+ }
329+ }
330+}
331+
250332 impl Default for CiConfig {
251333 fn default() -> Self {
252334 Self {
⋯ 2 unchanged lines
255337 deploy_secret: String::new(),
256338 deploy_branch: "main".to_string(),
257339 allowed_images: Vec::new(),
340+ default_image: DEFAULT_RUNNER_IMAGE.to_string(),
258341 memory_mb: 2048,
259342 cpus: 2.0,
260343 pids_limit: 512,
⋯ 16 unchanged lines
277360 && self.deploy_branch == branch
278361 }
279362
363+ /// The image a pipeline runs in: what it asked for, or
364+ /// [`default_image`](CiConfig::default_image) when it omitted `image:`.
365+ pub fn resolve_image<'a>(&'a self, requested: &'a str) -> &'a str {
366+ if requested.is_empty() {
367+ &self.default_image
368+ } else {
369+ requested
370+ }
371+ }
372+
280373 /// Whether `image` passes [`allowed_images`](CiConfig::allowed_images).
281374 /// An empty allowlist permits any image; a tagless entry permits every tag
282- /// of that image; a tagged entry permits exactly itself.
375+ /// of that image; a tagged entry permits exactly itself. The default image
376+ /// is always permitted.
283377 pub fn image_allowed(&self, image: &str) -> bool {
284- self.allowed_images.is_empty()
378+ image == self.default_image
379+ || self.allowed_images.is_empty()
285380 || self.allowed_images.iter().any(|allowed| {
286381 image == allowed
287382 || (!allowed.contains(':')
⋯ 131 unchanged lines
419514 self.data_dir.join("artifacts")
420515 }
421516
517+ /// Root directory under which agent-session transcripts are stored
518+ /// (`sessions/{session_id}.log`). On disk rather than in a column because a
519+ /// terminal transcript grows continuously, and `CiRun.log` — the only
520+ /// precedent — is rewritten whole on every append.
521+ pub fn sessions_dir(&self) -> PathBuf {
522+ self.data_dir.join("sessions")
523+ }
524+
422525 /// Root directory under which uploaded attachments are stored
423526 /// (`attachments/{repo_id}/{hash}`). Kept out of `repositories/` so the
424527 /// files are never git objects.
⋯ 51 unchanged lines
476579 }
477580
478581 #[test]
582+ fn an_omitted_image_resolves_to_the_shared_runner() {
583+ let ci = CiConfig::default();
584+ assert_eq!(ci.resolve_image(""), DEFAULT_RUNNER_IMAGE);
585+ assert_eq!(ci.resolve_image("rust:1.95-bookworm"), "rust:1.95-bookworm");
586+ }
587+
588+ /// An allowlist must not lock out the default image: a pipeline that simply
589+ /// omits `image:` never named anything for the operator to allow, and
590+ /// failing those runs is the regression this whole path risks.
591+ #[test]
592+ fn the_default_image_is_allowed_even_under_an_allowlist() {
593+ let ci = CiConfig {
594+ allowed_images: vec!["alpine:3.20".to_string()],
595+ ..CiConfig::default()
596+ };
597+ assert!(ci.image_allowed(DEFAULT_RUNNER_IMAGE));
598+ assert!(ci.image_allowed("alpine:3.20"));
599+ assert!(!ci.image_allowed("rust:1.95-bookworm"));
600+ }
601+
602+ /// Agent sessions are off unless the operator turns them on — they run a
603+ /// model over repository content, which `docs/untrusted-mode.md` treats as
604+ /// a different exposure from CI.
605+ #[test]
606+ fn agent_sessions_default_to_off_and_share_the_runner_image() {
607+ let agent = AgentConfig::default();
608+ assert!(!agent.enabled);
609+ assert_eq!(agent.image, DEFAULT_RUNNER_IMAGE);
610+ assert_eq!(agent.image, CiConfig::default().default_image);
611+ }
612+
613+ #[test]
479614 fn port_and_host_set_the_bind_address() {
480615 let mut config = Config::default();
481616 config.apply_env(env_of(&[("PORT", "4738")]));
⋯ 69 unchanged lines
modifiedcrates/anvil-core/src/db.rs+26 −1
⋯ 5 unchanged lines
66 error::Result,
77 models::{
88 AdminCache,
9+ AgentSession,
910 ApiToken,
1011 Attachment,
1112 CiArtifact,
⋯ 35 unchanged lines
4748 Attachment,
4849 ApiToken,
4950 AdminCache,
50- RepoSecret
51+ RepoSecret,
52+ AgentSession
5153 ))
5254 .connect(&url)
5355 .await?;
⋯ 26 unchanged lines
8082 ADMIN_CACHE_DDL,
8183 REPO_SECRETS_DDL,
8284 r#"CREATE INDEX IF NOT EXISTS "index_repo_secrets_by_repo_id" ON "repo_secrets" ("repo_id")"#,
85+ AGENT_SESSIONS_DDL,
86+ r#"CREATE INDEX IF NOT EXISTS "index_agent_sessions_by_repo_id" ON "agent_sessions" ("repo_id")"#,
87+ r#"CREATE INDEX IF NOT EXISTS "index_agent_sessions_by_status" ON "agent_sessions" ("status")"#,
8388 ];
8489
8590 const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" (
⋯ 62 unchanged lines
148153 "value" TEXT NOT NULL,
149154 "computed_at" BIGINT NOT NULL )"#;
150155
156+const AGENT_SESSIONS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "agent_sessions" (
157+"id" INTEGER NOT NULL PRIMARY KEY AUTOINCREMENT,
158+"repo_id" BIGINT NOT NULL,
159+"user_id" BIGINT NOT NULL,
160+"status" TEXT NOT NULL,
161+"kind" TEXT NOT NULL,
162+"base_ref" TEXT NOT NULL,
163+"base_commit" TEXT NOT NULL,
164+"branch" TEXT NOT NULL,
165+"prompt" TEXT NOT NULL,
166+"container_id" TEXT NOT NULL,
167+"image" TEXT NOT NULL,
168+"created_at" BIGINT NOT NULL,
169+"started_at" BIGINT NOT NULL,
170+"finished_at" BIGINT NOT NULL,
171+"last_attach_at" BIGINT NOT NULL,
172+"exit_code" BIGINT NOT NULL,
173+"error" TEXT NOT NULL )"#;
174+
151175 /// Columns added to existing tables after deployment, applied as
152176 /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS`
153177 /// for columns, so presence is checked via `pragma_table_info`). The model
⋯ 66 unchanged lines
220244 "api_tokens",
221245 "admin_caches",
222246 "repo_secrets",
247+ "agent_sessions",
223248 ];
224249
225250 /// Every schema object (table + indexes) for `table`, normalized.
⋯ 129 unchanged lines
modifiedcrates/anvil-core/src/lib.rs+7 −0
⋯ 6 unchanged lines
77
88 pub mod access;
99 pub mod admin_cache;
10+pub mod agent;
1011 pub mod api_tokens;
1112 pub mod attachments;
1213 pub mod ci;
⋯ 19 unchanged lines
3233 Result,
3334 };
3435 pub use models::{
36+ AgentSession,
3537 ApiToken,
3638 Attachment,
3739 CiArtifact,
⋯ 29 unchanged lines
6769 /// Plaintext repo secrets for CI, held in memory only and lost on
6870 /// restart — see [`secrets::Vault`].
6971 pub vault: secrets::Vault,
72+ /// Agent sessions live in this process, keyed by session id. Empty after a
73+ /// restart, which is why startup reconciles rows against containers — see
74+ /// [`agent::Registry`].
75+ pub sessions: agent::Registry,
7076 /// Server-wide secret keying CSRF tokens. Persisted in the data dir so
7177 /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap.
7278 csrf_secret: std::sync::Arc<[u8; 32]>,
⋯ 14 unchanged lines
8793 db,
8894 ci_tx: None,
8995 vault: secrets::Vault::default(),
96+ sessions: agent::Registry::default(),
9097 csrf_secret,
9198 })
9299 }
⋯ 52 unchanged lines
modifiedcrates/anvil-core/src/models.rs+49 −0
⋯ 108 unchanged lines
109109 pub created_at: i64,
110110 }
111111
112+/// One agent session: a long-lived container running tmux plus an agent CLI
113+/// against a repository, attachable from the browser.
114+///
115+/// `status` is one of `starting`, `running`, `exited` (the agent finished on
116+/// its own), `failed` (the supervisor could not start or keep it), or `reaped`
117+/// (a timeout, an operator stop, or a server restart took it). The transcript
118+/// is *not* a column — it lives at
119+/// [`storage::session_transcript_path`](crate::storage::session_transcript_path),
120+/// because a terminal stream grows continuously and [`CiRun::log`] is rewritten
121+/// whole on every append.
122+#[derive(Clone, Debug, toasty::Model)]
123+pub struct AgentSession {
124+ #[key]
125+ #[auto]
126+ pub id: i64,
127+ #[index]
128+ pub repo_id: i64,
129+ /// Account that started the session; its access decides who may attach.
130+ pub user_id: i64,
131+ #[index]
132+ pub status: String,
133+ /// `interactive` (a human drives it) or `autonomous` (started with a
134+ /// prompt and left to run).
135+ pub kind: String,
136+ /// Branch name the session was started from, e.g. `main`.
137+ pub base_ref: String,
138+ /// Full commit SHA the workspace was seeded at.
139+ pub base_commit: String,
140+ /// Branch the agent's work lands on (`agent/{id}`). Empty until the
141+ /// session has something to push.
142+ pub branch: String,
143+ /// Opening prompt for an autonomous session; empty for an interactive one.
144+ pub prompt: String,
145+ /// Docker container id, empty before it is created.
146+ pub container_id: String,
147+ /// Image the container was created from, recorded so a session's
148+ /// provenance survives a config change.
149+ pub image: String,
150+ pub created_at: i64,
151+ pub started_at: i64,
152+ pub finished_at: i64,
153+ /// Last time a viewer was attached, driving the idle sweep.
154+ pub last_attach_at: i64,
155+ /// Container exit code once it stops; 0 until then.
156+ pub exit_code: i64,
157+ /// Supervisor-facing failure detail, empty when there is none.
158+ pub error: String,
159+}
160+
112161 /// An issue on a repository. `number` is the user-facing per-repo sequence
113162 /// (`#1`, `#2`, …); `id` stays the global key. `state` is `open` or `closed`.
114163 ///
⋯ 148 unchanged lines
modifiedcrates/anvil-core/src/storage.rs+8 −0
⋯ 32 unchanged lines
3333 attachments_dir.join(repo_id.to_string()).join(hash)
3434 }
3535
36+/// On-disk path of an agent session's terminal transcript
37+/// (`<sessions_dir>/<session_id>.log`). Append-only: the supervisor writes the
38+/// same bytes the browser sees, so a finished session can be replayed without
39+/// the container.
40+pub fn session_transcript_path(sessions_dir: &Path, session_id: i64) -> PathBuf {
41+ sessions_dir.join(format!("{session_id}.log"))
42+}
43+
3644 /// Create a new bare repository on disk, returning the opened handle.
3745 ///
3846 /// `HEAD` is pointed at `refs/heads/<default_branch>` so the on-disk repository
⋯ 85 unchanged lines
modifiedcrates/anvil-web/Cargo.toml+2 −0
⋯ 7 unchanged lines
88 description = "HTTP server for anvil: web UI and smart-HTTP git endpoints (axum)."
99
1010 [dependencies]
11+anvil-agent.workspace = true
1112 anvil-core.workspace = true
1213 anvil-git.workspace = true
1314 axum.workspace = true
1415 axum-extra.workspace = true
16+futures-util.workspace = true
1517 tokio.workspace = true
1618 tokio-util.workspace = true
1719 tower-http.workspace = true
⋯ 27 unchanged lines
addedcrates/anvil-web/assets/wterm/core/index.js+2 −0
1+export { WasmBridge } from "./wasm-bridge.js";
2+export { WebSocketTransport } from "./transport.js";
addedcrates/anvil-web/assets/wterm/core/terminal-core.js+1 −0
1+export {};
addedcrates/anvil-web/assets/wterm/core/transport.js+87 −0
1+export class WebSocketTransport {
2+ constructor(options = {}) {
3+ this._ws = null;
4+ this._reconnectTimer = null;
5+ this._reconnectDelay = 1000;
6+ this._closed = false;
7+ this._buffer = [];
8+ this.url = options.url ?? null;
9+ this.reconnect = options.reconnect !== false;
10+ this.maxReconnectDelay = options.maxReconnectDelay ?? 30000;
11+ this.onData = options.onData ?? null;
12+ this.onOpen = options.onOpen ?? null;
13+ this.onClose = options.onClose ?? null;
14+ this.onError = options.onError ?? null;
15+ }
16+ connect(url) {
17+ if (url)
18+ this.url = url;
19+ if (!this.url)
20+ throw new Error("No WebSocket URL provided");
21+ this._closed = false;
22+ this._ws = new WebSocket(this.url);
23+ this._ws.binaryType = "arraybuffer";
24+ this._ws.onopen = () => {
25+ this._reconnectDelay = 1000;
26+ this._flushBuffer();
27+ if (this.onOpen)
28+ this.onOpen();
29+ };
30+ this._ws.onmessage = (event) => {
31+ if (this.onData) {
32+ if (event.data instanceof ArrayBuffer) {
33+ this.onData(new Uint8Array(event.data));
34+ }
35+ else {
36+ this.onData(event.data);
37+ }
38+ }
39+ };
40+ this._ws.onclose = () => {
41+ if (this.onClose)
42+ this.onClose();
43+ if (this.reconnect && !this._closed)
44+ this._scheduleReconnect();
45+ };
46+ this._ws.onerror = (event) => {
47+ if (this.onError)
48+ this.onError(event);
49+ this._ws?.close();
50+ };
51+ }
52+ send(data) {
53+ if (this._ws && this._ws.readyState === WebSocket.OPEN) {
54+ if (typeof data === "string") {
55+ this._ws.send(new TextEncoder().encode(data));
56+ }
57+ else {
58+ this._ws.send(data);
59+ }
60+ }
61+ else {
62+ this._buffer.push(data);
63+ }
64+ }
65+ close() {
66+ this._closed = true;
67+ if (this._reconnectTimer)
68+ clearTimeout(this._reconnectTimer);
69+ if (this._ws)
70+ this._ws.close();
71+ }
72+ get connected() {
73+ return this._ws !== null && this._ws.readyState === WebSocket.OPEN;
74+ }
75+ _flushBuffer() {
76+ const items = this._buffer.splice(0);
77+ for (const item of items) {
78+ this.send(item);
79+ }
80+ }
81+ _scheduleReconnect() {
82+ this._reconnectTimer = setTimeout(() => {
83+ this.connect();
84+ }, this._reconnectDelay);
85+ this._reconnectDelay = Math.min(this._reconnectDelay * 2, this.maxReconnectDelay);
86+ }
87+}
addedcrates/anvil-web/assets/wterm/core/wasm-bridge.js+246 −0
1+import { WASM_BASE64 } from "./wasm-inline.js";
2+function decodeBase64(base64) {
3+ const binary = atob(base64);
4+ const bytes = new Uint8Array(binary.length);
5+ for (let i = 0; i < binary.length; i++)
6+ bytes[i] = binary.charCodeAt(i);
7+ return bytes.buffer;
8+}
9+export class WasmBridge {
10+ get dv() {
11+ if (this._dvBuffer !== this.memory.buffer) {
12+ this._dvBuffer = this.memory.buffer;
13+ this._dv = new DataView(this.memory.buffer);
14+ }
15+ return this._dv;
16+ }
17+ constructor(instance) {
18+ this.gridPtr = 0;
19+ this.dirtyPtr = 0;
20+ this.writeBufferPtr = 0;
21+ this.cellSize = 12;
22+ this.maxCols = 256;
23+ this.encoder = new TextEncoder();
24+ this.decoder = new TextDecoder();
25+ this._dvBuffer = null;
26+ this.linkCache = new Map();
27+ this.exports = instance.exports;
28+ this.memory = this.exports.memory;
29+ }
30+ static async load(url) {
31+ let bytes;
32+ if (url) {
33+ const response = await fetch(url);
34+ if (!response.ok) {
35+ throw new Error(`[wterm] Failed to load WASM from ${url}: ${response.status} ${response.statusText}`);
36+ }
37+ bytes = await response.arrayBuffer();
38+ }
39+ else {
40+ bytes = decodeBase64(WASM_BASE64);
41+ }
42+ const { instance } = await WebAssembly.instantiate(bytes);
43+ return new WasmBridge(instance);
44+ }
45+ init(cols, rows) {
46+ this.exports.init(cols, rows);
47+ this.linkCache.clear();
48+ this._updatePointers();
49+ }
50+ _updatePointers() {
51+ this.gridPtr = this.exports.getGridPtr();
52+ this.dirtyPtr = this.exports.getDirtyPtr();
53+ this.writeBufferPtr = this.exports.getWriteBuffer();
54+ this.cellSize = this.exports.getCellSize();
55+ this.maxCols = this.exports.getMaxCols();
56+ }
57+ writeString(str, afterChunk) {
58+ const encoded = this.encoder.encode(str);
59+ this.writeRaw(encoded, afterChunk);
60+ }
61+ writeRaw(data, afterChunk) {
62+ let offset = 0;
63+ while (offset < data.length) {
64+ const chunk = Math.min(data.length - offset, 8192);
65+ const buf = new Uint8Array(this.memory.buffer, this.writeBufferPtr, 8192);
66+ buf.set(data.subarray(offset, offset + chunk));
67+ this.exports.writeBytes(chunk);
68+ offset += chunk;
69+ afterChunk?.();
70+ }
71+ }
72+ getCell(row, col) {
73+ const offset = this.gridPtr + (row * this.maxCols + col) * this.cellSize;
74+ const dv = this.dv;
75+ const result = {
76+ char: dv.getUint32(offset, true),
77+ fg: dv.getUint16(offset + 4, true),
78+ bg: dv.getUint16(offset + 6, true),
79+ flags: dv.getUint8(offset + 8),
80+ width: dv.getUint8(offset + 9),
81+ };
82+ Object.assign(result, this._readLink(dv.getUint16(offset + 10, true)));
83+ return result;
84+ }
85+ isDirtyRow(row) {
86+ return new Uint8Array(this.memory.buffer, this.dirtyPtr, 256)[row] !== 0;
87+ }
88+ clearDirty() {
89+ this.exports.clearDirty();
90+ }
91+ getCursor() {
92+ return {
93+ row: this.exports.getCursorRow(),
94+ col: this.exports.getCursorCol(),
95+ visible: this.exports.getCursorVisible() !== 0,
96+ };
97+ }
98+ getCols() {
99+ return this.exports.getCols();
100+ }
101+ getRows() {
102+ return this.exports.getRows();
103+ }
104+ cursorKeysApp() {
105+ return this.exports.getCursorKeysApp() !== 0;
106+ }
107+ bracketedPaste() {
108+ return this.exports.getBracketedPaste() !== 0;
109+ }
110+ usingAltScreen() {
111+ return this.exports.getUsingAltScreen() !== 0;
112+ }
113+ mouseTracking() {
114+ const mode = this.exports.getMouseTracking();
115+ return mode === 1000 || mode === 1002 ? mode : 0;
116+ }
117+ mouseSgr() {
118+ return this.exports.getMouseSgr() !== 0;
119+ }
120+ focusEvents() {
121+ return this.exports.getFocusEvents() !== 0;
122+ }
123+ synchronizedOutput() {
124+ return this.exports.getSynchronizedOutput() !== 0;
125+ }
126+ synchronizedOutputGeneration() {
127+ return this.exports.getSynchronizedOutputGeneration();
128+ }
129+ getTitle() {
130+ if (this.exports.getTitleChanged() === 0)
131+ return null;
132+ const ptr = this.exports.getTitlePtr();
133+ const len = this.exports.getTitleLen();
134+ const bytes = new Uint8Array(this.memory.buffer, ptr, len);
135+ return this.decoder.decode(bytes);
136+ }
137+ getResponse() {
138+ const len = this.exports.getResponseLen();
139+ if (len === 0)
140+ return null;
141+ const ptr = this.exports.getResponsePtr();
142+ const bytes = new Uint8Array(this.memory.buffer, ptr, len);
143+ const str = this.decoder.decode(bytes);
144+ this.exports.clearResponse();
145+ return str;
146+ }
147+ getResourceState() {
148+ const capacity = this.exports.getHyperlinkCapacity?.();
149+ const used = this.exports.getHyperlinkCount?.();
150+ const rejected = this.exports.getHyperlinkRejectedCount?.();
151+ if (capacity === undefined ||
152+ used === undefined ||
153+ rejected === undefined) {
154+ return {};
155+ }
156+ return {
157+ hyperlinks: {
158+ capacity,
159+ used,
160+ rejected,
161+ saturated: used >= capacity,
162+ },
163+ };
164+ }
165+ getScrollbackCount() {
166+ return this.exports.getScrollbackCount();
167+ }
168+ getScrollbackDiscardedCount() {
169+ return this.exports.getScrollbackDiscardedCount();
170+ }
171+ getScrollbackCell(offset, col) {
172+ const ptr = this.exports.getScrollbackLine(offset);
173+ const off = ptr + col * this.cellSize;
174+ const dv = this.dv;
175+ const result = {
176+ char: dv.getUint32(off, true),
177+ fg: dv.getUint16(off + 4, true),
178+ bg: dv.getUint16(off + 6, true),
179+ flags: dv.getUint8(off + 8),
180+ width: dv.getUint8(off + 9),
181+ };
182+ Object.assign(result, this._readLink(dv.getUint16(off + 10, true)));
183+ return result;
184+ }
185+ getScrollbackLineLen(offset) {
186+ return this.exports.getScrollbackLineLen(offset);
187+ }
188+ getUnhandledSequences() {
189+ const count = this.exports.getDebugLogCount();
190+ if (count === 0)
191+ return [];
192+ const ptr = this.exports.getDebugLogPtr();
193+ const entrySize = this.exports.getDebugLogEntrySize();
194+ const maxEntries = this.exports.getDebugLogMax();
195+ const total = Math.min(count, maxEntries);
196+ const dv = new DataView(this.memory.buffer);
197+ const entries = [];
198+ const startIdx = count >= maxEntries ? count % maxEntries : 0;
199+ for (let i = 0; i < total; i++) {
200+ const idx = (startIdx + i) % maxEntries;
201+ const off = ptr + idx * entrySize;
202+ const finalByte = dv.getUint8(off);
203+ if (finalByte === 0)
204+ continue;
205+ const privateByte = dv.getUint8(off + 1);
206+ const paramCount = dv.getUint8(off + 2);
207+ const params = [];
208+ for (let p = 0; p < Math.min(paramCount, 4); p++) {
209+ params.push(dv.getUint16(off + 4 + p * 2, true));
210+ }
211+ entries.push({
212+ final: String.fromCharCode(finalByte),
213+ private: privateByte ? String.fromCharCode(privateByte) : "",
214+ paramCount,
215+ params,
216+ });
217+ }
218+ return entries;
219+ }
220+ resize(cols, rows) {
221+ this.exports.resizeTerminal(cols, rows);
222+ this._updatePointers();
223+ }
224+ _readLink(index) {
225+ if (index === 0)
226+ return undefined;
227+ const cached = this.linkCache.get(index);
228+ if (cached)
229+ return cached;
230+ const uriLen = this.exports.getLinkUriLen(index);
231+ if (uriLen === 0)
232+ return undefined;
233+ const uri = this.decoder.decode(new Uint8Array(this.memory.buffer, this.exports.getLinkUriPtr(index), uriLen));
234+ const idLen = this.exports.getLinkIdLen(index);
235+ const linkId = idLen === 0
236+ ? undefined
237+ : this.decoder.decode(new Uint8Array(this.memory.buffer, this.exports.getLinkIdPtr(index), idLen));
238+ const value = {
239+ linkUri: uri,
240+ linkId,
241+ linkKey: linkId ? `e\0${linkId}\0${uri}` : `b\0${index}`,
242+ };
243+ this.linkCache.set(index, value);
244+ return value;
245+ }
246+}
addedcrates/anvil-web/assets/wterm/core/wasm-inline.js+2 −0
1+// Auto-generated — do not edit. Run `node scripts/inline-wasm.js` to regenerate.
2+export const WASM_BASE64 = "AGFzbQEAAAABQAtgAAF/YAAAYAF/AX9gAX8AYAJ/fwBgBH9/f38AYAN/f38AYAJ/fwF/YAV/f39/fwBgBH9/f38Bf2ADf39/AX8DQUAAAAAAAAEAAAICAAAAAAACAgICAAAAAAAAAAAAAAAAAAAAAAEAAAMDBAQFBQUEBgcBCAkGAQEECgYGBAQDAAQEBAUBcAEBAQUDAQBkBgkBfwFBgIDAAAsH/gUsBm1lbW9yeQIACmdldE1heENvbHMAAAtnZXRDZWxsU2l6ZQABDmdldERlYnVnTG9nTWF4AAIUZ2V0RGVidWdMb2dFbnRyeVNpemUAARBnZXREZWJ1Z0xvZ0NvdW50AAMOZ2V0RGVidWdMb2dQdHIABA1jbGVhclJlc3BvbnNlAAUOZ2V0UmVzcG9uc2VMZW4ABg5nZXRSZXNwb25zZVB0cgAHFGdldFNjcm9sbGJhY2tMaW5lTGVuAAgRZ2V0U2Nyb2xsYmFja0xpbmUACRtnZXRTY3JvbGxiYWNrRGlzY2FyZGVkQ291bnQAChJnZXRTY3JvbGxiYWNrQ291bnQACxlnZXRIeXBlcmxpbmtSZWplY3RlZENvdW50AAwRZ2V0SHlwZXJsaW5rQ291bnQADRRnZXRIeXBlcmxpbmtDYXBhY2l0eQAODGdldExpbmtJZExlbgAPDGdldExpbmtJZFB0cgAQDWdldExpbmtVcmlMZW4AEQ1nZXRMaW5rVXJpUHRyABIPZ2V0VGl0bGVDaGFuZ2VkABMLZ2V0VGl0bGVMZW4AFAtnZXRUaXRsZVB0cgAVH2dldFN5bmNocm9uaXplZE91dHB1dEdlbmVyYXRpb24AFhVnZXRTeW5jaHJvbml6ZWRPdXRwdXQAFw5nZXRGb2N1c0V2ZW50cwAYC2dldE1vdXNlU2dyABkQZ2V0TW91c2VUcmFja2luZwAaEWdldFVzaW5nQWx0U2NyZWVuABsRZ2V0QnJhY2tldGVkUGFzdGUAHBBnZXRDdXJzb3JLZXlzQXBwAB0HZ2V0Um93cwAeB2dldENvbHMAHxBnZXRDdXJzb3JWaXNpYmxlACAMZ2V0Q3Vyc29yQ29sACEMZ2V0Q3Vyc29yUm93ACIKY2xlYXJEaXJ0eQAjC2dldERpcnR5UHRyACQKZ2V0R3JpZFB0cgAlCndyaXRlQnl0ZXMAJg5nZXRXcml0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AzsB9rGYgQAMAQtBACADOwH2sZiBAAsgAUECaiEBDAALC+YBAQJ/I4CAgIAAQRBrIgIkgICAgABBACEDIAJBADoACyACIAE6AAkgAiAAOgAIIAJCADcDACACQQAtAKCK8IAAIgE6AAogAUEEIAFBBEkbQQF0IQECQANAIAEgA0YNASACIANqIANB/InwgABqLwEAOwEAIANBAmohAwwACwtBAC0AlMSggQAiA0EMbCIBIAIpAwA3AeaumIEAIAFB7q6YgQBqIAJBCGooAgA2AQBBACADQQFqQR9xOgCUxKCBAEEAQQAoAsyumIEAQQFqIgNBfyADGzYCzK6YgQAgAkEQaiSAgICAAAt4AQJ/AkAgAEH//wNxIgJBAC8B9ofwgABPDQAgAkGAGGxB9IfAgABqIQBBACEDAkADQCADQQAvAfSH8IAATw0BIAAgASkCADcCACAAQQhqIAFBCGooAgA2AgAgAEEMaiEAIANBAWohAwwACwsgAkEBOgD4h/CAAAsLsAIBA38CQCAAQf//A3EiBEEALwH2h/CAAE8NACABQf//A3EiBUEALwH0h/CAACIAIAUgAEkbIQECQCAFIAJB//8DcSIGIAAgBiAASRsiBk8NACABIARBgBhsQfSHwIAAaiIFIAFBDGxqLQAJRSABQf//A3FBAEdxayEBIAJB//8DcSAATw0AAkAgBSAGQQxsai0ACQ0AIAZBAWohBgwBCyAGIAUgBkF/akH//wNxQQxsai0ACUECRmohBgtBACAGQf//A3EiACABQf//A3EiAmsiASABIABLGyEBIARBgBhsIAJBDGxqQfSHwIAAaiEAAkADQCABRQ0BIAAgAykCADcCACAAQQhqIANBCGooAgA2AgAgAEEMaiEAIAFBf2ohAQwACwsgBEEBOgD4h/CAAAsL4QEBBH8CQCACQf//A3FFDQAgAUH//wNxIABB//8DcU0NACABIABrIgQgAkH//wNxIgIgBEH//wNxIgQgAiAESRsiBWtB//8DcSEGQQAhAgNAAkAgBiACRw0AIAUgAGpB//8DcSECA0AgAEH//wNxIAJPDQMgACADEKmAgIAAIABBAWohAAwACwsgASACQX9zaiIHQf//A3EhBAJAQYAYRQ0AIARBgBhsQfSHwIAAaiAHIAVrQf//A3FBgBhsQfSHwIAAakGAGPwKAAALIARB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";
addedcrates/anvil-web/assets/wterm/dom/debug.js+258 −0
1+const FLAG_NAMES = {
2+ 0x01: "bold",
3+ 0x02: "dim",
4+ 0x04: "italic",
5+ 0x08: "underline",
6+ 0x10: "blink",
7+ 0x20: "reverse",
8+ 0x40: "invisible",
9+ 0x80: "strikethrough",
10+};
11+function flagsToNames(flags) {
12+ const names = [];
13+ for (const [bit, name] of Object.entries(FLAG_NAMES)) {
14+ if (flags & Number(bit))
15+ names.push(name);
16+ }
17+ return names;
18+}
19+const ESC = 0x1b;
20+function scanSequences(data) {
21+ const entries = [];
22+ const ts = Date.now();
23+ let i = 0;
24+ let textStart = 0;
25+ const flushText = () => {
26+ if (i > textStart) {
27+ const raw = data.slice(textStart, i);
28+ if (raw.length > 0 && !/^[\x00-\x1f]+$/.test(raw)) {
29+ entries.push({ ts, type: "text", raw: raw.slice(0, 60) });
30+ }
31+ }
32+ };
33+ while (i < data.length) {
34+ if (data.charCodeAt(i) !== ESC) {
35+ i++;
36+ continue;
37+ }
38+ flushText();
39+ const seqStart = i;
40+ i++; // skip ESC
41+ if (i >= data.length)
42+ break;
43+ const next = data[i];
44+ if (next === "[") {
45+ // CSI sequence
46+ i++;
47+ let priv = "";
48+ if (i < data.length &&
49+ (data[i] === "?" || data[i] === ">" || data[i] === "!")) {
50+ priv = data[i];
51+ i++;
52+ }
53+ let paramStr = "";
54+ while (i < data.length &&
55+ ((data.charCodeAt(i) >= 0x30 && data.charCodeAt(i) <= 0x3b) ||
56+ data[i] === ":")) {
57+ paramStr += data[i];
58+ i++;
59+ }
60+ // skip intermediates
61+ while (i < data.length &&
62+ data.charCodeAt(i) >= 0x20 &&
63+ data.charCodeAt(i) <= 0x2f) {
64+ i++;
65+ }
66+ let final = "";
67+ if (i < data.length &&
68+ data.charCodeAt(i) >= 0x40 &&
69+ data.charCodeAt(i) <= 0x7e) {
70+ final = data[i];
71+ i++;
72+ }
73+ const raw = data.slice(seqStart, i);
74+ const params = paramStr
75+ ? paramStr
76+ .split(/[;:]/)
77+ .map(Number)
78+ .filter((n) => !isNaN(n))
79+ : [];
80+ const type = final === "m" ? "sgr" : "csi";
81+ entries.push({
82+ ts,
83+ type,
84+ raw,
85+ params: params.length > 0 ? params : undefined,
86+ private: priv || undefined,
87+ final,
88+ });
89+ }
90+ else if (next === "]") {
91+ // OSC sequence
92+ i++;
93+ while (i < data.length &&
94+ data.charCodeAt(i) !== 0x07 &&
95+ !(data.charCodeAt(i) === ESC &&
96+ i + 1 < data.length &&
97+ data[i + 1] === "\\")) {
98+ i++;
99+ }
100+ if (i < data.length) {
101+ if (data.charCodeAt(i) === 0x07)
102+ i++;
103+ else if (data.charCodeAt(i) === ESC)
104+ i += 2; // ESC backslash
105+ }
106+ const raw = data.slice(seqStart, i);
107+ entries.push({ ts, type: "osc", raw: raw.slice(0, 80) });
108+ }
109+ else if (next >= " " && next <= "~") {
110+ // Simple ESC + char
111+ i++;
112+ entries.push({
113+ ts,
114+ type: "esc",
115+ raw: data.slice(seqStart, i),
116+ final: next,
117+ });
118+ }
119+ else {
120+ i++;
121+ }
122+ textStart = i;
123+ }
124+ flushText();
125+ return entries;
126+}
127+// -- Main debug adapter --
128+const MAX_TRACES = 500;
129+export class DebugAdapter {
130+ constructor() {
131+ this._traces = [];
132+ this._bridge = null;
133+ this._perf = {
134+ frameCount: 0,
135+ totalRenderMs: 0,
136+ avgRenderMs: 0,
137+ maxRenderMs: 0,
138+ lastDirtyRows: 0,
139+ };
140+ }
141+ get traces() {
142+ return this._traces;
143+ }
144+ get perf() {
145+ return this._perf;
146+ }
147+ setBridge(bridge) {
148+ this._bridge = bridge;
149+ }
150+ traceWrite(data) {
151+ const str = typeof data === "string" ? data : new TextDecoder().decode(data);
152+ const entries = scanSequences(str);
153+ for (const entry of entries) {
154+ this._traces.push(entry);
155+ }
156+ if (this._traces.length > MAX_TRACES) {
157+ this._traces = this._traces.slice(-MAX_TRACES);
158+ }
159+ }
160+ recordRender(renderMs, dirtyRows) {
161+ this._perf.frameCount++;
162+ this._perf.totalRenderMs += renderMs;
163+ this._perf.avgRenderMs = this._perf.totalRenderMs / this._perf.frameCount;
164+ if (renderMs > this._perf.maxRenderMs) {
165+ this._perf.maxRenderMs = renderMs;
166+ }
167+ this._perf.lastDirtyRows = dirtyRows;
168+ }
169+ resetPerf() {
170+ this._perf = {
171+ frameCount: 0,
172+ totalRenderMs: 0,
173+ avgRenderMs: 0,
174+ maxRenderMs: 0,
175+ lastDirtyRows: 0,
176+ };
177+ }
178+ // -- Cell inspector --
179+ cell(row, col) {
180+ if (!this._bridge)
181+ return null;
182+ const c = this._bridge.getCell(row, col);
183+ return {
184+ ...c,
185+ charStr: c.chars ?? (c.char >= 32 ? String.fromCodePoint(c.char) : ""),
186+ flagNames: flagsToNames(c.flags),
187+ };
188+ }
189+ row(row) {
190+ if (!this._bridge)
191+ return null;
192+ const cols = this._bridge.getCols();
193+ const cells = [];
194+ for (let c = 0; c < cols; c++) {
195+ cells.push(this.cell(row, c));
196+ }
197+ return cells;
198+ }
199+ grid() {
200+ if (!this._bridge)
201+ return null;
202+ const cursor = this._bridge.getCursor();
203+ return {
204+ rows: this._bridge.getRows(),
205+ cols: this._bridge.getCols(),
206+ cursor,
207+ altScreen: this._bridge.usingAltScreen(),
208+ scrollbackCount: this._bridge.getScrollbackCount(),
209+ };
210+ }
211+ unhandled() {
212+ if (!this._bridge)
213+ return [];
214+ return this._bridge.getUnhandledSequences();
215+ }
216+ // -- Console-friendly dump --
217+ dump(count = 50) {
218+ const entries = this._traces.slice(-count);
219+ console.group(`%cwterm debug — last ${entries.length} traces`, "color: #569cd6; font-weight: bold");
220+ for (const e of entries) {
221+ const badge = e.type === "sgr"
222+ ? "%cSGR"
223+ : e.type === "csi"
224+ ? "%cCSI"
225+ : e.type === "osc"
226+ ? "%cOSC"
227+ : e.type === "esc"
228+ ? "%cESC"
229+ : "%cTXT";
230+ const color = e.type === "sgr"
231+ ? "background:#2d5a27;color:#fff;padding:1px 4px;border-radius:2px"
232+ : e.type === "csi"
233+ ? "background:#1e4a7a;color:#fff;padding:1px 4px;border-radius:2px"
234+ : "background:#555;color:#fff;padding:1px 4px;border-radius:2px";
235+ const detail = [
236+ e.private ? `private=${e.private}` : "",
237+ e.params ? `params=[${e.params}]` : "",
238+ e.final ? `final=${e.final}` : "",
239+ ]
240+ .filter(Boolean)
241+ .join(" ");
242+ console.log(`${badge} ${e.raw.slice(0, 40)}`, color, detail ? ` ${detail}` : "");
243+ }
244+ console.groupEnd();
245+ }
246+ dumpUnhandled() {
247+ const entries = this.unhandled();
248+ if (entries.length === 0) {
249+ console.log("%cwterm debug — no unhandled sequences", "color: #6a9955");
250+ return;
251+ }
252+ console.group(`%cwterm debug — ${entries.length} unhandled sequences`, "color: #d7ba7d; font-weight: bold");
253+ for (const e of entries) {
254+ console.log(` final=${e.final} private=${e.private || "-"} params=[${e.params.slice(0, e.paramCount)}]`);
255+ }
256+ console.groupEnd();
257+ }
258+}
addedcrates/anvil-web/assets/wterm/dom/hyperlink.js+3 −0
1+export function isLinkActivationModifier(event, navigator) {
2+ return navigator.platform.startsWith("Mac") ? event.metaKey : event.ctrlKey;
3+}
addedcrates/anvil-web/assets/wterm/dom/index.js+5 −0
1+export { WTerm } from "./wterm.js";
2+export { Renderer } from "./renderer.js";
3+export { InputHandler } from "./input.js";
4+export { DebugAdapter } from "./debug.js";
5+export * from "@wterm/core";
addedcrates/anvil-web/assets/wterm/dom/input.js+360 −0
1+import { isLinkActivationModifier } from "./hyperlink.js";
2+const NORMAL_KEYS = {
3+ ArrowUp: "\x1b[A",
4+ ArrowDown: "\x1b[B",
5+ ArrowRight: "\x1b[C",
6+ ArrowLeft: "\x1b[D",
7+ Home: "\x1b[H",
8+ End: "\x1b[F",
9+};
10+const APP_KEYS = {
11+ ArrowUp: "\x1bOA",
12+ ArrowDown: "\x1bOB",
13+ ArrowRight: "\x1bOC",
14+ ArrowLeft: "\x1bOD",
15+ Home: "\x1bOH",
16+ End: "\x1bOF",
17+};
18+const FIXED_KEYS = {
19+ Enter: "\r",
20+ Backspace: "\x7f",
21+ Tab: "\t",
22+ Escape: "\x1b",
23+ Insert: "\x1b[2~",
24+ Delete: "\x1b[3~",
25+ PageUp: "\x1b[5~",
26+ PageDown: "\x1b[6~",
27+ F1: "\x1bOP",
28+ F2: "\x1bOQ",
29+ F3: "\x1bOR",
30+ F4: "\x1bOS",
31+ F5: "\x1b[15~",
32+ F6: "\x1b[17~",
33+ F7: "\x1b[18~",
34+ F8: "\x1b[19~",
35+ F9: "\x1b[20~",
36+ F10: "\x1b[21~",
37+ F11: "\x1b[23~",
38+ F12: "\x1b[24~",
39+};
40+export class InputHandler {
41+ constructor(element, onData, getBridge, getCellSize = () => null) {
42+ this.composing = false;
43+ this.mouseButtons = 0;
44+ this.focused = false;
45+ this.element = element;
46+ this.onData = onData;
47+ this.getBridge = getBridge;
48+ this.getCellSize = getCellSize;
49+ this.textarea = document.createElement("textarea");
50+ this.textarea.setAttribute("autocapitalize", "off");
51+ this.textarea.setAttribute("autocomplete", "off");
52+ this.textarea.setAttribute("autocorrect", "off");
53+ this.textarea.setAttribute("spellcheck", "false");
54+ this.textarea.setAttribute("enterkeyhint", "send");
55+ this.textarea.setAttribute("tabindex", "0");
56+ this.textarea.setAttribute("aria-hidden", "true");
57+ const s = this.textarea.style;
58+ s.position = "absolute";
59+ s.left = "-9999px";
60+ s.top = "0";
61+ s.width = "1px";
62+ s.height = "1px";
63+ s.opacity = "0";
64+ s.overflow = "hidden";
65+ s.border = "0";
66+ s.padding = "0";
67+ s.margin = "0";
68+ s.outline = "none";
69+ s.resize = "none";
70+ s.pointerEvents = "none";
71+ s.caretColor = "transparent";
72+ s.color = "transparent";
73+ s.background = "transparent";
74+ element.appendChild(this.textarea);
75+ this._onKeyDown = this.handleKeyDown.bind(this);
76+ this._onPaste = this.handlePaste.bind(this);
77+ this._onCompositionStart = this.handleCompositionStart.bind(this);
78+ this._onCompositionEnd = this.handleCompositionEnd.bind(this);
79+ this._onInput = this.handleInput.bind(this);
80+ this._onFocus = () => {
81+ if (this.focused)
82+ return;
83+ this.focused = true;
84+ this.element.classList.add("focused");
85+ if (this.getBridge()?.focusEvents?.())
86+ this.onData("\x1b[I");
87+ };
88+ this._onBlur = () => {
89+ this.focused = false;
90+ this.element.classList.remove("focused");
91+ this.stopMouseCapture();
92+ if (this.getBridge()?.focusEvents?.())
93+ this.onData("\x1b[O");
94+ };
95+ this._onMouseDown = (event) => this.handleMouse(event, "press");
96+ this._onMouseMove = (event) => {
97+ if (this.mouseButtons !== 0)
98+ this.handleMouse(event, "move");
99+ };
100+ this._onMouseUp = (event) => {
101+ if (this.mouseButtons === 0)
102+ return;
103+ this.handleMouse(event, "release");
104+ this.mouseButtons = event.buttons & 7;
105+ if (this.mouseButtons === 0)
106+ this.stopMouseCapture();
107+ };
108+ this._onWheel = (event) => this.handleMouse(event, "wheel");
109+ this.textarea.addEventListener("keydown", this._onKeyDown);
110+ this.textarea.addEventListener("paste", this._onPaste);
111+ this.textarea.addEventListener("compositionstart", this._onCompositionStart);
112+ this.textarea.addEventListener("compositionend", this._onCompositionEnd);
113+ this.textarea.addEventListener("input", this._onInput);
114+ this.textarea.addEventListener("focus", this._onFocus);
115+ this.textarea.addEventListener("blur", this._onBlur);
116+ this.element.addEventListener("mousedown", this._onMouseDown);
117+ this.element.addEventListener("wheel", this._onWheel, { passive: false });
118+ }
119+ focus() {
120+ this.textarea.focus({ preventScroll: true });
121+ }
122+ destroy() {
123+ this.textarea.removeEventListener("keydown", this._onKeyDown);
124+ this.textarea.removeEventListener("paste", this._onPaste);
125+ this.textarea.removeEventListener("compositionstart", this._onCompositionStart);
126+ this.textarea.removeEventListener("compositionend", this._onCompositionEnd);
127+ this.textarea.removeEventListener("input", this._onInput);
128+ this.textarea.removeEventListener("focus", this._onFocus);
129+ this.textarea.removeEventListener("blur", this._onBlur);
130+ this.element.removeEventListener("mousedown", this._onMouseDown);
131+ this.stopMouseCapture();
132+ this.element.removeEventListener("wheel", this._onWheel);
133+ this.element.classList.remove("focused");
134+ this.textarea.remove();
135+ }
136+ handleKeyDown(e) {
137+ if (this.composing)
138+ return;
139+ if ((e.metaKey || e.ctrlKey) && e.key === "c") {
140+ const sel = window.getSelection();
141+ if (sel && sel.toString().length > 0)
142+ return;
143+ }
144+ if ((e.metaKey || e.ctrlKey) && e.key === "v") {
145+ this.textarea.focus();
146+ return;
147+ }
148+ if (e.metaKey && !e.ctrlKey) {
149+ if (e.key === "Backspace") {
150+ e.preventDefault();
151+ this.onData("\x15");
152+ }
153+ else if (e.key === "a") {
154+ e.preventDefault();
155+ const sel = window.getSelection();
156+ if (sel) {
157+ const range = document.createRange();
158+ range.selectNodeContents(this.element);
159+ sel.removeAllRanges();
160+ sel.addRange(range);
161+ }
162+ }
163+ return;
164+ }
165+ e.preventDefault();
166+ const seq = this.keyToSequence(e);
167+ if (seq)
168+ this.onData(seq);
169+ }
170+ handlePaste(e) {
171+ e.preventDefault();
172+ const text = e.clipboardData?.getData("text");
173+ if (!text)
174+ return;
175+ const bridge = this.getBridge();
176+ if (bridge && bridge.bracketedPaste()) {
177+ // Strip ESC bytes so clipboard payloads cannot inject \x1b[201~ to
178+ // break out of bracketed paste mode and smuggle commands to the PTY.
179+ const safe = text.replace(/\x1b/g, "");
180+ this.onData("\x1b[200~" + safe + "\x1b[201~");
181+ }
182+ else {
183+ this.onData(text);
184+ }
185+ }
186+ handleCompositionStart() {
187+ this.composing = true;
188+ }
189+ handleCompositionEnd(e) {
190+ this.composing = false;
191+ if (e.data)
192+ this.onData(e.data);
193+ this.textarea.value = "";
194+ }
195+ handleInput() {
196+ if (this.composing)
197+ return;
198+ const value = this.textarea.value;
199+ if (value) {
200+ this.onData(value);
201+ this.textarea.value = "";
202+ }
203+ }
204+ handleMouse(event, kind) {
205+ const bridge = this.getBridge();
206+ const tracking = bridge?.mouseTracking?.() ?? 0;
207+ if (!bridge || tracking === 0 || !bridge.mouseSgr?.())
208+ return;
209+ if (kind === "press" &&
210+ isLinkActivationModifier(event, this.element.ownerDocument.defaultView?.navigator ?? navigator) &&
211+ event.target instanceof Element &&
212+ event.target.closest(".term-link")) {
213+ return;
214+ }
215+ if (kind === "press" && (event.shiftKey || event.button > 2))
216+ return;
217+ if (kind === "release" && event.button > 2)
218+ return;
219+ const supportedButtons = event.buttons & 7;
220+ if (kind === "move" && (tracking !== 1002 || supportedButtons === 0)) {
221+ return;
222+ }
223+ const view = this.element.ownerDocument.defaultView;
224+ if (!view)
225+ return;
226+ const viewportRow = this.element.querySelector(".term-row:not(.term-scrollback-row)");
227+ const hostRect = this.element.getBoundingClientRect();
228+ const rowRect = viewportRow?.getBoundingClientRect();
229+ const cellSize = this.getCellSize();
230+ let left;
231+ let top;
232+ let charWidth;
233+ let rowHeight;
234+ if (rowRect && cellSize) {
235+ left = rowRect.left;
236+ top = rowRect.top;
237+ charWidth = cellSize.charWidth;
238+ rowHeight = cellSize.rowHeight;
239+ }
240+ else {
241+ const style = view.getComputedStyle(this.element);
242+ const borderLeft = parseFloat(style.borderLeftWidth) || 0;
243+ const borderRight = parseFloat(style.borderRightWidth) || 0;
244+ const borderTop = parseFloat(style.borderTopWidth) || 0;
245+ const borderBottom = parseFloat(style.borderBottomWidth) || 0;
246+ const paddingLeft = parseFloat(style.paddingLeft) || 0;
247+ const paddingRight = parseFloat(style.paddingRight) || 0;
248+ const paddingTop = parseFloat(style.paddingTop) || 0;
249+ const paddingBottom = parseFloat(style.paddingBottom) || 0;
250+ left = rowRect?.left ?? hostRect.left + borderLeft + paddingLeft;
251+ top = rowRect?.top ?? hostRect.top + borderTop + paddingTop;
252+ charWidth =
253+ (hostRect.width -
254+ borderLeft -
255+ borderRight -
256+ paddingLeft -
257+ paddingRight) /
258+ bridge.getCols();
259+ rowHeight =
260+ (hostRect.height -
261+ borderTop -
262+ borderBottom -
263+ paddingTop -
264+ paddingBottom) /
265+ bridge.getRows();
266+ }
267+ if (charWidth <= 0 || rowHeight <= 0)
268+ return;
269+ if (kind === "press") {
270+ this.textarea.focus({ preventScroll: true });
271+ if (!this.focused)
272+ this._onFocus();
273+ this.mouseButtons =
274+ supportedButtons ||
275+ (event.button === 1 ? 4 : event.button === 2 ? 2 : 1);
276+ view.addEventListener("mousemove", this._onMouseMove);
277+ view.addEventListener("mouseup", this._onMouseUp);
278+ }
279+ const col = Math.max(1, Math.min(bridge.getCols(), Math.floor((event.clientX - left) / charWidth) + 1));
280+ const row = Math.max(1, Math.min(bridge.getRows(), Math.floor((event.clientY - top) / rowHeight) + 1));
281+ const modifiers = (event.shiftKey ? 4 : 0) |
282+ (event.altKey ? 8 : 0) |
283+ (event.ctrlKey ? 16 : 0);
284+ let code;
285+ let final = "M";
286+ if (kind === "wheel") {
287+ const wheel = event;
288+ if (Math.abs(wheel.deltaX) > Math.abs(wheel.deltaY)) {
289+ if (wheel.deltaX === 0)
290+ return;
291+ code = (wheel.deltaX < 0 ? 66 : 67) | modifiers;
292+ }
293+ else {
294+ if (wheel.deltaY === 0)
295+ return;
296+ code = (wheel.deltaY < 0 ? 64 : 65) | modifiers;
297+ }
298+ }
299+ else {
300+ const button = kind === "move"
301+ ? supportedButtons & 4
302+ ? 1
303+ : supportedButtons & 2
304+ ? 2
305+ : 0
306+ : event.button === 1
307+ ? 1
308+ : event.button === 2
309+ ? 2
310+ : 0;
311+ code = button | modifiers | (kind === "move" ? 32 : 0);
312+ if (kind === "release")
313+ final = "m";
314+ }
315+ event.preventDefault();
316+ this.onData(`\x1b[<${code};${col};${row}${final}`);
317+ }
318+ stopMouseCapture() {
319+ this.mouseButtons = 0;
320+ const view = this.element.ownerDocument.defaultView;
321+ view?.removeEventListener("mousemove", this._onMouseMove);
322+ view?.removeEventListener("mouseup", this._onMouseUp);
323+ }
324+ keyToSequence(e) {
325+ if (e.ctrlKey && !e.altKey && !e.metaKey) {
326+ if (e.key.length === 1) {
327+ const code = e.key.toLowerCase().charCodeAt(0);
328+ if (code >= 97 && code <= 122)
329+ return String.fromCharCode(code - 96);
330+ }
331+ if (e.key === "[")
332+ return "\x1b";
333+ if (e.key === "\\")
334+ return "\x1c";
335+ if (e.key === "]")
336+ return "\x1d";
337+ if (e.key === "^")
338+ return "\x1e";
339+ if (e.key === "_")
340+ return "\x1f";
341+ }
342+ if (e.key === "Enter" && e.shiftKey)
343+ return "\x1b[13;2u";
344+ if (e.key === "Tab" && e.shiftKey)
345+ return "\x1b[Z";
346+ const fixed = FIXED_KEYS[e.key];
347+ if (fixed)
348+ return e.altKey ? "\x1b" + fixed : fixed;
349+ const bridge = this.getBridge();
350+ const appMode = bridge && bridge.cursorKeysApp();
351+ const navMap = appMode ? APP_KEYS : NORMAL_KEYS;
352+ const nav = navMap[e.key];
353+ if (nav)
354+ return e.altKey ? "\x1b" + nav : nav;
355+ if (e.key.length === 1 && !e.ctrlKey && !e.metaKey) {
356+ return e.altKey ? "\x1b" + e.key : e.key;
357+ }
358+ return null;
359+ }
360+}
addedcrates/anvil-web/assets/wterm/dom/renderer.js+572 −0
1+const DEFAULT_COLOR = 256;
2+const FLAG_BOLD = 0x01;
3+const FLAG_DIM = 0x02;
4+const FLAG_ITALIC = 0x04;
5+const FLAG_UNDERLINE = 0x08;
6+const FLAG_REVERSE = 0x20;
7+const FLAG_INVISIBLE = 0x40;
8+const FLAG_STRIKETHROUGH = 0x80;
9+const DEFAULT_SCROLLBACK_OVERSCAN_ROWS = 10;
10+function rgbToCSS(packed) {
11+ const r = (packed >> 16) & 0xff;
12+ const g = (packed >> 8) & 0xff;
13+ const b = packed & 0xff;
14+ return `rgb(${r},${g},${b})`;
15+}
16+function colorToCSS(index) {
17+ if (index === DEFAULT_COLOR)
18+ return null;
19+ if (index < 16)
20+ return `var(--term-color-${index})`;
21+ if (index < 232) {
22+ const n = index - 16;
23+ const r = Math.floor(n / 36) * 51;
24+ const g = (Math.floor(n / 6) % 6) * 51;
25+ const b = (n % 6) * 51;
26+ return `rgb(${r},${g},${b})`;
27+ }
28+ const level = (index - 232) * 10 + 8;
29+ return `rgb(${level},${level},${level})`;
30+}
31+function cellFgCSS(fg, fgRgb) {
32+ if (fgRgb !== undefined)
33+ return rgbToCSS(fgRgb);
34+ return colorToCSS(fg);
35+}
36+function cellBgCSS(bg, bgRgb) {
37+ if (bgRgb !== undefined)
38+ return rgbToCSS(bgRgb);
39+ return colorToCSS(bg);
40+}
41+function buildCellStyle(fg, bg, flags, fgRgb, bgRgb) {
42+ let fgIdx = fg, bgIdx = bg, fgR = fgRgb, bgR = bgRgb;
43+ if (flags & FLAG_REVERSE) {
44+ const tmpIdx = fgIdx;
45+ fgIdx = bgIdx;
46+ bgIdx = tmpIdx;
47+ const tmpR = fgR;
48+ fgR = bgR;
49+ bgR = tmpR;
50+ if (fgR === undefined && fgIdx === DEFAULT_COLOR)
51+ fgIdx = 0;
52+ if (bgR === undefined && bgIdx === DEFAULT_COLOR)
53+ bgIdx = 7;
54+ }
55+ const fgCSS = cellFgCSS(fgIdx, fgR);
56+ const bgCSS = cellBgCSS(bgIdx, bgR);
57+ let style = "";
58+ if (fgCSS)
59+ style += `color:${fgCSS};`;
60+ if (bgCSS)
61+ style += `background:${bgCSS};`;
62+ if (flags & FLAG_BOLD)
63+ style += "font-weight:bold;";
64+ if (flags & FLAG_DIM)
65+ style += "opacity:0.5;";
66+ if (flags & FLAG_ITALIC)
67+ style += "font-style:italic;";
68+ const decorations = [];
69+ if (flags & FLAG_UNDERLINE)
70+ decorations.push("underline");
71+ if (flags & FLAG_STRIKETHROUGH)
72+ decorations.push("line-through");
73+ if (decorations.length)
74+ style += `text-decoration:${decorations.join(" ")};`;
75+ if (flags & FLAG_INVISIBLE)
76+ style += "visibility:hidden;";
77+ return style;
78+}
79+function appendRun(parent, text, style) {
80+ const span = document.createElement("span");
81+ if (style)
82+ span.style.cssText = style;
83+ span.textContent = text;
84+ parent.appendChild(span);
85+}
86+function escapeHTML(text) {
87+ return text
88+ .replace(/&/g, "&amp;")
89+ .replace(/</g, "&lt;")
90+ .replace(/>/g, "&gt;")
91+ .replace(/"/g, "&quot;");
92+}
93+function safeLinkHref(uri) {
94+ if (!uri)
95+ return undefined;
96+ try {
97+ const url = new URL(uri);
98+ return url.protocol === "http:" || url.protocol === "https:"
99+ ? url.href
100+ : undefined;
101+ }
102+ catch {
103+ return undefined;
104+ }
105+}
106+function linkIdentity(cell) {
107+ if (!cell.linkUri)
108+ return "";
109+ return cell.linkKey ?? `fallback\0${cell.linkId ?? ""}\0${cell.linkUri}`;
110+}
111+function resolveColors(fg, bg, flags, fgRgb, bgRgb) {
112+ let fgIdx = fg, bgIdx = bg, fgR = fgRgb, bgR = bgRgb;
113+ if (flags & FLAG_REVERSE) {
114+ [fgIdx, bgIdx] = [bgIdx, fgIdx];
115+ [fgR, bgR] = [bgR, fgR];
116+ if (fgR === undefined && fgIdx === DEFAULT_COLOR)
117+ fgIdx = 0;
118+ if (bgR === undefined && bgIdx === DEFAULT_COLOR)
119+ bgIdx = 7;
120+ }
121+ return {
122+ fg: cellFgCSS(fgIdx, fgR) || "var(--term-fg)",
123+ bg: cellBgCSS(bgIdx, bgR) || "var(--term-bg)",
124+ };
125+}
126+// Pixel-snapped vertical gradient stops keyed off `--term-row-height` so that
127+// every cell paints the eighth-block boundary on the same physical pixel —
128+// using raw percentages (e.g. `12.5%`) at the canonical 17px row-height
129+// resolves to 2.125px and the browser rounds it differently across cells,
130+// producing the per-cell jog Claude Code's horizontal-rule (`▔▔▔▔▔`) makes
131+// visible against the row immediately below.
132+const SNAP_1_8 = "round(calc(var(--term-row-height) * 0.125), 1px)";
133+const SNAP_2_8 = "round(calc(var(--term-row-height) * 0.25), 1px)";
134+const SNAP_3_8 = "round(calc(var(--term-row-height) * 0.375), 1px)";
135+const SNAP_4_8 = "round(calc(var(--term-row-height) * 0.5), 1px)";
136+const SNAP_5_8 = "round(calc(var(--term-row-height) * 0.625), 1px)";
137+const SNAP_6_8 = "round(calc(var(--term-row-height) * 0.75), 1px)";
138+const SNAP_7_8 = "round(calc(var(--term-row-height) * 0.875), 1px)";
139+function getBlockBackground(cp, fg, bg) {
140+ switch (cp) {
141+ case 0x2580:
142+ return `linear-gradient(${fg} ${SNAP_4_8},${bg} ${SNAP_4_8})`;
143+ case 0x2581:
144+ return `linear-gradient(${bg} ${SNAP_7_8},${fg} ${SNAP_7_8})`;
145+ case 0x2582:
146+ return `linear-gradient(${bg} ${SNAP_6_8},${fg} ${SNAP_6_8})`;
147+ case 0x2583:
148+ return `linear-gradient(${bg} ${SNAP_5_8},${fg} ${SNAP_5_8})`;
149+ case 0x2584:
150+ return `linear-gradient(${bg} ${SNAP_4_8},${fg} ${SNAP_4_8})`;
151+ case 0x2585:
152+ return `linear-gradient(${bg} ${SNAP_3_8},${fg} ${SNAP_3_8})`;
153+ case 0x2586:
154+ return `linear-gradient(${bg} ${SNAP_2_8},${fg} ${SNAP_2_8})`;
155+ case 0x2587:
156+ return `linear-gradient(${bg} ${SNAP_1_8},${fg} ${SNAP_1_8})`;
157+ case 0x2588:
158+ return fg;
159+ case 0x2589:
160+ return `linear-gradient(to right,${fg} 87.5%,${bg} 87.5%)`;
161+ case 0x258a:
162+ return `linear-gradient(to right,${fg} 75%,${bg} 75%)`;
163+ case 0x258b:
164+ return `linear-gradient(to right,${fg} 62.5%,${bg} 62.5%)`;
165+ case 0x258c:
166+ return `linear-gradient(to right,${fg} 50%,${bg} 50%)`;
167+ case 0x258d:
168+ return `linear-gradient(to right,${fg} 37.5%,${bg} 37.5%)`;
169+ case 0x258e:
170+ return `linear-gradient(to right,${fg} 25%,${bg} 25%)`;
171+ case 0x258f:
172+ return `linear-gradient(to right,${fg} 12.5%,${bg} 12.5%)`;
173+ case 0x2590:
174+ return `linear-gradient(to right,${bg} 50%,${fg} 50%)`;
175+ case 0x2591:
176+ return `color-mix(in srgb,${fg} 25%,${bg})`;
177+ case 0x2592:
178+ return `color-mix(in srgb,${fg} 50%,${bg})`;
179+ case 0x2593:
180+ return `color-mix(in srgb,${fg} 75%,${bg})`;
181+ case 0x2594:
182+ return `linear-gradient(${fg} ${SNAP_1_8},${bg} ${SNAP_1_8})`;
183+ case 0x2595:
184+ return `linear-gradient(to right,${bg} 87.5%,${fg} 87.5%)`;
185+ default: {
186+ const QUADRANTS = {
187+ 0x2596: [false, false, true, false],
188+ 0x2597: [false, false, false, true],
189+ 0x2598: [true, false, false, false],
190+ 0x2599: [true, false, true, true],
191+ 0x259a: [true, false, false, true],
192+ 0x259b: [true, true, true, false],
193+ 0x259c: [true, true, false, true],
194+ 0x259d: [false, true, false, false],
195+ 0x259e: [false, true, true, false],
196+ 0x259f: [false, true, true, true],
197+ };
198+ const q = QUADRANTS[cp];
199+ if (!q)
200+ return fg;
201+ const [tl, tr, bl, br] = q;
202+ if (tl && tr && bl && br)
203+ return fg;
204+ const layers = [];
205+ const POS = ["0 0", "100% 0", "0 100%", "100% 100%"];
206+ q.forEach((filled, i) => {
207+ if (filled)
208+ layers.push(`linear-gradient(${fg},${fg}) ${POS[i]}/50% 50% no-repeat`);
209+ });
210+ layers.push(bg);
211+ return layers.join(",");
212+ }
213+ }
214+}
215+export class Renderer {
216+ constructor(container) {
217+ this.rows = 0;
218+ this.cols = 0;
219+ this.rowEls = [];
220+ this.prevCursorRow = -1;
221+ this.prevCursorCol = -1;
222+ this.prevContainerBg = "";
223+ this.prevRowBg = [];
224+ this._scrollbackRowEls = [];
225+ this._scrollbackStartKey = 0;
226+ this._renderedScrollbackCount = -1;
227+ this._renderedDiscardedCount = -1;
228+ this._scrollbackTopSpacer = null;
229+ this._scrollbackBottomSpacer = null;
230+ this.container = container;
231+ }
232+ setup(cols, rows) {
233+ this.cols = cols;
234+ this.rows = rows;
235+ this.container.innerHTML = "";
236+ this.rowEls = [];
237+ this.prevRowBg = [];
238+ this._scrollbackRowEls = [];
239+ this._scrollbackStartKey = 0;
240+ this._renderedScrollbackCount = -1;
241+ this._renderedDiscardedCount = -1;
242+ const fragment = document.createDocumentFragment();
243+ this._scrollbackTopSpacer = document.createElement("div");
244+ this._scrollbackTopSpacer.className = "term-scrollback-spacer";
245+ fragment.appendChild(this._scrollbackTopSpacer);
246+ this._scrollbackBottomSpacer = document.createElement("div");
247+ this._scrollbackBottomSpacer.className = "term-scrollback-spacer";
248+ fragment.appendChild(this._scrollbackBottomSpacer);
249+ for (let r = 0; r < rows; r++) {
250+ const rowEl = document.createElement("div");
251+ rowEl.className = "term-row";
252+ fragment.appendChild(rowEl);
253+ this.rowEls.push(rowEl);
254+ }
255+ this.container.appendChild(fragment);
256+ this.prevCursorRow = -1;
257+ this.prevCursorCol = -1;
258+ }
259+ _buildRowContent(rowEl, getCell, lineLen, cursorCol, rowIndex) {
260+ let html = "";
261+ let runStyle = "";
262+ let runText = "";
263+ let runCells = [];
264+ let runStart = 0;
265+ let runLinkKey = "";
266+ let runLinkUri;
267+ let outputLinkKey = "";
268+ const appendContent = (content, linkKey, uri) => {
269+ const href = safeLinkHref(uri);
270+ const nextLinkKey = href ? linkKey : "";
271+ if (nextLinkKey !== outputLinkKey) {
272+ if (outputLinkKey)
273+ html += "</a>";
274+ if (nextLinkKey) {
275+ html += `<a class="term-link" href="${escapeHTML(href)}" target="_blank" rel="noopener noreferrer">`;
276+ }
277+ outputLinkKey = nextLinkKey;
278+ }
279+ html += content;
280+ };
281+ const flushRun = (endCol) => {
282+ if (!runText)
283+ return;
284+ const escaped = escapeHTML(runText);
285+ let content = "";
286+ if (cursorCol >= runStart && cursorCol < endCol) {
287+ const offset = cursorCol - runStart;
288+ const before = runCells.slice(0, offset).join("");
289+ const cursorChar = runCells[offset] || " ";
290+ const after = runCells.slice(offset + 1).join("");
291+ if (before) {
292+ content += runStyle
293+ ? `<span style="${runStyle}">${escapeHTML(before)}</span>`
294+ : `<span>${escapeHTML(before)}</span>`;
295+ }
296+ content += runStyle
297+ ? `<span class="term-cursor" style="${runStyle}">${escapeHTML(cursorChar)}</span>`
298+ : `<span class="term-cursor">${escapeHTML(cursorChar)}</span>`;
299+ if (after) {
300+ content += runStyle
301+ ? `<span style="${runStyle}">${escapeHTML(after)}</span>`
302+ : `<span>${escapeHTML(after)}</span>`;
303+ }
304+ }
305+ else {
306+ content += runStyle
307+ ? `<span style="${runStyle}">${escaped}</span>`
308+ : `<span>${escaped}</span>`;
309+ }
310+ appendContent(content, runLinkKey, runLinkUri);
311+ runText = "";
312+ runCells = [];
313+ };
314+ const appendStyledSpan = (className, style, text, linkKey, linkUri) => {
315+ const classAttr = className ? ` class="${className}"` : "";
316+ const styleAttr = style ? ` style="${style}"` : "";
317+ appendContent(`<span${classAttr}${styleAttr}>${escapeHTML(text)}</span>`, linkKey, linkUri);
318+ };
319+ for (let col = 0; col < this.cols; col++) {
320+ const cell = getCell(col);
321+ const inBounds = col < lineLen;
322+ const cp = inBounds ? cell.char : 0;
323+ const width = inBounds ? (cell.width ?? 1) : 1;
324+ const cellLinkKey = inBounds ? linkIdentity(cell) : "";
325+ const cellLinkUri = inBounds ? cell.linkUri : undefined;
326+ if (inBounds && width === 0) {
327+ flushRun(col);
328+ // Skipping is only right when this continues the wide cell to the
329+ // left, which already covers both columns and its cursor. A width-0
330+ // cell with no wide cell before it owns its column, so dropping it
331+ // would shorten the row.
332+ const continuesWide = col > 0 && (getCell(col - 1).width ?? 1) === 2;
333+ if (!continuesWide) {
334+ appendStyledSpan(col === cursorCol ? "term-cursor" : "", "", " ", cellLinkKey, cellLinkUri);
335+ }
336+ runStyle = "";
337+ runLinkKey = "";
338+ runLinkUri = undefined;
339+ runText = "";
340+ runCells = [];
341+ runStart = col + 1;
342+ continue;
343+ }
344+ if (inBounds && width === 2) {
345+ flushRun(col);
346+ // A scrollback row keeps the width it was stored at, so a narrower
347+ // grid can put the last rendered column on a wide lead whose
348+ // continuation is outside the row. Drawing the pair here would spill
349+ // a second column past the row.
350+ if (col + 1 >= this.cols) {
351+ appendStyledSpan(col === cursorCol ? "term-cursor" : "", "", " ", cellLinkKey, cellLinkUri);
352+ runStyle = "";
353+ runLinkKey = "";
354+ runLinkUri = undefined;
355+ runText = "";
356+ runCells = [];
357+ runStart = col + 1;
358+ continue;
359+ }
360+ const ch = cell.chars ?? (cp >= 32 ? String.fromCodePoint(cp) : " ");
361+ const style = buildCellStyle(cell.fg, cell.bg, cell.flags, cell.fgRgb, cell.bgRgb);
362+ const cls = cursorCol >= col && cursorCol < col + 2
363+ ? "term-wide term-cursor"
364+ : "term-wide";
365+ appendStyledSpan(cls, style, ch, cellLinkKey, cellLinkUri);
366+ runStyle = "";
367+ runLinkKey = "";
368+ runLinkUri = undefined;
369+ runText = "";
370+ runCells = [];
371+ runStart = col + 2;
372+ continue;
373+ }
374+ if (inBounds && cp >= 0x2580 && cp <= 0x259f) {
375+ flushRun(col);
376+ const colors = resolveColors(cell.fg, cell.bg, cell.flags, cell.fgRgb, cell.bgRgb);
377+ const cls = col === cursorCol ? "term-block term-cursor" : "term-block";
378+ const bg = getBlockBackground(cp, colors.fg, colors.bg);
379+ const dim = cell.flags & FLAG_DIM ? "opacity:0.5;" : "";
380+ appendContent(`<span class="${cls}" style="background:${bg};${dim}"></span>`, cellLinkKey, cellLinkUri);
381+ runStyle = "";
382+ runLinkKey = "";
383+ runLinkUri = undefined;
384+ runText = "";
385+ runCells = [];
386+ runStart = col + 1;
387+ }
388+ else {
389+ const ch = cell.chars ?? (inBounds && cp >= 32 ? String.fromCodePoint(cp) : " ");
390+ const style = inBounds
391+ ? buildCellStyle(cell.fg, cell.bg, cell.flags, cell.fgRgb, cell.bgRgb)
392+ : "";
393+ if (style !== runStyle || cellLinkKey !== runLinkKey) {
394+ flushRun(col);
395+ runStyle = style;
396+ runLinkKey = cellLinkKey;
397+ runLinkUri = cellLinkUri;
398+ runText = ch;
399+ runCells = [ch];
400+ runStart = col;
401+ }
402+ else {
403+ runText += ch;
404+ runCells.push(ch);
405+ }
406+ }
407+ }
408+ flushRun(this.cols);
409+ if (outputLinkKey)
410+ html += "</a>";
411+ rowEl.innerHTML = html;
412+ let bgCss = "";
413+ if (lineLen >= this.cols && this.cols > 0) {
414+ const lastCell = getCell(this.cols - 1);
415+ let bgIdx = lastCell.bg;
416+ let bgR = lastCell.bgRgb;
417+ if (lastCell.flags & FLAG_REVERSE) {
418+ bgIdx = lastCell.fg;
419+ bgR = lastCell.fgRgb;
420+ if (bgR === undefined && bgIdx === DEFAULT_COLOR)
421+ bgIdx = 7;
422+ }
423+ bgCss = cellBgCSS(bgIdx, bgR) || "";
424+ }
425+ const boxShadow = bgCss ? `0 1px 0 ${bgCss}` : "";
426+ if (rowIndex >= 0) {
427+ if (bgCss !== (this.prevRowBg[rowIndex] ?? "")) {
428+ rowEl.style.background = bgCss;
429+ rowEl.style.boxShadow = boxShadow;
430+ this.prevRowBg[rowIndex] = bgCss;
431+ }
432+ }
433+ else {
434+ rowEl.style.background = bgCss;
435+ rowEl.style.boxShadow = boxShadow;
436+ }
437+ }
438+ _buildScrollbackRowEl(core, sbOffset) {
439+ const rowEl = document.createElement("div");
440+ rowEl.className = "term-row term-scrollback-row";
441+ const lineLen = core.getScrollbackLineLen(sbOffset);
442+ this._buildRowContent(rowEl, (col) => core.getScrollbackCell(sbOffset, col), lineLen, -1, -1);
443+ return rowEl;
444+ }
445+ syncScrollback(core, viewport) {
446+ const scrollbackCount = core.getScrollbackCount();
447+ const rowHeight = viewport?.rowHeight ?? 0;
448+ const virtual = viewport !== undefined && rowHeight > 0;
449+ const overscan = viewport?.overscanRows ?? DEFAULT_SCROLLBACK_OVERSCAN_ROWS;
450+ const hasDiscardedCount = viewport?.scrollbackDiscardedCount !== undefined;
451+ const discardedCount = viewport?.scrollbackDiscardedCount ?? 0;
452+ const viewportHeight = viewport && viewport.clientHeight > 0
453+ ? viewport.clientHeight
454+ : this.rows * rowHeight;
455+ const firstVisible = virtual
456+ ? Math.floor(viewport.scrollTop / rowHeight)
457+ : 0;
458+ const visibleRows = virtual
459+ ? Math.ceil(viewportHeight / rowHeight)
460+ : scrollbackCount;
461+ let start = virtual
462+ ? Math.max(0, Math.min(scrollbackCount, firstVisible - overscan))
463+ : 0;
464+ let end = virtual
465+ ? Math.max(start, Math.min(scrollbackCount, firstVisible + visibleRows + overscan))
466+ : scrollbackCount;
467+ const selection = this.container.ownerDocument.getSelection();
468+ const selectionInContainer = selection !== null &&
469+ !selection.isCollapsed &&
470+ (this.container.contains(selection.anchorNode) ||
471+ this.container.contains(selection.focusNode));
472+ if (selectionInContainer && this._scrollbackRowEls.length > 0) {
473+ start = Math.min(start, Math.max(0, this._scrollbackStartKey - discardedCount));
474+ end = Math.max(end, Math.min(scrollbackCount, this._scrollbackStartKey -
475+ discardedCount +
476+ this._scrollbackRowEls.length));
477+ }
478+ const startKey = discardedCount + start;
479+ if (hasDiscardedCount &&
480+ scrollbackCount === this._renderedScrollbackCount &&
481+ discardedCount === this._renderedDiscardedCount &&
482+ startKey === this._scrollbackStartKey &&
483+ end - start === this._scrollbackRowEls.length) {
484+ return;
485+ }
486+ const previous = new Map();
487+ for (let i = 0; i < this._scrollbackRowEls.length; i++) {
488+ previous.set(this._scrollbackStartKey + i, this._scrollbackRowEls[i]);
489+ }
490+ const endKey = discardedCount + end;
491+ for (const [key, rowEl] of previous) {
492+ if (key < startKey || key >= endKey)
493+ rowEl.remove();
494+ }
495+ const nextRows = [];
496+ let nextSibling = this._scrollbackTopSpacer?.nextSibling ?? null;
497+ for (let index = start; index < end; index++) {
498+ const key = discardedCount + index;
499+ const offset = scrollbackCount - 1 - index;
500+ const candidate = this._buildScrollbackRowEl(core, offset);
501+ const existing = previous.get(key);
502+ let rowEl = candidate;
503+ let positioned = false;
504+ if (existing &&
505+ existing.innerHTML === candidate.innerHTML &&
506+ existing.style.cssText === candidate.style.cssText) {
507+ rowEl = existing;
508+ }
509+ else if (existing) {
510+ existing.replaceWith(candidate);
511+ positioned = true;
512+ }
513+ if (!positioned && rowEl !== nextSibling) {
514+ this.container.insertBefore(rowEl, nextSibling ?? this._scrollbackBottomSpacer);
515+ }
516+ nextSibling = rowEl.nextSibling;
517+ nextRows.push(rowEl);
518+ }
519+ this._scrollbackRowEls = nextRows;
520+ this._scrollbackStartKey = startKey;
521+ this._renderedScrollbackCount = scrollbackCount;
522+ this._renderedDiscardedCount = discardedCount;
523+ if (this._scrollbackTopSpacer) {
524+ this._scrollbackTopSpacer.style.height = `${start * rowHeight}px`;
525+ }
526+ if (this._scrollbackBottomSpacer) {
527+ this._scrollbackBottomSpacer.style.height = `${(scrollbackCount - end) * rowHeight}px`;
528+ }
529+ }
530+ render(core, viewport) {
531+ const rows = core.getRows();
532+ const cols = core.getCols();
533+ let resized = false;
534+ if (rows !== this.rows || cols !== this.cols) {
535+ this.setup(cols, rows);
536+ resized = true;
537+ }
538+ this.syncScrollback(core, viewport);
539+ const cursor = core.getCursor();
540+ const cursorVisible = cursor.visible;
541+ const needsCursorUpdate = cursor.row !== this.prevCursorRow || cursor.col !== this.prevCursorCol;
542+ for (let r = 0; r < this.rows; r++) {
543+ const isDirty = resized || core.isDirtyRow(r);
544+ const hadCursor = r === this.prevCursorRow && needsCursorUpdate;
545+ const hasCursor = r === cursor.row;
546+ if (isDirty || hadCursor || (hasCursor && needsCursorUpdate)) {
547+ const cCol = hasCursor && cursorVisible ? cursor.col : -1;
548+ this._buildRowContent(this.rowEls[r], (col) => core.getCell(r, col), this.cols, cCol, r);
549+ }
550+ }
551+ this.prevCursorRow = cursor.row;
552+ this.prevCursorCol = cursor.col;
553+ const lastRowDirty = resized || core.isDirtyRow(this.rows - 1);
554+ if (lastRowDirty) {
555+ const bottomRight = core.getCell(this.rows - 1, this.cols - 1);
556+ let gridBgIdx = bottomRight.bg;
557+ let gridBgRgb = bottomRight.bgRgb;
558+ if (bottomRight.flags & FLAG_REVERSE) {
559+ gridBgIdx = bottomRight.fg;
560+ gridBgRgb = bottomRight.fgRgb;
561+ if (gridBgRgb === undefined && gridBgIdx === DEFAULT_COLOR)
562+ gridBgIdx = 7;
563+ }
564+ const containerBg = cellBgCSS(gridBgIdx, gridBgRgb) || "";
565+ if (containerBg !== this.prevContainerBg) {
566+ this.container.style.background = containerBg;
567+ this.prevContainerBg = containerBg;
568+ }
569+ }
570+ core.clearDirty();
571+ }
572+}
addedcrates/anvil-web/assets/wterm/dom/wterm.js+451 −0
1+import { WasmBridge } from "@wterm/core";
2+import { Renderer } from "./renderer.js";
3+import { InputHandler } from "./input.js";
4+import { DebugAdapter } from "./debug.js";
5+import { isLinkActivationModifier } from "./hyperlink.js";
6+const SYNCHRONIZED_OUTPUT_TIMEOUT_MS = 1000;
7+export class WTerm {
8+ constructor(element, options = {}) {
9+ this.bridge = null;
10+ this.debug = null;
11+ this.renderer = null;
12+ this.input = null;
13+ this.rafId = null;
14+ this._synchronizedOutputTimer = null;
15+ this._synchronizedOutputState = "idle";
16+ this._synchronizedOutputGeneration = 0;
17+ this._rendererNeedsSetup = false;
18+ this.resizeObserver = null;
19+ this._destroyed = false;
20+ this._shouldScrollToBottom = false;
21+ this._scrollbackDiscardedCount = 0;
22+ this._programmaticScrollTop = null;
23+ this._pendingResizeScrollTop = null;
24+ this._rowHeight = 0;
25+ this._charWidth = 0;
26+ this.element = element;
27+ this._coreOption = options.core;
28+ this.wasmUrl = options.wasmUrl;
29+ this.cols = options.cols || 80;
30+ this.rows = options.rows || 24;
31+ this.autoResize = options.autoResize !== false;
32+ this._debugEnabled = options.debug ?? false;
33+ this.onData = options.onData || null;
34+ this.onTitle = options.onTitle || null;
35+ this.onResize = options.onResize || null;
36+ this._container = document.createElement("div");
37+ this._container.className = "term-grid";
38+ this.element.appendChild(this._container);
39+ this.element.classList.add("wterm");
40+ if (options.cursorBlink)
41+ this.element.classList.add("cursor-blink");
42+ this._onClickFocus = (event) => {
43+ const target = event.target;
44+ if (target instanceof Element && target.closest(".term-link")) {
45+ if (isLinkActivationModifier(event, this.element.ownerDocument.defaultView?.navigator ?? navigator) ||
46+ event.detail === 0) {
47+ return;
48+ }
49+ event.preventDefault();
50+ }
51+ const sel = window.getSelection();
52+ if (!sel || sel.isCollapsed)
53+ this.input?.focus();
54+ };
55+ this.element.addEventListener("click", this._onClickFocus);
56+ this._onModifierChange = (event) => {
57+ this.element.classList.toggle("link-modifier-active", isLinkActivationModifier(event, this.element.ownerDocument.defaultView?.navigator ?? navigator));
58+ };
59+ this._onWindowBlur = () => {
60+ this.element.classList.remove("link-modifier-active");
61+ };
62+ this.element.ownerDocument.addEventListener("keydown", this._onModifierChange);
63+ this.element.ownerDocument.addEventListener("keyup", this._onModifierChange);
64+ this.element.ownerDocument.defaultView?.addEventListener("blur", this._onWindowBlur);
65+ this._onScroll = () => {
66+ if (this._pendingResizeScrollTop !== null)
67+ return;
68+ if (this._programmaticScrollTop !== null &&
69+ this.element.scrollTop === this._programmaticScrollTop) {
70+ this._programmaticScrollTop = null;
71+ return;
72+ }
73+ this._programmaticScrollTop = null;
74+ this._shouldScrollToBottom = false;
75+ this._scheduleRender();
76+ };
77+ this.element.addEventListener("scroll", this._onScroll, { passive: true });
78+ }
79+ async init() {
80+ try {
81+ if (this._coreOption) {
82+ this.bridge = this._coreOption;
83+ }
84+ else {
85+ this.bridge = await WasmBridge.load(this.wasmUrl);
86+ }
87+ if (this._destroyed)
88+ return this;
89+ this.bridge.init(this.cols, this.rows);
90+ if (this._debugEnabled) {
91+ this.debug = new DebugAdapter();
92+ this.debug.setBridge(this.bridge);
93+ globalThis.__wterm = this;
94+ }
95+ this._setRowHeight();
96+ this._measureCharSize();
97+ this.renderer = new Renderer(this._container);
98+ this.renderer.setup(this.cols, this.rows);
99+ this.input = new InputHandler(this.element, (data) => {
100+ this._scrollToBottom();
101+ if (this.onData) {
102+ this.onData(data);
103+ }
104+ else {
105+ this.write(data);
106+ }
107+ }, () => this.bridge, () => this._charWidth > 0 && this._rowHeight > 0
108+ ? { charWidth: this._charWidth, rowHeight: this._rowHeight }
109+ : null);
110+ if (this.autoResize) {
111+ this._setupResizeObserver();
112+ }
113+ else {
114+ this._lockHeight();
115+ }
116+ this.input.focus();
117+ this._initialRender();
118+ }
119+ catch (err) {
120+ this.destroy();
121+ throw new Error(`wterm: failed to initialize: ${err instanceof Error ? err.message : err}`);
122+ }
123+ return this;
124+ }
125+ _isScrolledToBottom() {
126+ const el = this.element;
127+ return el.scrollHeight - el.scrollTop - el.clientHeight < 5;
128+ }
129+ _scrollToBottom() {
130+ const el = this.element;
131+ const maxScroll = el.scrollHeight - el.clientHeight;
132+ if (maxScroll <= 0) {
133+ this._setScrollTop(0);
134+ return;
135+ }
136+ this._setScrollTop(maxScroll);
137+ }
138+ _setScrollTop(value) {
139+ if (this.element.scrollTop === value)
140+ return;
141+ this._programmaticScrollTop = value;
142+ this.element.scrollTop = value;
143+ }
144+ write(data) {
145+ if (!this.bridge)
146+ return;
147+ if (this.debug)
148+ this.debug.traceWrite(data);
149+ this._shouldScrollToBottom = this._isScrolledToBottom();
150+ let deliveryError;
151+ let hasDeliveryError = false;
152+ const drain = () => {
153+ const result = this._drainResponses();
154+ if (!hasDeliveryError && result.hasError) {
155+ hasDeliveryError = true;
156+ deliveryError = result.error;
157+ }
158+ };
159+ if (typeof data === "string") {
160+ this.bridge.writeString(data, drain);
161+ }
162+ else {
163+ this.bridge.writeRaw(data, drain);
164+ }
165+ const synchronized = this.bridge.synchronizedOutput?.() ?? false;
166+ const generation = this.bridge.synchronizedOutputGeneration?.() ?? 0;
167+ this._updateSynchronizedOutput(synchronized, generation);
168+ if (this._synchronizedOutputState !== "held") {
169+ this._setupRendererIfNeeded();
170+ this._scheduleRender();
171+ }
172+ drain();
173+ if (hasDeliveryError)
174+ throw deliveryError;
175+ }
176+ resize(cols, rows) {
177+ if (!this.bridge)
178+ return;
179+ this._shouldScrollToBottom =
180+ this._pendingResizeScrollTop === null && this._isScrolledToBottom();
181+ this.cols = cols;
182+ this.rows = rows;
183+ this.bridge.resize(cols, rows);
184+ const synchronized = this.bridge.synchronizedOutput?.() ?? false;
185+ const generation = this.bridge.synchronizedOutputGeneration?.() ?? 0;
186+ if (this._updateSynchronizedOutput(synchronized, generation)) {
187+ this._rendererNeedsSetup = true;
188+ }
189+ else {
190+ this._setupRenderer(cols, rows);
191+ this._scheduleRender();
192+ }
193+ if (this.onResize)
194+ this.onResize(cols, rows);
195+ }
196+ focus() {
197+ if (this.input) {
198+ this.input.focus();
199+ }
200+ else {
201+ this.element.focus();
202+ }
203+ }
204+ _scheduleRender() {
205+ if (this.rafId != null)
206+ return;
207+ this.rafId = requestAnimationFrame(() => {
208+ this.rafId = null;
209+ this._doRender();
210+ });
211+ }
212+ _cancelScheduledRender() {
213+ if (this.rafId != null) {
214+ cancelAnimationFrame(this.rafId);
215+ this.rafId = null;
216+ }
217+ }
218+ _updateSynchronizedOutput(synchronized, generation) {
219+ if (!synchronized) {
220+ if (this._synchronizedOutputState === "held") {
221+ this._cancelSynchronizedOutputFallback();
222+ }
223+ this._synchronizedOutputState = "idle";
224+ return false;
225+ }
226+ if (this._synchronizedOutputState === "held" &&
227+ generation !== this._synchronizedOutputGeneration) {
228+ this._armSynchronizedOutputFallback(generation);
229+ return true;
230+ }
231+ else if (this._synchronizedOutputState === "passthrough" &&
232+ generation !== this._synchronizedOutputGeneration) {
233+ this._synchronizedOutputState = "idle";
234+ }
235+ if (this._synchronizedOutputState !== "idle") {
236+ return this._synchronizedOutputState === "held";
237+ }
238+ this._synchronizedOutputState = "held";
239+ this._cancelScheduledRender();
240+ this._armSynchronizedOutputFallback(generation);
241+ return true;
242+ }
243+ _armSynchronizedOutputFallback(generation) {
244+ this._cancelSynchronizedOutputFallback();
245+ this._synchronizedOutputGeneration = generation;
246+ this._synchronizedOutputTimer = setTimeout(() => {
247+ if (this._synchronizedOutputState !== "held" ||
248+ this._synchronizedOutputGeneration !== generation) {
249+ return;
250+ }
251+ this._synchronizedOutputTimer = null;
252+ this._synchronizedOutputState = "passthrough";
253+ this._setupRendererIfNeeded();
254+ this._cancelScheduledRender();
255+ this._doRender();
256+ }, SYNCHRONIZED_OUTPUT_TIMEOUT_MS);
257+ }
258+ _cancelSynchronizedOutputFallback() {
259+ if (this._synchronizedOutputTimer == null)
260+ return;
261+ clearTimeout(this._synchronizedOutputTimer);
262+ this._synchronizedOutputTimer = null;
263+ }
264+ _setupRendererIfNeeded() {
265+ if (!this._rendererNeedsSetup)
266+ return;
267+ this._setupRenderer(this.cols, this.rows);
268+ this._rendererNeedsSetup = false;
269+ }
270+ _setupRenderer(cols, rows) {
271+ if (!this._shouldScrollToBottom && this._pendingResizeScrollTop === null) {
272+ this._pendingResizeScrollTop = this.element.scrollTop;
273+ }
274+ this.renderer?.setup(cols, rows);
275+ }
276+ _initialRender() {
277+ this._doRender();
278+ }
279+ _doRender() {
280+ if (!this.bridge || !this.renderer)
281+ return;
282+ let dirtyCount = 0;
283+ const t0 = this.debug ? performance.now() : 0;
284+ if (this.debug) {
285+ for (let r = 0; r < this.rows; r++) {
286+ if (this.bridge.isDirtyRow(r))
287+ dirtyCount++;
288+ }
289+ }
290+ const rowHeight = this._rowHeight || 17;
291+ const scrollbackCount = this.bridge.getScrollbackCount();
292+ const discardedCount = this.bridge.getScrollbackDiscardedCount?.();
293+ const discardedDelta = discardedCount !== undefined &&
294+ discardedCount >= this._scrollbackDiscardedCount
295+ ? discardedCount - this._scrollbackDiscardedCount
296+ : 0;
297+ if (discardedCount !== undefined) {
298+ this._scrollbackDiscardedCount = discardedCount;
299+ }
300+ let scrollTop = this._pendingResizeScrollTop !== null
301+ ? this._pendingResizeScrollTop
302+ : this.element.scrollTop;
303+ if (!this._shouldScrollToBottom && discardedDelta > 0) {
304+ scrollTop = Math.max(0, scrollTop - discardedDelta * rowHeight);
305+ if (this._pendingResizeScrollTop !== null) {
306+ this._pendingResizeScrollTop = scrollTop;
307+ }
308+ else {
309+ this._setScrollTop(scrollTop);
310+ }
311+ }
312+ this.renderer.render(this.bridge, {
313+ scrollTop: this._shouldScrollToBottom
314+ ? Math.max(0, (scrollbackCount + this.rows) * rowHeight -
315+ this.element.clientHeight)
316+ : scrollTop,
317+ clientHeight: this.element.clientHeight,
318+ rowHeight,
319+ scrollbackDiscardedCount: discardedCount,
320+ });
321+ if (this.debug) {
322+ this.debug.recordRender(performance.now() - t0, dirtyCount);
323+ }
324+ const hasScrollback = scrollbackCount > 0;
325+ this.element.classList.toggle("has-scrollback", hasScrollback);
326+ if (this._shouldScrollToBottom) {
327+ this._scrollToBottom();
328+ }
329+ else if (this._pendingResizeScrollTop !== null) {
330+ const pendingScrollTop = this._pendingResizeScrollTop;
331+ this._pendingResizeScrollTop = null;
332+ this._setScrollTop(pendingScrollTop);
333+ }
334+ else if (!hasScrollback && this.element.scrollTop !== 0) {
335+ this.element.scrollTop = 0;
336+ }
337+ const title = this.bridge.getTitle();
338+ if (title !== null && this.onTitle) {
339+ this.onTitle(title);
340+ }
341+ this._drainResponses();
342+ }
343+ _drainResponses() {
344+ if (!this.bridge)
345+ return { hasError: false };
346+ let response;
347+ let firstError;
348+ let hasError = false;
349+ while ((response = this.bridge.getResponse()) !== null) {
350+ try {
351+ if (this.onData)
352+ this.onData(response);
353+ }
354+ catch (error) {
355+ if (!hasError) {
356+ hasError = true;
357+ firstError = error;
358+ }
359+ }
360+ }
361+ return { hasError, error: firstError };
362+ }
363+ _lockHeight() {
364+ const rh = this._rowHeight || 17;
365+ const gridHeight = this.rows * rh;
366+ const cs = getComputedStyle(this.element);
367+ let extra = (parseFloat(cs.paddingTop) || 0) + (parseFloat(cs.paddingBottom) || 0);
368+ if (cs.boxSizing === "border-box") {
369+ extra +=
370+ (parseFloat(cs.borderTopWidth) || 0) +
371+ (parseFloat(cs.borderBottomWidth) || 0);
372+ }
373+ this.element.style.height = `${gridHeight + extra}px`;
374+ }
375+ _setRowHeight() {
376+ const probe = document.createElement("div");
377+ probe.className = "term-row";
378+ probe.style.visibility = "hidden";
379+ probe.style.position = "absolute";
380+ probe.textContent = "W";
381+ this._container.appendChild(probe);
382+ const h = probe.getBoundingClientRect().height;
383+ probe.remove();
384+ if (h > 0) {
385+ const rh = Math.ceil(h);
386+ this._rowHeight = rh;
387+ this.element.style.setProperty("--term-row-height", `${rh}px`);
388+ }
389+ }
390+ _measureCharSize() {
391+ const row = document.createElement("div");
392+ row.className = "term-row";
393+ row.style.visibility = "hidden";
394+ row.style.position = "absolute";
395+ const probe = document.createElement("span");
396+ probe.textContent = "W";
397+ row.appendChild(probe);
398+ this._container.appendChild(row);
399+ const charWidth = probe.getBoundingClientRect().width;
400+ const rowHeight = row.getBoundingClientRect().height;
401+ row.remove();
402+ if (charWidth === 0 || rowHeight === 0)
403+ return null;
404+ this._charWidth = charWidth;
405+ this._rowHeight = rowHeight;
406+ return { charWidth, rowHeight };
407+ }
408+ _setupResizeObserver() {
409+ const initial = this._measureCharSize();
410+ if (!initial)
411+ return;
412+ let { charWidth, rowHeight } = initial;
413+ this.resizeObserver = new ResizeObserver((entries) => {
414+ const measured = this._measureCharSize();
415+ if (measured) {
416+ charWidth = measured.charWidth;
417+ rowHeight = measured.rowHeight;
418+ }
419+ for (const entry of entries) {
420+ const { width, height } = entry.contentRect;
421+ const newCols = Math.max(1, Math.floor(width / charWidth));
422+ const newRows = Math.max(1, Math.floor(height / rowHeight));
423+ if (newCols !== this.cols || newRows !== this.rows) {
424+ this.resize(newCols, newRows);
425+ }
426+ }
427+ });
428+ this.resizeObserver.observe(this.element);
429+ }
430+ destroy() {
431+ this._destroyed = true;
432+ this._cancelScheduledRender();
433+ this._cancelSynchronizedOutputFallback();
434+ if (this.resizeObserver)
435+ this.resizeObserver.disconnect();
436+ if (this.input)
437+ this.input.destroy();
438+ this.element.removeEventListener("click", this._onClickFocus);
439+ this.element.removeEventListener("scroll", this._onScroll);
440+ this.element.ownerDocument.removeEventListener("keydown", this._onModifierChange);
441+ this.element.ownerDocument.removeEventListener("keyup", this._onModifierChange);
442+ this.element.ownerDocument.defaultView?.removeEventListener("blur", this._onWindowBlur);
443+ this.element.classList.remove("link-modifier-active");
444+ this.element.innerHTML = "";
445+ if (this.debug &&
446+ globalThis.__wterm === this) {
447+ delete globalThis.__wterm;
448+ }
449+ this.debug = null;
450+ }
451+}
addedcrates/anvil-web/assets/wterm/terminal.css+195 −0
1+.wterm {
2+ --term-fg: #d4d4d4;
3+ --term-bg: #1e1e1e;
4+ --term-cursor: #aeafad;
5+
6+ --term-color-0: #1e1e1e;
7+ --term-color-1: #f44747;
8+ --term-color-2: #6a9955;
9+ --term-color-3: #d7ba7d;
10+ --term-color-4: #569cd6;
11+ --term-color-5: #c586c0;
12+ --term-color-6: #4ec9b0;
13+ --term-color-7: #d4d4d4;
14+ --term-color-8: #808080;
15+ --term-color-9: #f44747;
16+ --term-color-10: #6a9955;
17+ --term-color-11: #d7ba7d;
18+ --term-color-12: #569cd6;
19+ --term-color-13: #c586c0;
20+ --term-color-14: #4ec9b0;
21+ --term-color-15: #ffffff;
22+
23+ --term-font-family: 'Menlo', 'Consolas', 'DejaVu Sans Mono', 'Courier New', monospace;
24+ --term-font-size: 14px;
25+ --term-line-height: 1.2;
26+ --term-row-height: 17px;
27+
28+ position: relative;
29+ background: var(--term-bg);
30+ color: var(--term-fg);
31+ font-family: var(--term-font-family);
32+ font-size: var(--term-font-size);
33+ line-height: var(--term-line-height);
34+ padding: 12px;
35+ border-radius: 8px;
36+ box-shadow: 0 8px 32px rgba(0, 0, 0, 0.4);
37+ outline: none;
38+ overflow: hidden;
39+ overflow-anchor: none;
40+}
41+
42+.wterm:focus,
43+.wterm:focus-visible {
44+ outline: none;
45+}
46+
47+.term-grid {
48+ display: block;
49+ white-space: pre;
50+ contain: layout paint style;
51+ will-change: contents;
52+}
53+
54+.term-row {
55+ display: block;
56+ height: var(--term-row-height);
57+ line-height: var(--term-row-height);
58+ contain: layout style;
59+}
60+
61+.term-scrollback-spacer {
62+ display: block;
63+ pointer-events: none;
64+}
65+
66+.term-row > span,
67+.term-row > .term-link,
68+.term-link > span {
69+ display: inline-block;
70+ height: var(--term-row-height);
71+ vertical-align: top;
72+}
73+
74+.term-link {
75+ color: inherit;
76+ cursor: text;
77+}
78+
79+.wterm.link-modifier-active .term-link:hover,
80+.term-link:focus-visible {
81+ cursor: pointer;
82+}
83+
84+.wterm.link-modifier-active .term-link:hover > span,
85+.term-link:focus-visible > span {
86+ text-decoration: underline;
87+ text-underline-offset: 0.15em;
88+}
89+
90+.term-block {
91+ width: 1ch;
92+ overflow: hidden;
93+}
94+
95+.term-wide {
96+ width: 2ch;
97+ overflow: hidden;
98+}
99+
100+.term-cursor {
101+ outline: 1px solid var(--term-cursor);
102+ outline-offset: -1px;
103+}
104+
105+.wterm.focused .term-cursor {
106+ background: var(--term-cursor);
107+ color: var(--term-bg);
108+ outline: none;
109+}
110+
111+.wterm.focused.cursor-blink .term-cursor {
112+ animation: cursor-blink 1s step-end infinite;
113+}
114+
115+@keyframes cursor-blink {
116+ 0%, 100% { background: var(--term-cursor); color: var(--term-bg); }
117+ 50% { background: transparent; color: inherit; }
118+}
119+
120+.wterm.has-scrollback {
121+ overflow-y: auto;
122+}
123+
124+.wterm ::selection {
125+ background: rgba(86, 156, 214, 0.3);
126+}
127+
128+/* Solarized Dark */
129+.wterm.theme-solarized-dark {
130+ --term-fg: #839496;
131+ --term-bg: #002b36;
132+ --term-cursor: #93a1a1;
133+ --term-color-0: #073642;
134+ --term-color-1: #dc322f;
135+ --term-color-2: #859900;
136+ --term-color-3: #b58900;
137+ --term-color-4: #268bd2;
138+ --term-color-5: #d33682;
139+ --term-color-6: #2aa198;
140+ --term-color-7: #eee8d5;
141+ --term-color-8: #586e75;
142+ --term-color-9: #cb4b16;
143+ --term-color-10: #586e75;
144+ --term-color-11: #657b83;
145+ --term-color-12: #839496;
146+ --term-color-13: #6c71c4;
147+ --term-color-14: #93a1a1;
148+ --term-color-15: #fdf6e3;
149+}
150+
151+/* Monokai */
152+.wterm.theme-monokai {
153+ --term-fg: #f8f8f2;
154+ --term-bg: #272822;
155+ --term-cursor: #f8f8f0;
156+ --term-color-0: #272822;
157+ --term-color-1: #f92672;
158+ --term-color-2: #a6e22e;
159+ --term-color-3: #f4bf75;
160+ --term-color-4: #66d9ef;
161+ --term-color-5: #ae81ff;
162+ --term-color-6: #a1efe4;
163+ --term-color-7: #f8f8f2;
164+ --term-color-8: #75715e;
165+ --term-color-9: #f92672;
166+ --term-color-10: #a6e22e;
167+ --term-color-11: #f4bf75;
168+ --term-color-12: #66d9ef;
169+ --term-color-13: #ae81ff;
170+ --term-color-14: #a1efe4;
171+ --term-color-15: #f9f8f5;
172+}
173+
174+/* Light */
175+.wterm.theme-light {
176+ --term-fg: #383a42;
177+ --term-bg: #fafafa;
178+ --term-cursor: #526eff;
179+ --term-color-0: #383a42;
180+ --term-color-1: #e45649;
181+ --term-color-2: #50a14f;
182+ --term-color-3: #c18401;
183+ --term-color-4: #4078f2;
184+ --term-color-5: #a626a4;
185+ --term-color-6: #0184bc;
186+ --term-color-7: #fafafa;
187+ --term-color-8: #a0a1a7;
188+ --term-color-9: #e45649;
189+ --term-color-10: #50a14f;
190+ --term-color-11: #c18401;
191+ --term-color-12: #4078f2;
192+ --term-color-13: #a626a4;
193+ --term-color-14: #0184bc;
194+ --term-color-15: #ffffff;
195+}
addedcrates/anvil-web/src/agent.rs+567 −0
1+//! Web surface for agent sessions: the session list and page, the websocket
2+//! that bridges a browser terminal to the container's tmux, and the vendored
3+//! terminal emulator assets.
4+//!
5+//! The wire format on the websocket is ours rather than wterm's built-in
6+//! transport, which is a raw byte pass-through with no control channel and so
7+//! no way to carry a resize:
8+//!
9+//! - **binary frames** are raw terminal bytes, in both directions;
10+//! - **text frames** are JSON control messages — `{"t":"resize",…}` from the
11+//! browser, `{"t":"exit",…}` back.
12+
13+use anvil_agent::StartError;
14+use anvil_core::{
15+ App,
16+ User,
17+ access,
18+ agent::{
19+ self,
20+ kind,
21+ status,
22+ },
23+};
24+use axum::{
25+ Router,
26+ extract::{
27+ Path,
28+ State,
29+ ws::{
30+ Message,
31+ WebSocket,
32+ WebSocketUpgrade,
33+ },
34+ },
35+ http::header,
36+ response::{
37+ IntoResponse,
38+ Redirect,
39+ Response,
40+ },
41+ routing::{
42+ get,
43+ post,
44+ },
45+};
46+use futures_util::{
47+ SinkExt,
48+ StreamExt,
49+};
50+use maud::{
51+ Markup,
52+ PreEscaped,
53+ html,
54+};
55+use tokio::io::AsyncWriteExt;
56+
57+use crate::{
58+ auth::{
59+ Csrf,
60+ CsrfForm,
61+ CurrentUser,
62+ verify_csrf,
63+ },
64+ ui::{
65+ self,
66+ forbidden,
67+ layout,
68+ not_found,
69+ server_error,
70+ },
71+};
72+
73+pub fn routes(router: Router<App>) -> Router<App> {
74+ router
75+ .route("/{owner}/{repo}/-/agent", get(list).post(create))
76+ .route("/{owner}/{repo}/-/agent/{id}", get(show))
77+ .route("/{owner}/{repo}/-/agent/{id}/stop", post(stop))
78+ .route("/{owner}/{repo}/-/agent/{id}/ws", get(socket))
79+ .route("/-/static/wterm/{*path}", get(wterm_asset))
80+}
81+
82+// --- vendored terminal emulator --------------------------------------------
83+
84+/// wterm (Apache-2.0), vendored as published ESM and embedded in the binary,
85+/// exactly as htmx is. No bundler is involved: the tree has a single bare
86+/// specifier (`@wterm/core`), which the page resolves with an import map.
87+///
88+/// The built-in core's WASM is inlined as base64 inside `wasm-inline.js`, so
89+/// there is no separate binary to fetch and no `import.meta.url` resolution to
90+/// get wrong.
91+const WTERM_ASSETS: &[(&str, &str, &str)] = &[
92+ (
93+ "core/index.js",
94+ include_str!("../assets/wterm/core/index.js"),
95+ JS,
96+ ),
97+ (
98+ "core/terminal-core.js",
99+ include_str!("../assets/wterm/core/terminal-core.js"),
100+ JS,
101+ ),
102+ (
103+ "core/transport.js",
104+ include_str!("../assets/wterm/core/transport.js"),
105+ JS,
106+ ),
107+ (
108+ "core/wasm-bridge.js",
109+ include_str!("../assets/wterm/core/wasm-bridge.js"),
110+ JS,
111+ ),
112+ (
113+ "core/wasm-inline.js",
114+ include_str!("../assets/wterm/core/wasm-inline.js"),
115+ JS,
116+ ),
117+ (
118+ "dom/index.js",
119+ include_str!("../assets/wterm/dom/index.js"),
120+ JS,
121+ ),
122+ (
123+ "dom/debug.js",
124+ include_str!("../assets/wterm/dom/debug.js"),
125+ JS,
126+ ),
127+ (
128+ "dom/hyperlink.js",
129+ include_str!("../assets/wterm/dom/hyperlink.js"),
130+ JS,
131+ ),
132+ (
133+ "dom/input.js",
134+ include_str!("../assets/wterm/dom/input.js"),
135+ JS,
136+ ),
137+ (
138+ "dom/renderer.js",
139+ include_str!("../assets/wterm/dom/renderer.js"),
140+ JS,
141+ ),
142+ (
143+ "dom/wterm.js",
144+ include_str!("../assets/wterm/dom/wterm.js"),
145+ JS,
146+ ),
147+ (
148+ "terminal.css",
149+ include_str!("../assets/wterm/terminal.css"),
150+ CSS,
151+ ),
152+];
153+
154+const JS: &str = "application/javascript; charset=utf-8";
155+const CSS: &str = "text/css; charset=utf-8";
156+
157+/// Serve one vendored wterm file. A `match` over embedded constants rather than
158+/// a route each, since it is a dozen files that only ever change together.
159+async fn wterm_asset(Path(path): Path<String>) -> Response {
160+ let Some((_, body, content_type)) = WTERM_ASSETS.iter().find(|(name, _, _)| *name == path)
161+ else {
162+ return not_found("no such asset");
163+ };
164+ (
165+ [
166+ (header::CONTENT_TYPE, *content_type),
167+ // Vendored at a fixed version and only replaced by a redeploy.
168+ (header::CACHE_CONTROL, "public, max-age=31536000, immutable"),
169+ ],
170+ *body,
171+ )
172+ .into_response()
173+}
174+
175+// --- pages ------------------------------------------------------------------
176+
177+/// Resolve the repo and require write access: starting a session runs code
178+/// against the repository and (from M2) pushes to it, so it is an owner action,
179+/// not a reader one.
180+async fn resolve_writable(
181+ app: &App,
182+ viewer: Option<&User>,
183+ owner: &str,
184+ name: &str,
185+) -> Result<anvil_core::Repository, Response> {
186+ let (_, repo) = ui::resolve_repo(app, viewer, owner, name).await?;
187+ if !access::can_write(&repo, viewer) {
188+ return Err(forbidden());
189+ }
190+ Ok(repo)
191+}
192+
193+/// `GET /{owner}/{repo}/-/agent` — this repository's sessions.
194+async fn list(
195+ State(app): State<App>,
196+ CurrentUser(user): CurrentUser,
197+ Path((owner, name)): Path<(String, String)>,
198+) -> Result<Markup, Response> {
199+ let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
200+ let sessions = agent::list_by_repo(&app.db, repo.id, 50)
201+ .await
202+ .map_err(server_error)?;
203+ let enabled = app.config.agent.enabled;
204+ let csrf = crate::auth::current_csrf();
205+
206+ Ok(layout(
207+ &format!("{owner}/{name} · agent"),
208+ user.as_ref(),
209+ html! {
210+ h1 { "Agent sessions" }
211+ @if !enabled {
212+ p.notice {
213+ "Agent sessions are disabled. Set "
214+ code { "agent.enabled" }
215+ " in anvil.toml to turn them on."
216+ }
217+ } @else {
218+ form method="post" action={"/" (owner) "/" (name) "/-/agent"} {
219+ (ui::csrf_input(&csrf))
220+ label {
221+ "Start from branch "
222+ input type="text" name="base_ref" value="main" required;
223+ }
224+ label {
225+ "Opening prompt (optional — leave empty to drive it yourself)"
226+ textarea name="prompt" rows="3" {}
227+ }
228+ button type="submit" { "Start session" }
229+ }
230+ }
231+ @if sessions.is_empty() {
232+ p { "No sessions yet." }
233+ } @else {
234+ ul.sessions {
235+ @for session in &sessions {
236+ li {
237+ a href={"/" (owner) "/" (name) "/-/agent/" (session.id)} {
238+ "#" (session.id)
239+ }
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)) }
244+ }
245+ }
246+ }
247+ }
248+ },
249+ ))
250+}
251+
252+fn short_commit(commit: &str) -> &str {
253+ &commit[..commit.len().min(12)]
254+}
255+
256+/// `POST /{owner}/{repo}/-/agent` — start a session.
257+async fn create(
258+ State(app): State<App>,
259+ CurrentUser(user): CurrentUser,
260+ csrf: Csrf,
261+ Path((owner, name)): Path<(String, String)>,
262+ axum::Form(form): axum::Form<CreateForm>,
263+) -> Result<Response, Response> {
264+ verify_csrf(&csrf, &form.csrf)?;
265+ let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
266+ let Some(user) = user else {
267+ return Err(forbidden());
268+ };
269+
270+ let base_ref = if form.base_ref.trim().is_empty() {
271+ repo.default_branch.clone()
272+ } else {
273+ form.base_ref.trim().to_string()
274+ };
275+ let prompt = form.prompt.trim();
276+ let session_kind = if prompt.is_empty() {
277+ kind::INTERACTIVE
278+ } else {
279+ kind::AUTONOMOUS
280+ };
281+
282+ match anvil_agent::start(&app, repo.id, user.id, session_kind, &base_ref, prompt).await {
283+ Ok(id) => Ok(Redirect::to(&format!("/{owner}/{name}/-/agent/{id}")).into_response()),
284+ Err(StartError::Disabled) => Err(forbidden()),
285+ Err(e) => Err(server_error(e)),
286+ }
287+}
288+
289+#[derive(serde::Deserialize)]
290+struct CreateForm {
291+ #[serde(default)]
292+ csrf: String,
293+ #[serde(default)]
294+ base_ref: String,
295+ #[serde(default)]
296+ prompt: String,
297+}
298+
299+/// `POST /{owner}/{repo}/-/agent/{id}/stop`
300+async fn stop(
301+ State(app): State<App>,
302+ CurrentUser(user): CurrentUser,
303+ csrf: Csrf,
304+ Path((owner, name, id)): Path<(String, String, i64)>,
305+ axum::Form(form): axum::Form<CsrfForm>,
306+) -> Result<Response, Response> {
307+ verify_csrf(&csrf, &form.csrf)?;
308+ let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
309+ let session = session_of(&app, repo.id, id).await?;
310+ anvil_agent::stop(&app, session.id).await;
311+ Ok(Redirect::to(&format!("/{owner}/{name}/-/agent/{id}")).into_response())
312+}
313+
314+/// Load a session, checking it really belongs to this repository — otherwise
315+/// the id alone would read across repos.
316+async fn session_of(
317+ app: &App,
318+ repo_id: i64,
319+ id: i64,
320+) -> Result<anvil_core::AgentSession, Response> {
321+ let session = agent::get(&app.db, id)
322+ .await
323+ .map_err(server_error)?
324+ .ok_or_else(|| not_found("no such session"))?;
325+ if session.repo_id != repo_id {
326+ return Err(not_found("no such session"));
327+ }
328+ Ok(session)
329+}
330+
331+/// `GET /{owner}/{repo}/-/agent/{id}` — the terminal.
332+async fn show(
333+ State(app): State<App>,
334+ CurrentUser(user): CurrentUser,
335+ Path((owner, name, id)): Path<(String, String, i64)>,
336+) -> Result<Markup, Response> {
337+ let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
338+ let session = session_of(&app, repo.id, id).await?;
339+ let live = status::is_live(&session.status);
340+ let csrf = crate::auth::current_csrf();
341+ let ws_path = format!("/{owner}/{name}/-/agent/{id}/ws");
342+
343+ Ok(layout(
344+ &format!("{owner}/{name} · agent #{id}"),
345+ user.as_ref(),
346+ html! {
347+ h1 { "Agent session #" (id) }
348+ p.muted {
349+ (session.status) " · " (session.base_ref) " @ "
350+ (short_commit(&session.base_commit))
351+ @if !session.error.is_empty() { " · " (session.error) }
352+ }
353+ @if live {
354+ form method="post" action={(ws_path.trim_end_matches("/ws")) "/stop"} {
355+ (ui::csrf_input(&csrf))
356+ button type="submit" { "Stop session" }
357+ }
358+ link rel="stylesheet" href="/-/static/wterm/terminal.css";
359+ div #terminal style="height:70vh" {}
360+ script type="importmap" {
361+ (PreEscaped(IMPORT_MAP))
362+ }
363+ script type="module" {
364+ (PreEscaped(format!("const WS_PATH = {};\n{}",
365+ serde_json::to_string(&ws_path).unwrap_or_else(|_| "\"\"".into()),
366+ TERMINAL_JS)))
367+ }
368+ } @else {
369+ p { "This session has ended." }
370+ }
371+ },
372+ ))
373+}
374+
375+/// Resolves wterm's single bare specifier. Everything else in the vendored
376+/// tree imports by relative path.
377+const IMPORT_MAP: &str = r#"{"imports":{"@wterm/core":"/-/static/wterm/core/index.js"}}"#;
378+
379+/// Browser half of the terminal.
380+///
381+/// Note the `htmx:beforeSwap` teardown: the layout sets `hx-boost` on `<body>`,
382+/// so navigating away swaps the DOM without a page load. Without this the
383+/// websocket and the WASM instance would leak on every navigation.
384+const TERMINAL_JS: &str = r#"
385+import { WTerm } from "/-/static/wterm/dom/index.js";
386+
387+const url = new URL(WS_PATH, location.href);
388+url.protocol = location.protocol === "https:" ? "wss:" : "ws:";
389+const ws = new WebSocket(url);
390+ws.binaryType = "arraybuffer";
391+
392+const encode = new TextEncoder();
393+let torndown = false;
394+
395+const sendResize = () => {
396+ if (ws.readyState === WebSocket.OPEN) {
397+ ws.send(JSON.stringify({ t: "resize", cols: term.cols, rows: term.rows }));
398+ }
399+};
400+
401+// Callbacks are read off the instance at call time, so setting them here (in
402+// the constructor options) is the same as setting them later — this is just
403+// the clearer place. `wasmUrl` is left unset on purpose: the built-in core's
404+// WASM is inlined as base64, so there is no second request to make.
405+const term = new WTerm(document.getElementById("terminal"), {
406+ autoResize: true,
407+ cursorBlink: true,
408+ // Keystrokes out. onData hands us a string; the wire carries bytes.
409+ onData: (data) => {
410+ if (ws.readyState === WebSocket.OPEN) ws.send(encode.encode(data));
411+ },
412+ // Fires on the ResizeObserver as well as an explicit resize(), so the
413+ // container's pty follows the browser window.
414+ onResize: sendResize,
415+});
416+await term.init();
417+
418+ws.onopen = sendResize;
419+
420+ws.onmessage = (event) => {
421+ // Binary is terminal bytes; text is our control channel.
422+ if (event.data instanceof ArrayBuffer) {
423+ term.write(new Uint8Array(event.data));
424+ return;
425+ }
426+ let msg;
427+ try {
428+ msg = JSON.parse(event.data);
429+ } catch {
430+ return;
431+ }
432+ if (msg.t === "exit") {
433+ term.write("\r\n\x1b[2m[session ended, status " + msg.code + "]\x1b[0m\r\n");
434+ } else if (msg.t === "error") {
435+ term.write("\r\n\x1b[31m[" + msg.message + "]\x1b[0m\r\n");
436+ }
437+};
438+
439+ws.onclose = () => {
440+ if (!torndown) term.write("\r\n\x1b[2m[disconnected]\x1b[0m\r\n");
441+};
442+
443+// The layout sets hx-boost on <body>, so navigating away swaps the DOM without
444+// a page load. Without this teardown the socket and the WASM instance would
445+// leak on every navigation.
446+const teardown = () => {
447+ if (torndown) return;
448+ torndown = true;
449+ try { ws.close(); } catch {}
450+ try { term.destroy(); } catch {}
451+};
452+document.body.addEventListener("htmx:beforeSwap", teardown, { once: true });
453+window.addEventListener("pagehide", teardown, { once: true });
454+
455+term.focus();
456+"#;
457+
458+// --- the websocket ----------------------------------------------------------
459+
460+/// `GET /{owner}/{repo}/-/agent/{id}/ws` — bridge the browser to the
461+/// container's tmux.
462+async fn socket(
463+ State(app): State<App>,
464+ CurrentUser(user): CurrentUser,
465+ Path((owner, name, id)): Path<(String, String, i64)>,
466+ upgrade: WebSocketUpgrade,
467+) -> Result<Response, Response> {
468+ let repo = resolve_writable(&app, user.as_ref(), &owner, &name).await?;
469+ let session = session_of(&app, repo.id, id).await?;
470+ if !status::is_live(&session.status) {
471+ return Err(not_found("session is not running"));
472+ }
473+ Ok(upgrade.on_upgrade(move |socket| bridge(app, session.id, socket)))
474+}
475+
476+/// Pump bytes between one websocket and one tmux client.
477+///
478+/// Each browser gets its own `docker exec … tmux attach`, so a dropped socket
479+/// takes down that client and nothing else — the agent is a process inside
480+/// tmux, not a child of the exec. tmux repaints a newly attached client, which
481+/// is what makes reconnect show the current screen rather than a blank one.
482+async fn bridge(app: App, session_id: i64, socket: WebSocket) {
483+ let (mut sink, mut stream) = socket.split();
484+
485+ let (terminal, docker) = match anvil_agent::attach_stream(&app, session_id, 80, 24).await {
486+ Ok(attached) => attached,
487+ Err(e) => {
488+ let _ = sink
489+ .send(Message::Text(
490+ format!(r#"{{"t":"error","message":{}}}"#, json_string(&e)).into(),
491+ ))
492+ .await;
493+ return;
494+ }
495+ };
496+ let anvil_agent::container::Terminal {
497+ exec_id,
498+ mut output,
499+ mut input,
500+ } = terminal;
501+
502+ // Container -> browser.
503+ let to_browser = tokio::spawn(async move {
504+ while let Some(chunk) = output.next().await {
505+ let Ok(log) = chunk else { break };
506+ let bytes = log.into_bytes();
507+ if bytes.is_empty() {
508+ continue;
509+ }
510+ if sink
511+ .send(Message::Binary(bytes.to_vec().into()))
512+ .await
513+ .is_err()
514+ {
515+ break;
516+ }
517+ }
518+ let _ = sink.close().await;
519+ });
520+
521+ // Browser -> container, plus the control channel.
522+ let control_docker = docker.clone();
523+ let control_exec = exec_id.clone();
524+ let to_container = tokio::spawn(async move {
525+ while let Some(message) = stream.next().await {
526+ match message {
527+ Ok(Message::Binary(data)) => {
528+ if input.write_all(&data).await.is_err() {
529+ break;
530+ }
531+ let _ = input.flush().await;
532+ }
533+ Ok(Message::Text(text)) => {
534+ if let Some((cols, rows)) = parse_resize(&text) {
535+ anvil_agent::container::resize(&control_docker, &control_exec, cols, rows)
536+ .await;
537+ }
538+ }
539+ Ok(Message::Close(_)) | Err(_) => break,
540+ _ => {}
541+ }
542+ }
543+ });
544+
545+ // Either direction ending means this viewer is gone.
546+ tokio::select! {
547+ _ = to_browser => {}
548+ _ = to_container => {}
549+ }
550+ anvil_agent::detached(&app, session_id);
551+}
552+
553+/// Parse `{"t":"resize","cols":N,"rows":M}`.
554+fn parse_resize(text: &str) -> Option<(u16, u16)> {
555+ let value: serde_json::Value = serde_json::from_str(text).ok()?;
556+ if value.get("t")?.as_str()? != "resize" {
557+ return None;
558+ }
559+ let cols = value.get("cols")?.as_u64()?.try_into().ok()?;
560+ let rows = value.get("rows")?.as_u64()?.try_into().ok()?;
561+ Some((cols, rows))
562+}
563+
564+/// JSON-encode a string, for the small hand-built control messages.
565+fn json_string(s: &str) -> String {
566+ serde_json::to_string(s).unwrap_or_else(|_| "\"\"".to_string())
567+}
modifiedcrates/anvil-web/src/lib.rs+2 −0
⋯ 20 unchanged lines
2121 };
2222
2323 pub mod admin;
24+pub mod agent;
2425 pub mod artifacts;
2526 pub mod attachments;
2627 pub mod auth;
⋯ 12 unchanged lines
3940 .route("/-/logout", post(auth::logout));
4041 router = ui::routes(router); // web UI, including `/`
4142 router = admin::routes(router); // admin-only dashboard
43+ router = agent::routes(router); // agent sessions + the browser terminal
4244 router = pages::routes(router); // static sites from `pages` branches
4345 router = artifacts::routes(router); // CI artifact downloads + sites
4446 router = attachments::routes(
⋯ 30 unchanged lines
modifiedcrates/anvil-web/src/ui.rs+6 −0
⋯ 1186 unchanged lines
11871187 span.sep { "·" }
11881188 a href=(format!("/{owner}/{repo}/pages")) { "Pages" }
11891189 @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+ }
11901196 span.sep { "·" }
11911197 a href=(format!("/{owner}/{repo}/settings")) { "Settings" }
11921198 }
⋯ 1662 unchanged lines
addeddeploy/runner/Dockerfile+77 −0
1+# anvil-runner — the shared execution image for BOTH CI jobs and agent
2+# sessions.
3+#
4+# CI pipelines that omit `image:` land here (config `ci.default_image`), and
5+# agent sessions always do (`agent.image`). Keeping them the same image means a
6+# session can reproduce a build by hand, and there is one thing to keep current.
7+#
8+# Parameterized by base so the same recipe produces a small general runner and
9+# a toolchain-carrying one:
10+#
11+# anvil-runner:latest BASE=debian:bookworm-slim (the default)
12+# anvil-runner:rust BASE=rust:1.95-bookworm (builds anvil itself)
13+#
14+# Build both with deploy/runner/build.sh. There is NO registry behind this
15+# image — it lives only in the host's local image store, which is why anvil
16+# treats a failed pull of the default image as non-fatal.
17+ARG BASE=debian:bookworm-slim
18+FROM ${BASE}
19+
20+# Which Claude Code release channel to track. `stable` is roughly a week behind
21+# and skips releases with known major regressions; `latest` ships immediately.
22+ARG CLAUDE_CHANNEL=stable
23+
24+ENV DEBIAN_FRONTEND=noninteractive
25+
26+# Fingerprint of the Claude Code release signing key, checked below so a
27+# substituted key fails the build rather than silently installing. Published at
28+# https://code.claude.com/docs/en/setup. Deliberately inlined rather than an
29+# ARG: a build arg could be overridden on the command line, which would defeat
30+# the pin it exists to enforce.
31+
32+# tmux — session persistence; the whole point of the agent design
33+# git — the container clones/pushes for itself (anvil's own code never
34+# shells out to git, but what a job runs is its own tooling)
35+# fish — the interactive shell you actually get when you attach
36+# ripgrep — Claude Code's search backend
37+# curl/gnupg/ca-certificates — fetching and verifying the apt repo key
38+RUN apt-get update \
39+ && apt-get install -y --no-install-recommends \
40+ ca-certificates \
41+ curl \
42+ fish \
43+ git \
44+ gnupg \
45+ less \
46+ ripgrep \
47+ tmux \
48+ && install -d -m 0755 /etc/apt/keyrings \
49+ && curl -fsSL https://downloads.claude.ai/keys/claude-code.asc \
50+ -o /etc/apt/keyrings/claude-code.asc \
51+ && gpg --show-keys --with-colons /etc/apt/keyrings/claude-code.asc \
52+ | awk -F: '/^fpr:/ {print $10}' \
53+ | grep -qx 31DDDE24DDFAB679F42D7BD2BAA929FF1A7ECACE \
54+ && echo "deb [signed-by=/etc/apt/keyrings/claude-code.asc]" \
55+ "https://downloads.claude.ai/claude-code/apt/${CLAUDE_CHANNEL}" \
56+ "${CLAUDE_CHANNEL} main" > /etc/apt/sources.list.d/claude-code.list \
57+ && apt-get update \
58+ && apt-get install -y --no-install-recommends claude-code \
59+ && rm -rf /var/lib/apt/lists/*
60+
61+# apt installs never auto-update, but Claude Code still checks on startup and
62+# the check is pure noise in a container whose version is pinned by the image.
63+ENV DISABLE_AUTOUPDATER=1
64+
65+# An unprivileged user for agent sessions to run as. Deliberately NOT set as
66+# 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
68+# 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
72+
73+COPY tmux.conf /etc/anvil/tmux.conf
74+COPY session-entrypoint.sh /usr/local/bin/anvil-session
75+RUN chmod 0755 /usr/local/bin/anvil-session
76+
77+WORKDIR /workspace
addeddeploy/runner/build.sh+56 −0
1+#!/usr/bin/env bash
2+# Build the shared anvil runner image(s) — what BOTH CI jobs and agent sessions
3+# execute in.
4+#
5+# Two tags from one Dockerfile, differing only in base:
6+#
7+# anvil-runner:latest debian:bookworm-slim general purpose, the default
8+# anvil-runner:rust rust:1.95-bookworm carries the Rust toolchain
9+#
10+# There is deliberately no registry push: anvil resolves its default image from
11+# the LOCAL image store and treats a failed pull as non-fatal when the image is
12+# already present. So this must run on whichever host owns the Docker daemon
13+# anvil talks to — the VPS in production, your machine in dev.
14+#
15+# ./deploy/runner/build.sh # both tags
16+# ./deploy/runner/build.sh latest # just the slim one
17+# ./deploy/runner/build.sh rust # just the toolchain one
18+set -euo pipefail
19+
20+cd "$(dirname "$0")"
21+
22+NAME="${ANVIL_RUNNER_IMAGE:-anvil-runner}"
23+CHANNEL="${CLAUDE_CHANNEL:-stable}"
24+
25+build() {
26+ local tag="$1" base="$2"
27+ echo "==> building ${NAME}:${tag} (base ${base}, claude channel ${CHANNEL})"
28+ docker build \
29+ --build-arg "BASE=${base}" \
30+ --build-arg "CLAUDE_CHANNEL=${CHANNEL}" \
31+ -t "${NAME}:${tag}" \
32+ .
33+}
34+
35+case "${1:-all}" in
36+ latest) build latest debian:bookworm-slim ;;
37+ rust) build rust rust:1.95-bookworm ;;
38+ all)
39+ build latest debian:bookworm-slim
40+ build rust rust:1.95-bookworm
41+ ;;
42+ *)
43+ echo "usage: $0 [latest|rust|all]" >&2
44+ exit 64
45+ ;;
46+esac
47+
48+echo
49+echo "==> done"
50+docker images "${NAME}" --format ' {{.Repository}}:{{.Tag}} {{.Size}}'
51+echo
52+echo "Point anvil at it in anvil.toml if you use a non-default name:"
53+echo " [ci]"
54+echo " default_image = \"${NAME}:latest\""
55+echo " [agent]"
56+echo " image = \"${NAME}:latest\""
addeddeploy/runner/session-entrypoint.sh+69 −0
1+#!/bin/sh
2+# PID 1 of an agent-session container.
3+#
4+# Starts the agent under a detached tmux session, tees a byte-exact transcript,
5+# and then blocks until that session ends — so the container's lifetime is the
6+# agent's lifetime, and `docker wait` is a valid completion signal.
7+#
8+# Every browser attach is a SEPARATE `docker exec … tmux attach`, so a dropped
9+# websocket kills only that client. That is the reason tmux is here at all.
10+#
11+# Usage: anvil-session <command> [args...]
12+set -eu
13+
14+SESSION="${ANVIL_TMUX_SESSION:-agent}"
15+TRANSCRIPT="${ANVIL_TRANSCRIPT:-/tmp/anvil-transcript}"
16+WORKDIR="${ANVIL_WORKDIR:-/workspace}"
17+EXIT_FILE="${ANVIL_EXIT_FILE:-/tmp/anvil-exit}"
18+CMD_FILE="${ANVIL_CMD_FILE:-/tmp/anvil-cmd.sh}"
19+
20+if [ "$#" -eq 0 ]; then
21+ echo "anvil-session: no command given" >&2
22+ exit 64
23+fi
24+
25+# Single-quote one argument for safe re-parsing by /bin/sh.
26+quote() {
27+ printf "'%s'" "$(printf '%s' "$1" | sed "s/'/'\\\\''/g")"
28+}
29+
30+: > "$TRANSCRIPT"
31+rm -f "$EXIT_FILE"
32+
33+# Generate a script rather than nesting quotes inside tmux's command string:
34+# tmux hands that string to `sh -c`, so an argument containing spaces or quotes
35+# (an autonomous session's prompt, most obviously) would otherwise re-split.
36+{
37+ printf '#!/bin/sh\n'
38+ printf 'cd %s || exit 1\n' "$(quote "$WORKDIR")"
39+ for arg in "$@"; do
40+ printf '%s ' "$(quote "$arg")"
41+ done
42+ printf '\n'
43+ printf 'printf %%s $? > %s\n' "$(quote "$EXIT_FILE")"
44+} > "$CMD_FILE"
45+chmod 0755 "$CMD_FILE"
46+
47+tmux -f /etc/anvil/tmux.conf new-session -d -s "$SESSION" -c "$WORKDIR" \
48+ "sh $CMD_FILE"
49+
50+# Tee everything the pane emits into the transcript. `-o` means "only if not
51+# already piping", so a re-invocation is harmless.
52+tmux pipe-pane -o -t "$SESSION" "cat >> '$TRANSCRIPT'"
53+
54+# Block until the session is gone. Polling rather than `tmux wait-for` because
55+# the session can also be killed from outside (an operator stop, a sweep, an
56+# attached user typing `exit`), and has-session covers every one of those
57+# without needing a cooperating signaller.
58+while tmux has-session -t "$SESSION" 2>/dev/null; do
59+ if [ -f "$EXIT_FILE" ]; then
60+ # The command finished; the pane lingers because of remain-on-exit.
61+ # Leave the final screen readable for a moment, then wind up.
62+ sleep "${ANVIL_LINGER_SECS:-5}"
63+ tmux kill-session -t "$SESSION" 2>/dev/null || true
64+ break
65+ fi
66+ sleep 1
67+done
68+
69+exit "$(cat "$EXIT_FILE" 2>/dev/null || echo 0)"
addeddeploy/runner/tmux.conf+37 −0
1+# tmux configuration for anvil agent sessions.
2+#
3+# The browser terminal is the only client, so the usual interactive niceties
4+# (status bar, prefix gymnastics) are noise. What matters is that the pane
5+# behaves like a real terminal for a TUI and that scrollback is deep enough to
6+# replay on reconnect.
7+
8+# No status bar: the web UI already shows session state around the terminal,
9+# and a status line would eat a row and confuse `capture-pane` replay.
10+set -g status off
11+
12+# Mouse reporting through to the application, so a TUI's click targets and
13+# scroll work in the browser.
14+set -g mouse on
15+
16+# Deep scrollback — `capture-pane -S -` replays this on reconnect, and an agent
17+# session produces a lot of output before anyone looks at it.
18+set -g history-limit 50000
19+
20+# 256-colour + true-colour advertisement. wterm's core resolves 24-bit SGR, so
21+# there is no reason to let tmux downsample.
22+set -g default-terminal "tmux-256color"
23+set -ga terminal-overrides ",*256col*:Tc"
24+
25+# Keep the pane after its command exits, so the browser can still read the last
26+# screen; the entrypoint decides when the container is actually done.
27+set -g remain-on-exit on
28+
29+# Do not renumber or rename out from under the supervisor, which addresses the
30+# session by name.
31+set -g allow-rename off
32+
33+# With several browsers attached, size the window to the most recently active
34+# client rather than the smallest. The default ("smallest") means one phone
35+# viewer squeezes everyone else's terminal down to its width for as long as it
36+# stays connected.
37+set -g window-size latest
addeddocs/agent-sessions.md+149 −0
1+# Agent sessions
2+
3+An **agent session** is a long-lived container running tmux plus an agent CLI
4+(Claude Code) against one repository, attached from a terminal in the browser.
5+
6+It is off by default. Turn it on with `agent.enabled` in `anvil.toml`, and read
7+[untrusted-mode.md](untrusted-mode.md) §7 first — a session is a genuinely
8+different exposure from CI.
9+
10+```
11+ browser (wterm) <--websocket--> anvil-web
12+ |
13+ docker exec (tty)
14+ v
15+ supervisor ---- tail -F ----> [ container: tmux -> claude ]
16+ | |
17+ '--> transcript on disk pipe-pane
18+```
19+
20+## Why tmux
21+
22+With a long-lived container you could just `docker exec -it` a shell. tmux earns
23+its place for four reasons, in order of importance:
24+
25+1. **A dropped websocket must not kill the agent.** An exec session is tied to
26+ its client. The agent is a process *inside* tmux rather than a child of the
27+ exec, so closing the tab detaches a client and nothing more. This is the
28+ decisive one.
29+2. **`tmux pipe-pane`** gives a byte-exact transcript that keeps recording
30+ whether or not anyone is watching — the storage story for autonomous runs.
31+3. **tmux is already the multiplexer.** Several browsers attach to the same
32+ session and tmux repaints each one, so reconnect shows the current screen
33+ rather than a blank one. anvil needs no fan-out channel of its own.
34+4. Multiple windows (agent / shell / `git log`) without more containers.
35+
36+(2) and (3) are the two hard problems in a web terminal, and tmux solves both.
37+
38+## The shared runner image
39+
40+`deploy/runner/Dockerfile` builds **one** image used by both CI jobs and agent
41+sessions, so a session can reproduce a build by hand and there is one thing to
42+keep current. It carries tmux, git, fish, ripgrep and Claude Code (installed
43+from Anthropic's signed apt repository, with the release key's fingerprint
44+pinned in the Dockerfile).
45+
46+```
47+./deploy/runner/build.sh # anvil-runner:latest and :rust
48+./deploy/runner/build.sh latest # just the slim one
49+```
50+
51+Two tags from one recipe, differing only in base:
52+
53+| Tag | Base | For |
54+| -------------------- | --------------------- | -------------------------- |
55+| `anvil-runner:latest`| `debian:bookworm-slim`| the default, general work |
56+| `anvil-runner:rust` | `rust:1.95-bookworm` | pipelines needing the toolchain |
57+
58+**There is no registry behind this image.** It is built straight into the
59+Docker daemon's local store, so it must be built on whichever host owns the
60+socket anvil talks to. Because of that, `anvil_ci::docker::ensure_image` treats
61+a failed pull as non-fatal when the image is already present locally — an
62+unconditional pull, which is what CI used to do, fails for exactly this image.
63+
64+A pipeline may still name any image it likes; `image:` in `.anvil/ci.yml` is now
65+simply optional, and omitting it selects `ci.default_image`. The default image
66+is always permitted regardless of `ci.allowed_images`, since a pipeline that
67+omits `image:` never named anything for an operator to allow.
68+
69+## Lifecycle
70+
71+- **Start** (`POST /{owner}/{repo}/-/agent`, owner-only) creates the row, then
72+ creates, seeds and starts the container. The workspace is the commit's files
73+ uploaded as a tar, exactly as CI seeds a job — no `.git` yet, so no push
74+ credential exists to leak.
75+- **Credentials** are uploaded the same way: `agent.credentials_dir` on the host
76+ (a `~/.claude`) is tarred into the container's home. Never bind-mounted, so
77+ the no-mounts invariant in the threat model still holds. Note the corollary —
78+ a token the CLI refreshes *inside* the container is refreshed on a copy, and
79+ is lost when the session ends.
80+- **Attach** (`GET …/-/agent/{id}/ws`) opens a `docker exec … tmux attach` per
81+ browser and bridges it to the websocket.
82+- **Autonomous** sessions are the same interactive agent with the opening prompt
83+ typed at it via `tmux send-keys`, rather than a separate headless mode. A
84+ human can take over mid-run just by attaching — there is nothing to bail out
85+ of.
86+- **End**: the container exits when its tmux session does, and the entrypoint
87+ propagates the agent's exit code, so `docker wait` is a valid completion
88+ signal. The sweep also ends sessions past `agent.idle_timeout_secs` (with no
89+ viewer attached *and* no output) or `agent.max_lifetime_secs`.
90+- **Restart**: containers outlive anvil, and the live-session registry is
91+ in-memory. Startup therefore reaps every container labelled `anvil.session`
92+ and marks every row that still claims to be live. A terminal session cannot
93+ be resumed the way `ci::requeue_interrupted` re-queues a job, so it ends.
94+
95+## The websocket protocol
96+
97+wterm ships a `WebSocketTransport`, and anvil deliberately does not use it: it
98+is a raw byte pass-through with no control channel — no way to carry a resize —
99+and a blind reconnect that would reattach without a repaint. The protocol here
100+is a few lines instead:
101+
102+| Frame | Direction | Meaning |
103+| ------ | --------- | ---------------------------------------------- |
104+| binary | both | raw terminal bytes |
105+| text | browser→ | `{"t":"resize","cols":N,"rows":M}` → `resize_exec` |
106+| text | →browser | `{"t":"exit","code":N}` / `{"t":"error","message":…}` |
107+
108+## The terminal
109+
110+[wterm](https://wterm.dev) (Apache-2.0), vendored as published ESM under
111+`crates/anvil-web/assets/wterm/` and embedded in the binary exactly as htmx is.
112+**No bundler**: the tree has a single bare specifier (`@wterm/core`), which the
113+page resolves with a three-line import map. The built-in core's WASM is inlined
114+as base64, so there is no second request and no `import.meta.url` resolution to
115+get wrong.
116+
117+It is DOM-first, which is why it was chosen over xterm.js: real browser text
118+selection and find work on an agent transcript. The built-in core handles the
119+alternate screen buffer, mouse tracking, bracketed paste, synchronized output
120+and OSC 8 links, which is the surface a Claude Code TUI uses. If a TUI ever
121+glitches, `@wterm/ghostty` (libghostty's VT parser, ~400K) is a documented
122+drop-in: `new WTerm(el, { core })`.
123+
124+The session page tears the socket and the WASM instance down on
125+`htmx:beforeSwap`. The layout sets `hx-boost` on `<body>`, so without that
126+teardown both would leak on every navigation.
127+
128+## Configuration
129+
130+See the `[agent]` block in `anvil.example.toml`. The knobs that matter:
131+`enabled`, `credentials_dir`, `max_concurrent` (each session holds a container
132+open, so this is the real resource bound), `idle_timeout_secs` and
133+`max_lifetime_secs`.
134+
135+`memory_mb` defaults to 4096 rather than CI's 2048 because Claude Code asks for
136+4 GB. Unlike CI there is no network opt-out: a session cannot work without
137+reaching the model API.
138+
139+## Not yet done
140+
141+- **A real checkout.** M1 seeds files only. Giving the container a clone with
142+ history and a working remote needs a push credential, which does not exist
143+ today (tokens are read-only, and Bearer is accepted only on GET/HEAD).
144+- **Ref scoping for that credential.** Restricting a session to write only
145+ `refs/heads/agent/*` needs a ref filter in receive-pack. Until it exists, any
146+ push credential handed to a session could write `main`.
147+- **Trigger surfaces** from a TODO item, an issue, or a red CI run.
148+- **Rate limiting.** Nothing stops automated pushes from queueing sessions
149+ once triggers land; `max_concurrent` bounds concurrency, not churn.
modifieddocs/untrusted-mode.md+47 −1
⋯ 7 unchanged lines
88 items land.
99
1010 **Supported stance:** single-tenant / owner-operated. Untrusted multi-tenancy
11-is *not* supported until at least items 1–4 below are closed.
11+is *not* supported until at least items 1–4 below are closed. Agent sessions
12+(§7) raise the bar further and are off by default.
1213
1314 ---
1415
⋯ 87 unchanged lines
102103 block private/link-local/metadata ranges (and re-check on redirect), pin DNS,
103104 cap response sizes and time, and never reflect response bodies to users.
104105
106+## 7. Agent sessions: a model reading repo content as instructions
107+
108+Off by default (`agent.enabled`), and it should stay off unless you trust
109+everyone who can push. See [agent-sessions.md](agent-sessions.md) for the
110+design.
111+
112+This is **not** a variant of §1. CI runs code the pusher *wrote*: hostile, but
113+authored. An agent session runs a model that reads repository content, file
114+names, issue text and TODO items and may treat any of it as instruction. A
115+prompt injected into a README is therefore a path to arbitrary tool use inside
116+the session — and the session has network access it cannot do without.
117+
118+**What carries over from §1.** The container is created through the same broker:
119+no Docker socket, no bind mounts, no volumes, `--cap-drop=ALL`,
120+`no-new-privileges`, pids/memory/cpu caps. Sessions additionally run as an
121+unprivileged user (`agent`, uid 1000) rather than root, which CI does not. The
122+workspace and the agent's credentials both arrive as tar uploads through the
123+Docker API, so nothing on anvil's filesystem is reachable.
124+
125+**What is new.**
126+
127+- **Network is mandatory.** `ci.network = false` has no counterpart here: the
128+ session must reach the model API. Egress filtering does not exist, so an
129+ injected instruction can exfiltrate anything in the workspace.
130+- **`--dangerously-skip-permissions` is passed deliberately.** The container is
131+ the security boundary; a permission prompt inside a container the agent
132+ already owns end-to-end buys nothing. The consequence is that containment is
133+ entirely the container's job — there is no second line of defence inside it.
134+- **Credentials sit in the container.** `agent.credentials_dir` is copied into
135+ every session, so any code the agent runs — including code the repository
136+ supplied — can read the model credentials. Scope that account accordingly.
137+- **A push credential is coming and is not yet scoped.** M1 seeds files only and
138+ hands out no credential at all. When the container gets a real clone it will
139+ need one, and restricting it to `refs/heads/agent/*` requires a ref filter in
140+ receive-pack that does not exist. Until it does, a session credential could
141+ write any branch, `main` included.
142+- **No rate limiting.** `agent.max_concurrent` bounds how many run at once, not
143+ how many are started. Once push/issue triggers land, automated pushes could
144+ queue sessions indefinitely; restricting who can push is the only control
145+ today.
146+
147+**Before untrusted use:** all of §§1–4, plus per-user session quotas, egress
148+filtering or an allowlisted proxy, the ref-scoped push credential, and a
149+credential per repository rather than one instance-wide.
150+
105151 ---
106152
107153 ## Already right (keep it that way)
⋯ 10 unchanged lines