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 | |||
| 120 | 120 | ] | |
| 121 | 121 | ||
| 122 | 122 | [[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]] | |
| 123 | 138 | name = "anvil-ci" | |
| 124 | 139 | version = "0.0.0" | |
| 125 | 140 | dependencies = [ | |
| ⋯ 14 unchanged lines | |||
| 140 | 155 | name = "anvil-cli" | |
| 141 | 156 | version = "0.0.0" | |
| 142 | 157 | dependencies = [ | |
| 158 | + | "anvil-agent", | |
| 143 | 159 | "anvil-ci", | |
| 144 | 160 | "anvil-core", | |
| 145 | 161 | "anvil-ssh", | |
| ⋯ 70 unchanged lines | |||
| 216 | 232 | name = "anvil-web" | |
| 217 | 233 | version = "0.0.0" | |
| 218 | 234 | dependencies = [ | |
| 235 | + | "anvil-agent", | |
| 219 | 236 | "anvil-core", | |
| 220 | 237 | "anvil-git", | |
| 221 | 238 | "argon2 0.5.3", | |
| 222 | 239 | "axum", | |
| 223 | 240 | "axum-extra", | |
| 224 | 241 | "base64", | |
| 242 | + | "futures-util", | |
| 225 | 243 | "lru", | |
| 226 | 244 | "maud", | |
| 227 | 245 | "pulldown-cmark", | |
| ⋯ 91 unchanged lines | |||
| 319 | 337 | checksum = "31b698c5f9a010f6573133b09e0de5408834d0c82f8d7475a89fc1867a71cd90" | |
| 320 | 338 | dependencies = [ | |
| 321 | 339 | "axum-core", | |
| 340 | + | "base64", | |
| 322 | 341 | "bytes", | |
| 323 | 342 | "form_urlencoded", | |
| 324 | 343 | "futures-util", | |
| ⋯ 12 unchanged lines | |||
| 337 | 356 | "serde_json", | |
| 338 | 357 | "serde_path_to_error", | |
| 339 | 358 | "serde_urlencoded", | |
| 359 | + | "sha1 0.10.6", | |
| 340 | 360 | "sync_wrapper", | |
| 341 | 361 | "tokio", | |
| 362 | + | "tokio-tungstenite", | |
| 342 | 363 | "tower", | |
| 343 | 364 | "tower-layer", | |
| 344 | 365 | "tower-service", | |
| ⋯ 1020 unchanged lines | |||
| 1365 | 1386 | ||
| 1366 | 1387 | [[package]] | |
| 1367 | 1388 | 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" | |
| 1368 | 1401 | version = "0.4.2" | |
| 1369 | 1402 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 1370 | 1403 | checksum = "0de51e6874e94e7bf76d726fc5d13ba782deca734ff60d5bb2fb2607c7406555" | |
| ⋯ 1 unchanged line | |||
| 1372 | 1405 | "cfg-if", | |
| 1373 | 1406 | "js-sys", | |
| 1374 | 1407 | "libc", | |
| 1375 | - | "r-efi", | |
| 1408 | + | "r-efi 6.0.0", | |
| 1376 | 1409 | "rand_core 0.10.1", | |
| 1377 | 1410 | "wasip2", | |
| 1378 | 1411 | "wasip3", | |
| ⋯ 2089 unchanged lines | |||
| 3468 | 3501 | ||
| 3469 | 3502 | [[package]] | |
| 3470 | 3503 | 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" | |
| 3471 | 3510 | version = "6.0.0" | |
| 3472 | 3511 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3473 | 3512 | checksum = "f8dcc9c7d52a811697d2151c701e0d08956f92b0e24136cf4cf27b57a6a0d9bf" | |
| ⋯ 4 unchanged lines | |||
| 3478 | 3517 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3479 | 3518 | checksum = "22f6172bdec972074665ed81ed53b71da00bfc44b65a753cfde883ec4c702a1a" | |
| 3480 | 3519 | dependencies = [ | |
| 3481 | - | "rand_chacha", | |
| 3520 | + | "rand_chacha 0.3.1", | |
| 3482 | 3521 | "rand_core 0.6.4", | |
| 3483 | 3522 | ] | |
| 3484 | 3523 | ||
| 3485 | 3524 | [[package]] | |
| 3486 | 3525 | 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" | |
| 3487 | 3536 | version = "0.10.1" | |
| 3488 | 3537 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3489 | 3538 | checksum = "d2e8e8bcc7961af1fdac401278c6a831614941f6164ee3bf4ce61b7edb162207" | |
| ⋯ 14 unchanged lines | |||
| 3504 | 3553 | ] | |
| 3505 | 3554 | ||
| 3506 | 3555 | [[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]] | |
| 3507 | 3566 | name = "rand_core" | |
| 3508 | 3567 | version = "0.6.4" | |
| 3509 | 3568 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 4 unchanged lines | |||
| 3514 | 3573 | ||
| 3515 | 3574 | [[package]] | |
| 3516 | 3575 | 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" | |
| 3517 | 3585 | version = "0.10.1" | |
| 3518 | 3586 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| 3519 | 3587 | checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" | |
| ⋯ 1185 unchanged lines | |||
| 4705 | 4773 | ] | |
| 4706 | 4774 | ||
| 4707 | 4775 | [[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]] | |
| 4708 | 4788 | name = "tokio-util" | |
| 4709 | 4789 | version = "0.7.18" | |
| 4710 | 4790 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 172 unchanged lines | |||
| 4883 | 4963 | checksum = "e421abadd41a4225275504ea4d6566923418b7f05506fbc9c0fe86ba7396114b" | |
| 4884 | 4964 | ||
| 4885 | 4965 | [[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]] | |
| 4886 | 4982 | name = "typenum" | |
| 4887 | 4983 | version = "1.20.1" | |
| 4888 | 4984 | source = "registry+https://github.com/rust-lang/crates.io-index" | |
| ⋯ 736 unchanged lines | |||
modifiedCargo.toml+2 −1
| ⋯ 15 unchanged lines | |||
| 16 | 16 | # Internal crates | |
| 17 | 17 | anvil-core = { path = "crates/anvil-core" } | |
| 18 | 18 | anvil-ci = { path = "crates/anvil-ci" } | |
| 19 | + | anvil-agent = { path = "crates/anvil-agent" } | |
| 19 | 20 | anvil-git = { path = "crates/anvil-git" } | |
| 20 | 21 | anvil-web = { path = "crates/anvil-web" } | |
| 21 | 22 | anvil-ssh = { path = "crates/anvil-ssh" } | |
| ⋯ 12 unchanged lines | |||
| 34 | 35 | aes-gcm = "=0.11.0-rc.4" | |
| 35 | 36 | curve25519-dalek = "=5.0.0-rc.0" | |
| 36 | 37 | async-trait = "0.1" | |
| 37 | - | axum = "0.8" | |
| 38 | + | axum = { version = "0.8", features = ["ws"] } | |
| 38 | 39 | axum-extra = { version = "0.10", features = ["cookie"] } | |
| 39 | 40 | base64 = "0.22" | |
| 40 | 41 | bollard = "0.18" | |
| ⋯ 63 unchanged lines | |||
modifiedanvil.example.toml+37 −0
| ⋯ 64 unchanged lines | |||
| 65 | 65 | network = true | |
| 66 | 66 | # User inside the job container, e.g. "1000:1000". Empty keeps the image default. | |
| 67 | 67 | 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 | |
| 68 | 105 | ||
| 69 | 106 | # Artifact caps (MiB, 0 = unlimited): one artifact / one run's total / the | |
| 70 | 107 | # 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 | |||
| 14 | 14 | //! pids/memory/cpu caps plus a wall-clock timeout and an optional image | |
| 15 | 15 | //! allowlist ([`anvil_core::config::CiConfig`]). | |
| 16 | 16 | ||
| 17 | + | pub mod docker; | |
| 18 | + | ||
| 17 | 19 | use std::{ | |
| 18 | 20 | collections::BTreeMap, | |
| 19 | 21 | io::Read, | |
| ⋯ 28 unchanged lines | |||
| 48 | 50 | UploadToContainerOptions, | |
| 49 | 51 | WaitContainerOptions, | |
| 50 | 52 | }, | |
| 51 | - | image::CreateImageOptions, | |
| 52 | 53 | models::HostConfig, | |
| 53 | 54 | }; | |
| 54 | 55 | use futures_util::StreamExt; | |
| ⋯ 74 unchanged lines | |||
| 129 | 130 | let short = &run.commit[..run.commit.len().min(12)]; | |
| 130 | 131 | let mut log = format!( | |
| 131 | 132 | "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) | |
| 133 | 137 | ); | |
| 134 | 138 | ||
| 135 | 139 | // Artifacts are collected into a scratch directory next to their final | |
| ⋯ 252 unchanged lines | |||
| 388 | 392 | scratch: &Path, | |
| 389 | 393 | env: &[(String, String)], | |
| 390 | 394 | ) -> 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) { | |
| 392 | 399 | 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" | |
| 395 | 401 | )); | |
| 396 | 402 | } | |
| 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?; | |
| 399 | 405 | ||
| 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 | - | ||
| 418 | 406 | // Build a single `set -e` script from the steps. The steps run in a | |
| 419 | 407 | // subshell so the meta-extractor trailer still runs (and the original | |
| 420 | 408 | // exit code is preserved) when a step fails — failure artifacts like test | |
| ⋯ 35 unchanged lines | |||
| 456 | 444 | ..Default::default() | |
| 457 | 445 | }; | |
| 458 | 446 | let config = Config { | |
| 459 | - | image: Some(pipeline.image.clone()), | |
| 447 | + | image: Some(image.to_string()), | |
| 460 | 448 | cmd: Some(vec!["sh".to_string(), "-c".to_string(), script]), | |
| 461 | 449 | env: (!env.is_empty()).then(|| env.iter().map(|(k, v)| format!("{k}={v}")).collect()), | |
| 462 | 450 | working_dir: Some(WORKDIR.to_string()), | |
| ⋯ 425 unchanged lines | |||
modifiedcrates/anvil-cli/Cargo.toml+1 −0
| ⋯ 15 unchanged lines | |||
| 16 | 16 | anvil-core.workspace = true | |
| 17 | 17 | anvil-web.workspace = true | |
| 18 | 18 | anvil-ssh.workspace = true | |
| 19 | + | anvil-agent.workspace = true | |
| 19 | 20 | anvil-ci.workspace = true | |
| 20 | 21 | tokio.workspace = true | |
| 21 | 22 | clap.workspace = true | |
| ⋯ 12 unchanged lines | |||
modifiedcrates/anvil-cli/src/main.rs+17 −1
| ⋯ 163 unchanged lines | |||
| 164 | 164 | app.ci_tx = Some(ci_tx); | |
| 165 | 165 | tokio::spawn(anvil_ci::run_worker(app.clone(), ci_rx)); | |
| 166 | 166 | ||
| 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 | + | ||
| 167 | 174 | // Start periodic background jobs (language detection, preview images, disk usage cache). | |
| 168 | - | let periodic_jobs = vec![ | |
| 175 | + | let mut periodic_jobs = vec![ | |
| 169 | 176 | ( | |
| 170 | 177 | std::time::Duration::from_secs(app.config.periodic.language_detection_interval_secs), | |
| 171 | 178 | Box::new(anvil_core::periodic::LanguageDetectionJob) | |
| ⋯ 15 unchanged lines | |||
| 187 | 194 | as Box<dyn anvil_core::periodic::PeriodicJob>, | |
| 188 | 195 | ), | |
| 189 | 196 | ]; | |
| 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 | + | } | |
| 190 | 206 | anvil_core::periodic::spawn_runner(app.clone(), periodic_jobs).await; | |
| 191 | 207 | ||
| 192 | 208 | 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 | |||
| 30 | 30 | /// artifacts to collect afterwards. | |
| 31 | 31 | #[derive(Clone, Debug, Deserialize)] | |
| 32 | 32 | 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)] | |
| 34 | 39 | pub image: String, | |
| 35 | 40 | #[serde(default)] | |
| 36 | 41 | pub steps: Vec<Step>, | |
| ⋯ 471 unchanged lines | |||
modifiedcrates/anvil-core/src/config.rs+137 −2
| ⋯ 28 unchanged lines | |||
| 29 | 29 | pub ssh: SshConfig, | |
| 30 | 30 | /// Continuous-deployment settings (the single-repo redeploy webhook). | |
| 31 | 31 | 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, | |
| 32 | 35 | /// Periodic background job settings. | |
| 33 | 36 | pub periodic: PeriodicConfig, | |
| 34 | 37 | /// Single sign-on against an OpenID Connect provider. | |
| ⋯ 36 unchanged lines | |||
| 71 | 74 | pub sso_logout: bool, | |
| 72 | 75 | } | |
| 73 | 76 | ||
| 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 | + | ||
| 74 | 129 | /// CI configuration: job sandbox limits and the single-repo redeploy webhook. | |
| 75 | 130 | /// | |
| 76 | 131 | /// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the | |
| ⋯ 19 unchanged lines | |||
| 96 | 151 | /// Images a pipeline may run in. Empty allows any image. An entry without a | |
| 97 | 152 | /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag | |
| 98 | 153 | /// (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:`. | |
| 99 | 158 | 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, | |
| 100 | 165 | /// Memory cap for a job container, in MiB (swap is capped to the same | |
| 101 | 166 | /// value). `0` means unlimited. Defaults to 2048. | |
| 102 | 167 | pub memory_mb: i64, | |
| ⋯ 90 unchanged lines | |||
| 193 | 258 | http: HttpConfig::default(), | |
| 194 | 259 | ssh: SshConfig::default(), | |
| 195 | 260 | ci: CiConfig::default(), | |
| 261 | + | agent: AgentConfig::default(), | |
| 196 | 262 | periodic: PeriodicConfig::default(), | |
| 197 | 263 | oidc: OidcConfig::default(), | |
| 198 | 264 | } | |
| ⋯ 48 unchanged lines | |||
| 247 | 313 | } | |
| 248 | 314 | } | |
| 249 | 315 | ||
| 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 | + | ||
| 250 | 332 | impl Default for CiConfig { | |
| 251 | 333 | fn default() -> Self { | |
| 252 | 334 | Self { | |
| ⋯ 2 unchanged lines | |||
| 255 | 337 | deploy_secret: String::new(), | |
| 256 | 338 | deploy_branch: "main".to_string(), | |
| 257 | 339 | allowed_images: Vec::new(), | |
| 340 | + | default_image: DEFAULT_RUNNER_IMAGE.to_string(), | |
| 258 | 341 | memory_mb: 2048, | |
| 259 | 342 | cpus: 2.0, | |
| 260 | 343 | pids_limit: 512, | |
| ⋯ 16 unchanged lines | |||
| 277 | 360 | && self.deploy_branch == branch | |
| 278 | 361 | } | |
| 279 | 362 | ||
| 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 | + | ||
| 280 | 373 | /// Whether `image` passes [`allowed_images`](CiConfig::allowed_images). | |
| 281 | 374 | /// 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. | |
| 283 | 377 | 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() | |
| 285 | 380 | || self.allowed_images.iter().any(|allowed| { | |
| 286 | 381 | image == allowed | |
| 287 | 382 | || (!allowed.contains(':') | |
| ⋯ 131 unchanged lines | |||
| 419 | 514 | self.data_dir.join("artifacts") | |
| 420 | 515 | } | |
| 421 | 516 | ||
| 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 | + | ||
| 422 | 525 | /// Root directory under which uploaded attachments are stored | |
| 423 | 526 | /// (`attachments/{repo_id}/{hash}`). Kept out of `repositories/` so the | |
| 424 | 527 | /// files are never git objects. | |
| ⋯ 51 unchanged lines | |||
| 476 | 579 | } | |
| 477 | 580 | ||
| 478 | 581 | #[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] | |
| 479 | 614 | fn port_and_host_set_the_bind_address() { | |
| 480 | 615 | let mut config = Config::default(); | |
| 481 | 616 | config.apply_env(env_of(&[("PORT", "4738")])); | |
| ⋯ 69 unchanged lines | |||
modifiedcrates/anvil-core/src/db.rs+26 −1
| ⋯ 5 unchanged lines | |||
| 6 | 6 | error::Result, | |
| 7 | 7 | models::{ | |
| 8 | 8 | AdminCache, | |
| 9 | + | AgentSession, | |
| 9 | 10 | ApiToken, | |
| 10 | 11 | Attachment, | |
| 11 | 12 | CiArtifact, | |
| ⋯ 35 unchanged lines | |||
| 47 | 48 | Attachment, | |
| 48 | 49 | ApiToken, | |
| 49 | 50 | AdminCache, | |
| 50 | - | RepoSecret | |
| 51 | + | RepoSecret, | |
| 52 | + | AgentSession | |
| 51 | 53 | )) | |
| 52 | 54 | .connect(&url) | |
| 53 | 55 | .await?; | |
| ⋯ 26 unchanged lines | |||
| 80 | 82 | ADMIN_CACHE_DDL, | |
| 81 | 83 | REPO_SECRETS_DDL, | |
| 82 | 84 | 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")"#, | |
| 83 | 88 | ]; | |
| 84 | 89 | ||
| 85 | 90 | const CI_ARTIFACTS_DDL: &str = r#"CREATE TABLE IF NOT EXISTS "ci_artifacts" ( | |
| ⋯ 62 unchanged lines | |||
| 148 | 153 | "value" TEXT NOT NULL, | |
| 149 | 154 | "computed_at" BIGINT NOT NULL )"#; | |
| 150 | 155 | ||
| 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 | + | ||
| 151 | 175 | /// Columns added to existing tables after deployment, applied as | |
| 152 | 176 | /// `ALTER TABLE … ADD COLUMN` when missing (SQLite has no `IF NOT EXISTS` | |
| 153 | 177 | /// for columns, so presence is checked via `pragma_table_info`). The model | |
| ⋯ 66 unchanged lines | |||
| 220 | 244 | "api_tokens", | |
| 221 | 245 | "admin_caches", | |
| 222 | 246 | "repo_secrets", | |
| 247 | + | "agent_sessions", | |
| 223 | 248 | ]; | |
| 224 | 249 | ||
| 225 | 250 | /// Every schema object (table + indexes) for `table`, normalized. | |
| ⋯ 129 unchanged lines | |||
modifiedcrates/anvil-core/src/lib.rs+7 −0
| ⋯ 6 unchanged lines | |||
| 7 | 7 | ||
| 8 | 8 | pub mod access; | |
| 9 | 9 | pub mod admin_cache; | |
| 10 | + | pub mod agent; | |
| 10 | 11 | pub mod api_tokens; | |
| 11 | 12 | pub mod attachments; | |
| 12 | 13 | pub mod ci; | |
| ⋯ 19 unchanged lines | |||
| 32 | 33 | Result, | |
| 33 | 34 | }; | |
| 34 | 35 | pub use models::{ | |
| 36 | + | AgentSession, | |
| 35 | 37 | ApiToken, | |
| 36 | 38 | Attachment, | |
| 37 | 39 | CiArtifact, | |
| ⋯ 29 unchanged lines | |||
| 67 | 69 | /// Plaintext repo secrets for CI, held in memory only and lost on | |
| 68 | 70 | /// restart — see [`secrets::Vault`]. | |
| 69 | 71 | 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, | |
| 70 | 76 | /// Server-wide secret keying CSRF tokens. Persisted in the data dir so | |
| 71 | 77 | /// tokens survive restarts. Wrapped in `Arc` to keep `App: Clone` cheap. | |
| 72 | 78 | csrf_secret: std::sync::Arc<[u8; 32]>, | |
| ⋯ 14 unchanged lines | |||
| 87 | 93 | db, | |
| 88 | 94 | ci_tx: None, | |
| 89 | 95 | vault: secrets::Vault::default(), | |
| 96 | + | sessions: agent::Registry::default(), | |
| 90 | 97 | csrf_secret, | |
| 91 | 98 | }) | |
| 92 | 99 | } | |
| ⋯ 52 unchanged lines | |||
modifiedcrates/anvil-core/src/models.rs+49 −0
| ⋯ 108 unchanged lines | |||
| 109 | 109 | pub created_at: i64, | |
| 110 | 110 | } | |
| 111 | 111 | ||
| 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 | + | ||
| 112 | 161 | /// An issue on a repository. `number` is the user-facing per-repo sequence | |
| 113 | 162 | /// (`#1`, `#2`, …); `id` stays the global key. `state` is `open` or `closed`. | |
| 114 | 163 | /// | |
| ⋯ 148 unchanged lines | |||
modifiedcrates/anvil-core/src/storage.rs+8 −0
| ⋯ 32 unchanged lines | |||
| 33 | 33 | attachments_dir.join(repo_id.to_string()).join(hash) | |
| 34 | 34 | } | |
| 35 | 35 | ||
| 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 | + | ||
| 36 | 44 | /// Create a new bare repository on disk, returning the opened handle. | |
| 37 | 45 | /// | |
| 38 | 46 | /// `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 | |||
| 8 | 8 | description = "HTTP server for anvil: web UI and smart-HTTP git endpoints (axum)." | |
| 9 | 9 | ||
| 10 | 10 | [dependencies] | |
| 11 | + | anvil-agent.workspace = true | |
| 11 | 12 | anvil-core.workspace = true | |
| 12 | 13 | anvil-git.workspace = true | |
| 13 | 14 | axum.workspace = true | |
| 14 | 15 | axum-extra.workspace = true | |
| 16 | + | futures-util.workspace = true | |
| 15 | 17 | tokio.workspace = true | |
| 16 | 18 | tokio-util.workspace = true | |
| 17 | 19 | 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, "&") | |
| 89 | + | .replace(/</g, "<") | |
| 90 | + | .replace(/>/g, ">") | |
| 91 | + | .replace(/"/g, """); | |
| 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 | |||
| 21 | 21 | }; | |
| 22 | 22 | ||
| 23 | 23 | pub mod admin; | |
| 24 | + | pub mod agent; | |
| 24 | 25 | pub mod artifacts; | |
| 25 | 26 | pub mod attachments; | |
| 26 | 27 | pub mod auth; | |
| ⋯ 12 unchanged lines | |||
| 39 | 40 | .route("/-/logout", post(auth::logout)); | |
| 40 | 41 | router = ui::routes(router); // web UI, including `/` | |
| 41 | 42 | router = admin::routes(router); // admin-only dashboard | |
| 43 | + | router = agent::routes(router); // agent sessions + the browser terminal | |
| 42 | 44 | router = pages::routes(router); // static sites from `pages` branches | |
| 43 | 45 | router = artifacts::routes(router); // CI artifact downloads + sites | |
| 44 | 46 | router = attachments::routes( | |
| ⋯ 30 unchanged lines | |||
modifiedcrates/anvil-web/src/ui.rs+6 −0
| ⋯ 1186 unchanged lines | |||
| 1187 | 1187 | span.sep { "·" } | |
| 1188 | 1188 | a href=(format!("/{owner}/{repo}/pages")) { "Pages" } | |
| 1189 | 1189 | @if can_write { | |
| 1190 | + | // Agent sessions start containers and (from M2) push, so | |
| 1191 | + | // they are an owner action — hidden from readers entirely. | |
| 1192 | + | @if app.config.agent.enabled { | |
| 1193 | + | span.sep { "·" } | |
| 1194 | + | a href=(format!("/{owner}/{repo}/-/agent")) { "Agent" } | |
| 1195 | + | } | |
| 1190 | 1196 | span.sep { "·" } | |
| 1191 | 1197 | a href=(format!("/{owner}/{repo}/settings")) { "Settings" } | |
| 1192 | 1198 | } | |
| ⋯ 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 | |||
| 8 | 8 | items land. | |
| 9 | 9 | ||
| 10 | 10 | **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. | |
| 12 | 13 | ||
| 13 | 14 | --- | |
| 14 | 15 | ||
| ⋯ 87 unchanged lines | |||
| 102 | 103 | block private/link-local/metadata ranges (and re-check on redirect), pin DNS, | |
| 103 | 104 | cap response sizes and time, and never reflect response bodies to users. | |
| 104 | 105 | ||
| 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 | + | ||
| 105 | 151 | --- | |
| 106 | 152 | ||
| 107 | 153 | ## Already right (keep it that way) | |
| ⋯ 10 unchanged lines | |||