anvilsign in

collin/anvil

1//! Server configuration: loaded from a TOML file with sensible defaults.
2
3use std::path::{
4 Path,
5 PathBuf,
6};
7
8use serde::{
9 Deserialize,
10 Serialize,
11};
12
13use crate::error::{
14 Error,
15 Result,
16};
17
18/// Top-level anvil configuration.
19///
20/// Load with [`Config::load`] (from a TOML file) or [`Config::default`].
21#[derive(Clone, Debug, Deserialize, Serialize)]
22#[serde(default)]
23pub struct Config {
24 /// Root directory holding all server state (database + repositories).
25 pub data_dir: PathBuf,
26 /// HTTP server settings.
27 pub http: HttpConfig,
28 /// SSH server settings.
29 pub ssh: SshConfig,
30 /// Continuous-deployment settings (the single-repo redeploy webhook).
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,
35 /// Periodic background job settings.
36 pub periodic: PeriodicConfig,
37 /// Single sign-on against an OpenID Connect provider.
38 pub oidc: OidcConfig,
39}
40
41/// Path the provider redirects back to after an authorization. Registered at
42/// the provider as this app's redirect URI, and matched there character for
43/// character — see `docs/oidc.md`.
44pub const OIDC_CALLBACK_PATH: &str = "/-/oidc/callback";
45
46/// Sign-in delegated to an OpenID Connect provider (authorization code flow
47/// with PKCE). Off unless [`issuer`](OidcConfig::issuer) is set, so an
48/// unconfigured instance behaves exactly as it did before: local passwords
49/// only. When on, it is *additional* — existing accounts keep their passwords,
50/// and the two are reconciled on the `sub` claim.
51#[derive(Clone, Debug, Deserialize, Serialize)]
52#[serde(default)]
53pub struct OidcConfig {
54 /// Issuer URL, e.g. `https://login.richardscollin.com`. Empty disables
55 /// single sign-on entirely. Discovery, and the `iss` claim every id token
56 /// is checked against, both come from this.
57 pub issuer: String,
58 /// Client id registered at the provider. Defaults to `anvil`.
59 pub client_id: String,
60 /// Client secret. Empty for a client registered as public — PKCE protects
61 /// the code either way.
62 pub client_secret: String,
63 /// Overrides the redirect URI, which otherwise is
64 /// `base_url` + [`OIDC_CALLBACK_PATH`]. Must match the provider's
65 /// allowlist exactly.
66 pub redirect_uri: String,
67 /// What the sign-in button says, after `Sign in with `. Defaults to the
68 /// issuer's hostname.
69 pub label: String,
70 /// Whether signing out of anvil also ends the provider's session (an
71 /// RP-initiated logout). Needs a post-logout URI registered for this
72 /// client, or the provider drops the user on its own page instead of
73 /// bringing them back. Defaults to `true`.
74 pub sso_logout: bool,
75}
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.
82pub 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)]
93pub 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
129/// CI configuration: job sandbox limits and the single-repo redeploy webhook.
130///
131/// On a successful CI run of [`deploy_branch`](CiConfig::deploy_branch) in the
132/// single repository named by [`deploy_repo`](CiConfig::deploy_repo), anvil
133/// POSTs to [`deploy_webhook`](CiConfig::deploy_webhook). This is deliberately
134/// scoped to **one** repository — no other repo can trigger the deploy, even
135/// with its own passing CI.
136#[derive(Clone, Debug, Deserialize, Serialize)]
137#[serde(default)]
138pub struct CiConfig {
139 /// The one repository (`owner/name`) permitted to trigger the deploy
140 /// webhook. Empty disables deploys entirely.
141 pub deploy_repo: String,
142 /// URL POSTed to when `deploy_repo`'s `deploy_branch` goes green. Should be
143 /// a host-local plaintext HTTP endpoint (a small deploy-script receiver);
144 /// HTTPS is intentionally unsupported to keep the build TLS-free.
145 pub deploy_webhook: String,
146 /// Shared secret sent as the `X-Anvil-Deploy-Secret` header so the receiver
147 /// can authenticate the call. Empty sends no header.
148 pub deploy_secret: String,
149 /// Branch whose successful run triggers a deploy. Defaults to `main`.
150 pub deploy_branch: String,
151 /// Images a pipeline may run in. Empty allows any image. An entry without a
152 /// tag (e.g. `rust`) allows every tag of that image; an entry with a tag
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:`.
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,
165 /// Memory cap for a job container, in MiB (swap is capped to the same
166 /// value). `0` means unlimited. Defaults to 2048.
167 pub memory_mb: i64,
168 /// CPU cap for a job container, in (possibly fractional) CPUs. `0` means
169 /// unlimited. Defaults to 2.
170 pub cpus: f64,
171 /// Process-count cap inside a job container. `0` means unlimited.
172 /// Defaults to 512.
173 pub pids_limit: i64,
174 /// Wall-clock timeout for a job, in seconds; on expiry the container is
175 /// force-removed and the run errors. `0` disables the timeout. Defaults to
176 /// 1800 (30 minutes).
177 pub timeout_secs: u64,
178 /// Whether job containers get network access (the default Docker network).
179 /// Most builds need it to fetch dependencies; disable for stricter
180 /// isolation. Defaults to `true`.
181 pub network: bool,
182 /// User to run the job as inside the container (`uid[:gid]` or a name known
183 /// to the image). Empty keeps the image's default user. Note many base
184 /// images assume root for e.g. `apt-get`.
185 pub run_as: String,
186 /// Size cap for a single artifact, in MiB; larger artifacts are skipped
187 /// (with a log note), never failing the run. `0` means unlimited.
188 /// Defaults to 256.
189 pub artifact_max_mb: i64,
190 /// Combined size cap for one run's artifacts, in MiB. Artifacts that would
191 /// push the run over it are skipped. `0` means unlimited. Defaults to 512.
192 pub artifact_run_max_mb: i64,
193 /// Combined artifact budget per repository, in MiB. After each run, oldest
194 /// commits' artifacts are deleted until the repo fits (branch-head commits
195 /// are pinned). `0` means unlimited. Defaults to 4096.
196 pub artifact_quota_mb: i64,
197}
198
199#[derive(Clone, Debug, Deserialize, Serialize)]
200#[serde(default)]
201pub struct HttpConfig {
202 /// Address the HTTP server binds to, e.g. `127.0.0.1:3000`.
203 pub listen: String,
204 /// Externally visible base URL, used when constructing clone URLs.
205 pub base_url: String,
206 /// Memory budget, in MiB, for the cache of syntax-highlighted file views
207 /// (rendered HTML keyed by blob oid). Highlighting large files is the most
208 /// CPU-expensive page render, so repeat views are served from this cache.
209 /// `0` disables it — lowest memory, every view re-highlights. Defaults
210 /// to 16.
211 pub highlight_cache_mb: usize,
212 /// Maximum size, in MiB, of a single uploaded attachment (e.g. an image
213 /// pasted into the file editor). Uploads over this are rejected. Defaults
214 /// to 16.
215 pub attachment_max_mb: usize,
216 /// Per-repository cap, in MiB, on total stored attachments. A new upload
217 /// that would push a repo over this is rejected (re-uploading existing,
218 /// deduped content is always free). `0` means unlimited. Defaults to 0.
219 pub attachment_quota_mb: usize,
220}
221
222#[derive(Clone, Debug, Deserialize, Serialize)]
223#[serde(default)]
224pub struct SshConfig {
225 /// Whether the SSH git transport is enabled.
226 pub enabled: bool,
227 /// Address the SSH server binds to internally, e.g. `0.0.0.0:2222`. Under
228 /// Docker this is the in-container bind, which may differ from the
229 /// externally forwarded port — see the `clone_*` fields below.
230 pub listen: String,
231 /// Hostname shown in SSH clone URLs (what users actually connect to).
232 pub clone_host: String,
233 /// Port shown in SSH clone URLs. Set this to the *externally forwarded*
234 /// port when it differs from the internal bind (e.g. Docker `-p 2200:2222`).
235 pub clone_port: u16,
236 /// Username shown in SSH clone URLs (conventionally `git`).
237 pub clone_user: String,
238}
239
240#[derive(Clone, Debug, Deserialize, Serialize)]
241#[serde(default)]
242pub struct PeriodicConfig {
243 /// Interval (seconds) between repository language-detection scans.
244 /// Defaults to 3600 (1 hour).
245 pub language_detection_interval_secs: u64,
246 /// Interval (seconds) between repository preview-image extractions from README.
247 /// Defaults to 3600 (1 hour).
248 pub preview_image_interval_secs: u64,
249 /// Interval (seconds) between disk-usage cache refreshes.
250 /// Defaults to 3600 (1 hour).
251 pub disk_usage_interval_secs: u64,
252}
253
254impl Default for Config {
255 fn default() -> Self {
256 Self {
257 data_dir: PathBuf::from("data"),
258 http: HttpConfig::default(),
259 ssh: SshConfig::default(),
260 ci: CiConfig::default(),
261 agent: AgentConfig::default(),
262 periodic: PeriodicConfig::default(),
263 oidc: OidcConfig::default(),
264 }
265 }
266}
267
268impl Default for OidcConfig {
269 fn default() -> Self {
270 Self {
271 issuer: String::new(),
272 client_id: "anvil".to_string(),
273 client_secret: String::new(),
274 redirect_uri: String::new(),
275 label: String::new(),
276 sso_logout: true,
277 }
278 }
279}
280
281impl OidcConfig {
282 /// Whether single sign-on is configured at all.
283 pub fn enabled(&self) -> bool {
284 !self.issuer.is_empty()
285 }
286
287 /// The issuer with any trailing slashes removed — the exact string the
288 /// `iss` claim must equal, and the prefix every endpoint is built from.
289 pub fn issuer(&self) -> &str {
290 self.issuer.trim_end_matches('/')
291 }
292
293 /// Text for the sign-in button, after `Sign in with `. The configured
294 /// label wins; otherwise the issuer's host, with a leading `login.` peeled
295 /// off when a domain is left over — `login.richardscollin.com` reads
296 /// better as `richardscollin.com`, while `login.localhost` must keep its
297 /// prefix or it would collapse to a bare `localhost`.
298 pub fn label(&self) -> String {
299 if !self.label.is_empty() {
300 return self.label.clone();
301 }
302 let host = self
303 .issuer()
304 .split_once("://")
305 .map_or(self.issuer(), |(_, rest)| rest)
306 .split(['/', ':'])
307 .next()
308 .unwrap_or_default();
309 match host.strip_prefix("login.") {
310 Some(domain) if domain.contains('.') => domain.to_string(),
311 _ => host.to_string(),
312 }
313 }
314}
315
316impl 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
332impl Default for CiConfig {
333 fn default() -> Self {
334 Self {
335 deploy_repo: String::new(),
336 deploy_webhook: String::new(),
337 deploy_secret: String::new(),
338 deploy_branch: "main".to_string(),
339 allowed_images: Vec::new(),
340 default_image: DEFAULT_RUNNER_IMAGE.to_string(),
341 memory_mb: 2048,
342 cpus: 2.0,
343 pids_limit: 512,
344 timeout_secs: 1800,
345 network: true,
346 run_as: String::new(),
347 artifact_max_mb: 256,
348 artifact_run_max_mb: 512,
349 artifact_quota_mb: 4096,
350 }
351 }
352}
353
354impl CiConfig {
355 /// Whether `owner/name` on `branch` is the configured deploy target.
356 pub fn is_deploy_target(&self, owner: &str, name: &str, branch: &str) -> bool {
357 !self.deploy_repo.is_empty()
358 && !self.deploy_webhook.is_empty()
359 && self.deploy_repo == format!("{owner}/{name}")
360 && self.deploy_branch == branch
361 }
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
373 /// Whether `image` passes [`allowed_images`](CiConfig::allowed_images).
374 /// An empty allowlist permits any image; a tagless entry permits every tag
375 /// of that image; a tagged entry permits exactly itself. The default image
376 /// is always permitted.
377 pub fn image_allowed(&self, image: &str) -> bool {
378 image == self.default_image
379 || self.allowed_images.is_empty()
380 || self.allowed_images.iter().any(|allowed| {
381 image == allowed
382 || (!allowed.contains(':')
383 && image
384 .strip_prefix(allowed.as_str())
385 .is_some_and(|rest| rest.starts_with(':')))
386 })
387 }
388}
389
390impl Default for HttpConfig {
391 fn default() -> Self {
392 Self {
393 listen: "127.0.0.1:3000".to_string(),
394 base_url: "http://localhost:3000".to_string(),
395 highlight_cache_mb: 16,
396 attachment_max_mb: 16,
397 attachment_quota_mb: 0,
398 }
399 }
400}
401
402impl Default for SshConfig {
403 fn default() -> Self {
404 Self {
405 enabled: false,
406 listen: "127.0.0.1:2222".to_string(),
407 clone_host: "localhost".to_string(),
408 clone_port: 2222,
409 clone_user: "git".to_string(),
410 }
411 }
412}
413
414impl Default for PeriodicConfig {
415 fn default() -> Self {
416 Self {
417 language_detection_interval_secs: 3600,
418 preview_image_interval_secs: 3600,
419 disk_usage_interval_secs: 3600,
420 }
421 }
422}
423
424impl Config {
425 /// Load configuration from a TOML file. Missing fields fall back to defaults.
426 pub fn load(path: impl AsRef<Path>) -> Result<Self> {
427 let path = path.as_ref();
428 let text = std::fs::read_to_string(path)
429 .map_err(|e| Error::Config(format!("reading {}: {e}", path.display())))?;
430 toml::from_str(&text).map_err(|e| Error::Config(format!("parsing {}: {e}", path.display())))
431 }
432
433 /// Load from `path` if it exists, otherwise return defaults. Environment
434 /// overrides are applied either way — see [`Config::apply_env`].
435 pub fn load_or_default(path: impl AsRef<Path>) -> Result<Self> {
436 let path = path.as_ref();
437 let mut config = if path.exists() {
438 Self::load(path)?
439 } else {
440 Self::default()
441 };
442 config.apply_env(|key| std::env::var(key).ok());
443 Ok(config)
444 }
445
446 /// Overlay environment variables onto a loaded config, so a supervisor can
447 /// place anvil wherever it likes without a config file.
448 ///
449 /// - `ANVIL_LISTEN`, or `HOST`/`PORT` — the bind address. `PORT` (with
450 /// `HOST` defaulting to `127.0.0.1`) is the convention process managers
451 /// and local proxies use; portless, for one, hands the app a free port in
452 /// 4000-4999 and reverse-proxies a `.localhost` name to it.
453 /// - `ANVIL_BASE_URL`, or `PORTLESS_URL` — the externally visible URL that
454 /// clone commands and links are built from. Getting this right is what
455 /// makes the UI usable behind a proxy: the bind port is an implementation
456 /// detail, `https://anvil.localhost` is the address users see.
457 ///
458 /// Explicit `ANVIL_*` wins over the generic name, and both win over the
459 /// file, on the usual "closest to the invocation" principle.
460 pub fn apply_env(&mut self, env: impl Fn(&str) -> Option<String>) {
461 if let Some(listen) = env("ANVIL_LISTEN") {
462 self.http.listen = listen;
463 } else if let Some(port) = env("PORT").filter(|p| p.parse::<u16>().is_ok()) {
464 let host = env("HOST").unwrap_or_else(|| "127.0.0.1".to_string());
465 self.http.listen = format!("{host}:{port}");
466 }
467 if let Some(base) = env("ANVIL_BASE_URL").or_else(|| env("PORTLESS_URL")) {
468 self.http.base_url = base.trim_end_matches('/').to_string();
469 }
470 if let Some(dir) = env("ANVIL_DATA_DIR") {
471 self.data_dir = dir.into();
472 }
473 // Single sign-on. The secret especially wants an env var: config files
474 // get committed, and this one must not be.
475 if let Some(issuer) = env("ANVIL_OIDC_ISSUER") {
476 self.oidc.issuer = issuer.trim().trim_end_matches('/').to_string();
477 }
478 if let Some(id) = env("ANVIL_OIDC_CLIENT_ID") {
479 self.oidc.client_id = id;
480 }
481 if let Some(secret) = env("ANVIL_OIDC_CLIENT_SECRET") {
482 self.oidc.client_secret = secret;
483 }
484 if let Some(uri) = env("ANVIL_OIDC_REDIRECT_URI") {
485 self.oidc.redirect_uri = uri;
486 }
487 }
488
489 /// The redirect URI handed to the provider: the configured override, or
490 /// [`OIDC_CALLBACK_PATH`] on the public base URL.
491 pub fn oidc_redirect_uri(&self) -> String {
492 if !self.oidc.redirect_uri.is_empty() {
493 return self.oidc.redirect_uri.clone();
494 }
495 format!(
496 "{}{OIDC_CALLBACK_PATH}",
497 self.http.base_url.trim_end_matches('/')
498 )
499 }
500
501 /// Filesystem path to the SQLite database file.
502 pub fn database_path(&self) -> PathBuf {
503 self.data_dir.join("anvil.db")
504 }
505
506 /// Root directory under which bare repositories are stored.
507 pub fn repositories_dir(&self) -> PathBuf {
508 self.data_dir.join("repositories")
509 }
510
511 /// Root directory under which CI artifacts are stored
512 /// (`artifacts/{repo_id}/{commit}/…` — see `docs/ci-artifacts.md`).
513 pub fn artifacts_dir(&self) -> PathBuf {
514 self.data_dir.join("artifacts")
515 }
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
525 /// Root directory under which uploaded attachments are stored
526 /// (`attachments/{repo_id}/{hash}`). Kept out of `repositories/` so the
527 /// files are never git objects.
528 pub fn attachments_dir(&self) -> PathBuf {
529 self.data_dir.join("attachments")
530 }
531
532 /// Whether session cookies should carry the `Secure` attribute (HTTPS-only).
533 /// Derived from the public base URL's scheme, so local plaintext dev still
534 /// works while production behind TLS gets `Secure` automatically.
535 pub fn secure_cookies(&self) -> bool {
536 self.http.base_url.starts_with("https://")
537 }
538
539 /// The HTTP clone URL for `<owner>/<name>`, e.g.
540 /// `http://localhost:3000/alice/hello.git`.
541 pub fn http_clone_url(&self, owner: &str, name: &str) -> String {
542 format!(
543 "{}/{owner}/{name}.git",
544 self.http.base_url.trim_end_matches('/')
545 )
546 }
547
548 /// The SSH clone URL for `<owner>/<name>`, using the externally advertised
549 /// host/port/user (which may differ from the internal bind under Docker).
550 /// On the SSH default (22), this is the scp-like `user@host:path` form,
551 /// which needs no `ssh://` scheme or port; a non-default port can only be
552 /// expressed with the `ssh://` form, so that's used instead.
553 pub fn ssh_clone_url(&self, owner: &str, name: &str) -> String {
554 let ssh = &self.ssh;
555 if ssh.clone_port == 22 {
556 format!("{}@{}:{owner}/{name}.git", ssh.clone_user, ssh.clone_host)
557 } else {
558 format!(
559 "ssh://{}@{}:{}/{owner}/{name}.git",
560 ssh.clone_user, ssh.clone_host, ssh.clone_port
561 )
562 }
563 }
564}
565
566#[cfg(test)]
567mod tests {
568 use super::*;
569
570 /// Look up from a fixed list, standing in for the process environment.
571 fn env_of<'a>(pairs: &'a [(&'a str, &'a str)]) -> impl Fn(&str) -> Option<String> + 'a {
572 move |key| {
573 pairs
574 .iter()
575 .find(|(k, _)| *k == key)
576 .map(|(_, v)| v.to_string())
577 }
578 }
579
580 #[test]
581 fn an_omitted_image_resolves_to_the_shared_runner() {
582 let ci = CiConfig::default();
583 assert_eq!(ci.resolve_image(""), DEFAULT_RUNNER_IMAGE);
584 assert_eq!(ci.resolve_image("rust:1.95-bookworm"), "rust:1.95-bookworm");
585 }
586
587 /// An allowlist must not lock out the default image: a pipeline that simply
588 /// omits `image:` never named anything for the operator to allow, and
589 /// failing those runs is the regression this whole path risks.
590 #[test]
591 fn the_default_image_is_allowed_even_under_an_allowlist() {
592 let ci = CiConfig {
593 allowed_images: vec!["alpine:3.20".to_string()],
594 ..CiConfig::default()
595 };
596 assert!(ci.image_allowed(DEFAULT_RUNNER_IMAGE));
597 assert!(ci.image_allowed("alpine:3.20"));
598 assert!(!ci.image_allowed("rust:1.95-bookworm"));
599 }
600
601 /// Agent sessions are off unless the operator turns them on — they run a
602 /// model over repository content, which `docs/untrusted-mode.md` treats as
603 /// a different exposure from CI.
604 #[test]
605 fn agent_sessions_default_to_off_and_share_the_runner_image() {
606 let agent = AgentConfig::default();
607 assert!(!agent.enabled);
608 assert_eq!(agent.image, DEFAULT_RUNNER_IMAGE);
609 assert_eq!(agent.image, CiConfig::default().default_image);
610 }
611
612 #[test]
613 fn port_and_host_set_the_bind_address() {
614 let mut config = Config::default();
615 config.apply_env(env_of(&[("PORT", "4738")]));
616 assert_eq!(config.http.listen, "127.0.0.1:4738");
617
618 let mut config = Config::default();
619 config.apply_env(env_of(&[("PORT", "4738"), ("HOST", "0.0.0.0")]));
620 assert_eq!(config.http.listen, "0.0.0.0:4738");
621 }
622
623 #[test]
624 fn anvil_listen_wins_over_port() {
625 let mut config = Config::default();
626 config.apply_env(env_of(&[
627 ("PORT", "4738"),
628 ("ANVIL_LISTEN", "0.0.0.0:9000"),
629 ]));
630 assert_eq!(config.http.listen, "0.0.0.0:9000");
631 }
632
633 #[test]
634 fn a_nonsense_port_leaves_the_configured_address_alone() {
635 let mut config = Config::default();
636 config.apply_env(env_of(&[("PORT", "not-a-port")]));
637 assert_eq!(config.http.listen, "127.0.0.1:3000");
638 }
639
640 #[test]
641 fn proxy_url_becomes_the_base_url() {
642 let mut config = Config::default();
643 config.apply_env(env_of(&[("PORTLESS_URL", "https://anvil.localhost/")]));
644 assert_eq!(config.http.base_url, "https://anvil.localhost");
645 // …and drives the Secure cookie attribute, since it is https.
646 assert!(config.secure_cookies());
647
648 let mut config = Config::default();
649 config.apply_env(env_of(&[
650 ("PORTLESS_URL", "https://anvil.localhost"),
651 ("ANVIL_BASE_URL", "https://forge.example.com"),
652 ]));
653 assert_eq!(config.http.base_url, "https://forge.example.com");
654 }
655
656 #[test]
657 fn an_empty_environment_changes_nothing() {
658 let mut config = Config::default();
659 config.apply_env(env_of(&[]));
660 assert_eq!(config.http.listen, HttpConfig::default().listen);
661 assert_eq!(config.http.base_url, HttpConfig::default().base_url);
662 }
663
664 #[test]
665 fn image_allowlist_semantics() {
666 let mut ci = CiConfig::default();
667 assert!(ci.image_allowed("anything:latest"), "empty list allows all");
668
669 ci.allowed_images = vec!["rust".to_string(), "alpine:3.20".to_string()];
670 assert!(ci.image_allowed("rust"), "tagless entry, tagless image");
671 assert!(
672 ci.image_allowed("rust:1.95-bookworm"),
673 "tagless entry allows any tag"
674 );
675 assert!(ci.image_allowed("alpine:3.20"), "tagged entry, exact match");
676 assert!(!ci.image_allowed("alpine:3.21"), "tagged entry, other tag");
677 assert!(!ci.image_allowed("alpine"), "tagged entry, tagless image");
678 assert!(
679 !ci.image_allowed("rustlang/rust:nightly"),
680 "no prefix bleed"
681 );
682 assert!(!ci.image_allowed("rusty:latest"), "no name-prefix bleed");
683 }
684}