diff --git a/docker/entrypoint.sh b/docker/entrypoint.sh index bb95d1ca..c16d87a1 100755 --- a/docker/entrypoint.sh +++ b/docker/entrypoint.sh @@ -1,6 +1,16 @@ #!/bin/sh set -e +# ── Claude credentials ──────────────────────────────────────────────── +# The `new project` command bind-mounts the host ~/.claude/.credentials.json +# at /run/claude-credentials-src:ro. We copy it here so the huskies user +# owns the file and mode 0600 is enforced regardless of host uid/gid. +if [ -f /run/claude-credentials-src ]; then + mkdir -p /home/huskies/.claude + cp /run/claude-credentials-src /home/huskies/.claude/.credentials.json + chmod 600 /home/huskies/.claude/.credentials.json +fi + # ── SSH authorized key ──────────────────────────────────────────────── # HUSKIES_SSH_PUBKEY is set by `new project` when it generates a keypair. # Write it to authorized_keys so the user can connect with the matching diff --git a/server/src/chat/transport/matrix/new_project.rs b/server/src/chat/transport/matrix/new_project.rs index cb711472..4feb99a1 100644 --- a/server/src/chat/transport/matrix/new_project.rs +++ b/server/src/chat/transport/matrix/new_project.rs @@ -388,6 +388,21 @@ async fn handle_adopt_project( projects_store: &Arc>>, config_dir: &Path, ) -> String { + // ── Credentials pre-flight ─────────────────────────────────────────────── + // Agents inside the container need Claude credentials to spawn. Fail fast + // with an actionable message rather than launching a sled that immediately + // errors with "Not logged in" when `start_agent` is called. + let credentials_file = std::path::PathBuf::from(home) + .join(".claude") + .join(".credentials.json"); + if !credentials_file.exists() { + return format!( + "No Claude credentials found at `{}/.claude/.credentials.json`. \ + Run `claude login` on the host first, then retry.", + home + ); + } + // Scaffold .huskies/ into the existing repo (write-if-missing — safe). if let Err(e) = crate::service::gateway::io::scaffold_project(host_path) { return format!("Scaffold failed: {e}"); @@ -453,6 +468,7 @@ async fn handle_adopt_project( &pubkey, &git_user_name, &git_user_email, + Some(&credentials_file), ); docker_args.push("-v".into()); @@ -752,6 +768,21 @@ pub async fn handle_new_project( } }; + // ── Credentials pre-flight ─────────────────────────────────────────────── + let credentials_file = std::path::PathBuf::from(&home) + .join(".claude") + .join(".credentials.json"); + if !credentials_file.exists() { + let _ = tokio::fs::remove_dir_all(&host_path).await; + let _ = tokio::fs::remove_dir_all(&ssh_key_dir).await; + return format!( + "No Claude credentials found at `{home}/.claude/.credentials.json`. \ + Run `claude login` on the host first, then retry.\n\n\ + Partial state removed at `{}`.", + host_path.display() + ); + } + // ── Resolve git identity ───────────────────────────────────────────────── // Read from bot.toml → fallback to host git config → hardcoded default. @@ -798,6 +829,7 @@ pub async fn handle_new_project( &pubkey, &git_user_name, &git_user_email, + Some(&credentials_file), ); // HTTPS push token: passed as env vars consumed by the entrypoint credential helper. @@ -918,6 +950,12 @@ pub async fn handle_new_project( /// to all interfaces, making Docker port forwarding reachable from the host. /// Without this the server defaults to `127.0.0.1` inside the container — /// reachable only from within the container itself, not via `docker -p`. +/// +/// When `credentials_path` is `Some`, the file is bind-mounted read-only at +/// `/run/claude-credentials-src` so the container entrypoint can copy it into +/// `/home/huskies/.claude/.credentials.json` with mode 0600. Mounting to an +/// intermediate path (rather than directly to the destination) ensures the +/// huskies user owns the copy regardless of the host user's UID. fn project_docker_run_args( container_name: &str, port: u16, @@ -925,8 +963,9 @@ fn project_docker_run_args( pubkey: &str, git_user_name: &str, git_user_email: &str, + credentials_path: Option<&std::path::Path>, ) -> Vec { - vec![ + let mut args = vec![ "run".into(), "-d".into(), "--name".into(), @@ -945,7 +984,15 @@ fn project_docker_run_args( format!("GIT_USER_NAME={git_user_name}"), "-e".into(), format!("GIT_USER_EMAIL={git_user_email}"), - ] + ]; + if let Some(creds) = credentials_path { + args.push("-v".into()); + args.push(format!( + "{}:/run/claude-credentials-src:ro", + creds.display() + )); + } + args } /// Convert a failed `docker run` stderr into an actionable chat message. @@ -1365,6 +1412,7 @@ mod tests { "ssh-ed25519 AAAA...", "Test User", "test@example.com", + None, ); // Find "-e" followed by "HUSKIES_HOST=0.0.0.0" let pairs: Vec<_> = args.windows(2).collect(); @@ -1382,6 +1430,72 @@ mod tests { ); } + #[test] + fn project_docker_args_include_credentials_mount() { + let creds = std::path::Path::new("/home/user/.claude/.credentials.json"); + let args = project_docker_run_args( + "huskies-myapp", + 3100, + 2200, + "ssh-ed25519 AAAA...", + "Test User", + "test@example.com", + Some(creds), + ); + let pairs: Vec<_> = args.windows(2).collect(); + assert!( + pairs.iter().any(|w| w[0] == "-v" + && w[1] == "/home/user/.claude/.credentials.json:/run/claude-credentials-src:ro"), + "expected credentials bind-mount in docker args, got: {args:?}" + ); + } + + #[test] + fn project_docker_args_no_credentials_mount_when_none() { + let args = project_docker_run_args( + "huskies-myapp", + 3100, + 2200, + "ssh-ed25519 AAAA...", + "Test User", + "test@example.com", + None, + ); + assert!( + !args.iter().any(|a| a.contains("claude-credentials-src")), + "expected no credentials mount when credentials_path is None, got: {args:?}" + ); + } + + #[tokio::test] + async fn handle_adopt_project_missing_credentials_returns_error() { + let adopt_dir = tempfile::tempdir().unwrap(); + let home_dir = tempfile::tempdir().unwrap(); + // home_dir has no .claude/.credentials.json + + let store = Arc::new(RwLock::new(BTreeMap::new())); + let config_dir = tempfile::tempdir().unwrap(); + + let result = handle_adopt_project( + "myapp", + None, + adopt_dir.path(), + home_dir.path().to_str().unwrap(), + &store, + config_dir.path(), + ) + .await; + + assert!( + result.contains("claude login"), + "expected claude login suggestion in error, got: {result}" + ); + assert!( + result.contains(".credentials.json"), + "expected credentials path in error, got: {result}" + ); + } + #[test] fn interpret_docker_run_error_missing_image_points_at_script() { let stderr = "Unable to find image 'huskies-project-rust:latest' locally\n\