diff --git a/core/archipelago/src/container/prod_orchestrator.rs b/core/archipelago/src/container/prod_orchestrator.rs index 0d66db21..4e101e7c 100644 --- a/core/archipelago/src/container/prod_orchestrator.rs +++ b/core/archipelago/src/container/prod_orchestrator.rs @@ -272,10 +272,15 @@ fn build_fingerprint_stamp_path(data_dir: &Path, tag: &str) -> PathBuf { } async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> { - let uid = uid_gid + let (uid, gid) = uid_gid .split_once(':') - .and_then(|(uid, _)| uid.parse::().ok()) - .unwrap_or(0); + .map(|(u, g)| { + ( + u.parse::().unwrap_or(0), + g.parse::().unwrap_or(0), + ) + }) + .unwrap_or((0, 0)); if uid > 0 && uid < 100_000 { let output = tokio::process::Command::new("podman") @@ -288,9 +293,22 @@ async fn chown_for_rootless_container(uid_gid: &str, path: &str) -> Result<()> { } } - let status = host_sudo(&["chown", "-R", uid_gid, path]) + // Host-side fallback. A CONTAINER-namespace id must be translated into + // the subuid range first: `sudo chown 999` writes literal host uid 999, + // which maps to nobody inside the userns — the app then can't open its + // own files while the chown reported success (botfights SQLITE_CANTOPEN + // crash-loop, framework-pt 2026-08-06). Container uid N (N>=1) lives at + // subuid_base + N - 1; the fleet provisions base 100000. uid 0 and + // already-mapped ids (>=100000) pass through untouched. + let host_uid_gid = if uid > 0 && uid < 100_000 { + let map = |id: u32| if id == 0 { 1000 } else { 100_000 + id - 1 }; + format!("{}:{}", map(uid), map(gid)) + } else { + uid_gid.to_string() + }; + let status = host_sudo(&["chown", "-R", &host_uid_gid, path]) .await - .with_context(|| format!("sudo chown -R {uid_gid} {path}"))?; + .with_context(|| format!("sudo chown -R {host_uid_gid} {path}"))?; if status.success() { return Ok(()); }