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