huskies: merge 1135 story Bootstrap Claude credentials into newly-launched project sleds
This commit is contained in:
@@ -1,6 +1,16 @@
|
|||||||
#!/bin/sh
|
#!/bin/sh
|
||||||
set -e
|
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 ────────────────────────────────────────────────
|
# ── SSH authorized key ────────────────────────────────────────────────
|
||||||
# HUSKIES_SSH_PUBKEY is set by `new project` when it generates a keypair.
|
# 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
|
# Write it to authorized_keys so the user can connect with the matching
|
||||||
|
|||||||
@@ -388,6 +388,21 @@ async fn handle_adopt_project(
|
|||||||
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
projects_store: &Arc<RwLock<BTreeMap<String, ProjectEntry>>>,
|
||||||
config_dir: &Path,
|
config_dir: &Path,
|
||||||
) -> String {
|
) -> 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).
|
// Scaffold .huskies/ into the existing repo (write-if-missing — safe).
|
||||||
if let Err(e) = crate::service::gateway::io::scaffold_project(host_path) {
|
if let Err(e) = crate::service::gateway::io::scaffold_project(host_path) {
|
||||||
return format!("Scaffold failed: {e}");
|
return format!("Scaffold failed: {e}");
|
||||||
@@ -453,6 +468,7 @@ async fn handle_adopt_project(
|
|||||||
&pubkey,
|
&pubkey,
|
||||||
&git_user_name,
|
&git_user_name,
|
||||||
&git_user_email,
|
&git_user_email,
|
||||||
|
Some(&credentials_file),
|
||||||
);
|
);
|
||||||
|
|
||||||
docker_args.push("-v".into());
|
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 ─────────────────────────────────────────────────
|
// ── Resolve git identity ─────────────────────────────────────────────────
|
||||||
// Read from bot.toml → fallback to host git config → hardcoded default.
|
// Read from bot.toml → fallback to host git config → hardcoded default.
|
||||||
|
|
||||||
@@ -798,6 +829,7 @@ pub async fn handle_new_project(
|
|||||||
&pubkey,
|
&pubkey,
|
||||||
&git_user_name,
|
&git_user_name,
|
||||||
&git_user_email,
|
&git_user_email,
|
||||||
|
Some(&credentials_file),
|
||||||
);
|
);
|
||||||
|
|
||||||
// HTTPS push token: passed as env vars consumed by the entrypoint credential helper.
|
// 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.
|
/// to all interfaces, making Docker port forwarding reachable from the host.
|
||||||
/// Without this the server defaults to `127.0.0.1` inside the container —
|
/// Without this the server defaults to `127.0.0.1` inside the container —
|
||||||
/// reachable only from within the container itself, not via `docker -p`.
|
/// 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(
|
fn project_docker_run_args(
|
||||||
container_name: &str,
|
container_name: &str,
|
||||||
port: u16,
|
port: u16,
|
||||||
@@ -925,8 +963,9 @@ fn project_docker_run_args(
|
|||||||
pubkey: &str,
|
pubkey: &str,
|
||||||
git_user_name: &str,
|
git_user_name: &str,
|
||||||
git_user_email: &str,
|
git_user_email: &str,
|
||||||
|
credentials_path: Option<&std::path::Path>,
|
||||||
) -> Vec<String> {
|
) -> Vec<String> {
|
||||||
vec![
|
let mut args = vec![
|
||||||
"run".into(),
|
"run".into(),
|
||||||
"-d".into(),
|
"-d".into(),
|
||||||
"--name".into(),
|
"--name".into(),
|
||||||
@@ -945,7 +984,15 @@ fn project_docker_run_args(
|
|||||||
format!("GIT_USER_NAME={git_user_name}"),
|
format!("GIT_USER_NAME={git_user_name}"),
|
||||||
"-e".into(),
|
"-e".into(),
|
||||||
format!("GIT_USER_EMAIL={git_user_email}"),
|
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.
|
/// Convert a failed `docker run` stderr into an actionable chat message.
|
||||||
@@ -1365,6 +1412,7 @@ mod tests {
|
|||||||
"ssh-ed25519 AAAA...",
|
"ssh-ed25519 AAAA...",
|
||||||
"Test User",
|
"Test User",
|
||||||
"test@example.com",
|
"test@example.com",
|
||||||
|
None,
|
||||||
);
|
);
|
||||||
// Find "-e" followed by "HUSKIES_HOST=0.0.0.0"
|
// Find "-e" followed by "HUSKIES_HOST=0.0.0.0"
|
||||||
let pairs: Vec<_> = args.windows(2).collect();
|
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]
|
#[test]
|
||||||
fn interpret_docker_run_error_missing_image_points_at_script() {
|
fn interpret_docker_run_error_missing_image_points_at_script() {
|
||||||
let stderr = "Unable to find image 'huskies-project-rust:latest' locally\n\
|
let stderr = "Unable to find image 'huskies-project-rust:latest' locally\n\
|
||||||
|
|||||||
Reference in New Issue
Block a user