Fix stale AWS/Bedrock auth carrying over on backend switch
When a project switched backends (e.g. Bedrock -> Anthropic), the recreated container kept authenticating against Bedrock, and SSO kept firing after switching away. Three root causes, all fixed: 1. Recreation builds the new container from a `docker commit` snapshot. commit always bakes the previous container's full ENV into the image (an empty commit Config does NOT strip it, and the commit API cannot remove env). So CLAUDE_CODE_USE_BEDROCK=1 / AWS_* survived into the new container. Fix: create_container now explicitly clears every managed auth key the active backend does not set (MANAGED_AUTH_KEYS), so create-time env overrides the stale baked-in values. 2. awsAuthRefresh was written into ~/.claude.json (persisted home volume) and never removed, so Claude Code kept invoking triple-c-sso-refresh after switching to a non-SSO backend. Fix: entrypoint now deletes awsAuthRefresh when AWS_SSO_AUTH_REFRESH_CMD is unset, idempotent both ways. 3. Static/session AWS creds were baked into Config.Env at create time, so a stop/start kept stale creds and rotated keys never refreshed without a full recreation. Fix: static creds are no longer injected as env vars; write_bedrock_static_credentials() writes ~/.aws/credentials (0600, secrets via exec env not argv) on every start, and removes a stale ~/.aws/config left from a prior profile/SSO session. Static creds also dropped from the bedrock fingerprint so a key rotation refreshes in place instead of forcing recreation. Adds exec_oneshot_env() for env-carrying one-shot execs. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -432,6 +432,13 @@ pub async fn start_project_container(
|
|||||||
new_id
|
new_id
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Refresh Bedrock static/session credentials on every start so rotated
|
||||||
|
// keys are picked up without a full container recreation. No-op for
|
||||||
|
// other backends / auth methods.
|
||||||
|
if let Err(e) = docker::write_bedrock_static_credentials(&container_id, &project).await {
|
||||||
|
log::warn!("Failed to refresh AWS credentials for project {}: {}", project.id, e);
|
||||||
|
}
|
||||||
|
|
||||||
Ok(container_id)
|
Ok(container_id)
|
||||||
}.await;
|
}.await;
|
||||||
|
|
||||||
|
|||||||
@@ -275,12 +275,15 @@ fn compute_bedrock_fingerprint(project: &Project, global_aws: &GlobalAwsSettings
|
|||||||
bedrock.model_id.as_deref(),
|
bedrock.model_id.as_deref(),
|
||||||
global_aws.default_model_id.as_deref(),
|
global_aws.default_model_id.as_deref(),
|
||||||
).unwrap_or("").to_string();
|
).unwrap_or("").to_string();
|
||||||
|
// NOTE: the static credential fields (access key / secret / session
|
||||||
|
// token) are intentionally NOT part of the fingerprint. They are
|
||||||
|
// written to ~/.aws/credentials on every start by
|
||||||
|
// write_bedrock_static_credentials(), so a key rotation should refresh
|
||||||
|
// in place rather than force a full container recreation. Region,
|
||||||
|
// profile, and bearer token remain env-based and so stay here.
|
||||||
let parts = vec![
|
let parts = vec![
|
||||||
format!("{:?}", bedrock.auth_method),
|
format!("{:?}", bedrock.auth_method),
|
||||||
bedrock.aws_region.clone(),
|
bedrock.aws_region.clone(),
|
||||||
bedrock.aws_access_key_id.as_deref().unwrap_or("").to_string(),
|
|
||||||
bedrock.aws_secret_access_key.as_deref().unwrap_or("").to_string(),
|
|
||||||
bedrock.aws_session_token.as_deref().unwrap_or("").to_string(),
|
|
||||||
bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
|
bedrock.aws_profile.as_deref().unwrap_or("").to_string(),
|
||||||
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
|
bedrock.aws_bearer_token.as_deref().unwrap_or("").to_string(),
|
||||||
effective_model,
|
effective_model,
|
||||||
@@ -669,15 +672,14 @@ pub async fn create_container(
|
|||||||
|
|
||||||
match bedrock.auth_method {
|
match bedrock.auth_method {
|
||||||
BedrockAuthMethod::StaticCredentials => {
|
BedrockAuthMethod::StaticCredentials => {
|
||||||
if let Some(ref key_id) = bedrock.aws_access_key_id {
|
// Static/session credentials are NOT injected as env vars.
|
||||||
env_vars.push(format!("AWS_ACCESS_KEY_ID={}", key_id));
|
// They are written to ~/.aws/credentials by
|
||||||
}
|
// write_bedrock_static_credentials() on every container
|
||||||
if let Some(ref secret) = bedrock.aws_secret_access_key {
|
// start, so rotated/updated keys are picked up without a
|
||||||
env_vars.push(format!("AWS_SECRET_ACCESS_KEY={}", secret));
|
// full container recreation (and never get baked into the
|
||||||
}
|
// snapshot image). The empty values set by the
|
||||||
if let Some(ref token) = bedrock.aws_session_token {
|
// MANAGED_AUTH_KEYS neutralization pass below are ignored by
|
||||||
env_vars.push(format!("AWS_SESSION_TOKEN={}", token));
|
// the AWS SDK, which falls through to the credentials file.
|
||||||
}
|
|
||||||
}
|
}
|
||||||
BedrockAuthMethod::Profile => {
|
BedrockAuthMethod::Profile => {
|
||||||
// Per-project profile overrides global
|
// Per-project profile overrides global
|
||||||
@@ -755,6 +757,41 @@ pub async fn create_container(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Neutralize stale backend auth env vars ──────────────────────────────
|
||||||
|
// When a project switches backends (e.g. Bedrock → Anthropic) the container
|
||||||
|
// is recreated *from a snapshot image* committed off the previous container.
|
||||||
|
// `docker commit` always bakes the previous container's full ENV into that
|
||||||
|
// image, and the commit API cannot strip it. So any auth var set under the
|
||||||
|
// old backend (e.g. CLAUDE_CODE_USE_BEDROCK=1, AWS_*) survives in the image
|
||||||
|
// ENV and stays active unless we explicitly override it at create time.
|
||||||
|
// Create-time env takes precedence over image ENV, so we set every managed
|
||||||
|
// auth key the *current* backend did NOT set to an empty value, clearing the
|
||||||
|
// stale baked-in one.
|
||||||
|
const MANAGED_AUTH_KEYS: &[&str] = &[
|
||||||
|
"CLAUDE_CODE_USE_BEDROCK",
|
||||||
|
"AWS_REGION",
|
||||||
|
"AWS_ACCESS_KEY_ID",
|
||||||
|
"AWS_SECRET_ACCESS_KEY",
|
||||||
|
"AWS_SESSION_TOKEN",
|
||||||
|
"AWS_PROFILE",
|
||||||
|
"AWS_BEARER_TOKEN_BEDROCK",
|
||||||
|
"AWS_SSO_AUTH_REFRESH_CMD",
|
||||||
|
"ANTHROPIC_BASE_URL",
|
||||||
|
"ANTHROPIC_AUTH_TOKEN",
|
||||||
|
"ANTHROPIC_MODEL",
|
||||||
|
"DISABLE_PROMPT_CACHING",
|
||||||
|
"ANTHROPIC_BEDROCK_SERVICE_TIER",
|
||||||
|
];
|
||||||
|
let already_set: std::collections::HashSet<String> = env_vars
|
||||||
|
.iter()
|
||||||
|
.filter_map(|e| e.split('=').next().map(|k| k.to_string()))
|
||||||
|
.collect();
|
||||||
|
for key in MANAGED_AUTH_KEYS {
|
||||||
|
if !already_set.contains(*key) {
|
||||||
|
env_vars.push(format!("{}=", key));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Custom environment variables (global + per-project, project overrides global for same key)
|
// Custom environment variables (global + per-project, project overrides global for same key)
|
||||||
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
let merged_env = merge_custom_env_vars(global_custom_env_vars, &project.custom_env_vars);
|
||||||
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
let reserved_prefixes = ["ANTHROPIC_", "AWS_", "GIT_", "HOST_", "TRIPLE_C_"];
|
||||||
@@ -1060,10 +1097,92 @@ pub fn get_snapshot_image_name(project: &Project) -> String {
|
|||||||
format!("triple-c-snapshot-{}:latest", project.id)
|
format!("triple-c-snapshot-{}:latest", project.id)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Write Bedrock static/session credentials into the running container's
|
||||||
|
/// ~/.aws/credentials file. Called on every container start (not just creation)
|
||||||
|
/// so rotated keys or refreshed session tokens are picked up without recreating
|
||||||
|
/// the container. Credentials are passed via the exec environment (not argv) and
|
||||||
|
/// the file is written with 0600 permissions. No-op unless the project uses
|
||||||
|
/// Bedrock with static-credential auth.
|
||||||
|
pub async fn write_bedrock_static_credentials(
|
||||||
|
container_id: &str,
|
||||||
|
project: &Project,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if project.backend != Backend::Bedrock {
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
let bedrock = match project.bedrock_config {
|
||||||
|
Some(ref b) if b.auth_method == BedrockAuthMethod::StaticCredentials => b,
|
||||||
|
_ => return Ok(()),
|
||||||
|
};
|
||||||
|
|
||||||
|
let key_id = match bedrock.aws_access_key_id.as_deref() {
|
||||||
|
Some(k) if !k.is_empty() => k,
|
||||||
|
_ => {
|
||||||
|
log::warn!("Bedrock static auth selected but no AWS access key id is set");
|
||||||
|
return Ok(());
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let secret = bedrock.aws_secret_access_key.as_deref().unwrap_or("");
|
||||||
|
|
||||||
|
// Pass secrets via the exec environment, then have the shell write them to
|
||||||
|
// the file. This keeps them out of the process argv (visible via `ps`).
|
||||||
|
let mut env = vec![
|
||||||
|
format!("TC_AWS_KEY_ID={}", key_id),
|
||||||
|
format!("TC_AWS_SECRET={}", secret),
|
||||||
|
];
|
||||||
|
if let Some(token) = bedrock.aws_session_token.as_deref() {
|
||||||
|
if !token.is_empty() {
|
||||||
|
env.push(format!("TC_AWS_TOKEN={}", token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// umask 077 + explicit chmod guarantees 0600. The session-token line is only
|
||||||
|
// emitted when the variable is non-empty.
|
||||||
|
//
|
||||||
|
// We also remove a stale ~/.aws/config left over from a previous
|
||||||
|
// profile/SSO session on this project (the home volume persists across
|
||||||
|
// backend switches), so its sso_session/profile settings don't shadow the
|
||||||
|
// static [default] credentials. This is skipped when /tmp/.host-aws is
|
||||||
|
// mounted (a global aws_config_path is configured) — in that case the
|
||||||
|
// entrypoint already refreshes ~/.aws from the host on every start and the
|
||||||
|
// config is intentional.
|
||||||
|
let script = r#"set -e
|
||||||
|
umask 077
|
||||||
|
mkdir -p "$HOME/.aws"
|
||||||
|
if [ ! -d /tmp/.host-aws ] && [ -f "$HOME/.aws/config" ]; then
|
||||||
|
rm -f "$HOME/.aws/config"
|
||||||
|
fi
|
||||||
|
{
|
||||||
|
printf '[default]\n'
|
||||||
|
printf 'aws_access_key_id=%s\n' "$TC_AWS_KEY_ID"
|
||||||
|
printf 'aws_secret_access_key=%s\n' "$TC_AWS_SECRET"
|
||||||
|
if [ -n "${TC_AWS_TOKEN:-}" ]; then
|
||||||
|
printf 'aws_session_token=%s\n' "$TC_AWS_TOKEN"
|
||||||
|
fi
|
||||||
|
} > "$HOME/.aws/credentials"
|
||||||
|
chmod 600 "$HOME/.aws/credentials""#;
|
||||||
|
|
||||||
|
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||||
|
crate::docker::exec::exec_oneshot_env(container_id, cmd, env)
|
||||||
|
.await
|
||||||
|
.map(|_| ())
|
||||||
|
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
|
||||||
|
|
||||||
|
log::info!("Wrote Bedrock static credentials into container {}", container_id);
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
/// Commit the container's filesystem to a snapshot image so that system-level
|
/// Commit the container's filesystem to a snapshot image so that system-level
|
||||||
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
|
/// changes (apt/pip/npm installs, ~/.claude.json, etc.) survive container
|
||||||
/// removal. The Config is left empty so that secrets injected as env vars are
|
/// removal.
|
||||||
/// NOT baked into the image.
|
///
|
||||||
|
/// NOTE: `docker commit` always bakes the *running container's* full ENV into
|
||||||
|
/// the resulting image — passing an empty Config here does NOT strip it, and
|
||||||
|
/// the commit API gives no way to remove env vars. As a result auth vars (e.g.
|
||||||
|
/// CLAUDE_CODE_USE_BEDROCK, AWS_*) are present in this snapshot image's ENV.
|
||||||
|
/// `create_container` defends against that by explicitly overriding every
|
||||||
|
/// managed auth key for the active backend (see MANAGED_AUTH_KEYS), so a
|
||||||
|
/// backend switch does not inherit the previous backend's stale credentials.
|
||||||
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
|
pub async fn commit_container_snapshot(container_id: &str, project: &Project) -> Result<(), String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
let image_name = get_snapshot_image_name(project);
|
let image_name = get_snapshot_image_name(project);
|
||||||
|
|||||||
@@ -281,6 +281,18 @@ impl ExecSessionManager {
|
|||||||
|
|
||||||
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
/// Run a one-shot (non-interactive) exec command in a container and collect stdout.
|
||||||
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String, String> {
|
||||||
|
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Like `exec_oneshot`, but passes additional environment variables to the exec
|
||||||
|
/// process. Secrets passed this way live only in `/proc/<pid>/environ` (readable
|
||||||
|
/// by the same user / root) rather than in the process argv, so they are not
|
||||||
|
/// exposed via `ps`.
|
||||||
|
pub async fn exec_oneshot_env(
|
||||||
|
container_id: &str,
|
||||||
|
cmd: Vec<String>,
|
||||||
|
env: Vec<String>,
|
||||||
|
) -> Result<String, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
|
|
||||||
let exec = docker
|
let exec = docker
|
||||||
@@ -290,6 +302,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
|||||||
attach_stdout: Some(true),
|
attach_stdout: Some(true),
|
||||||
attach_stderr: Some(true),
|
attach_stderr: Some(true),
|
||||||
cmd: Some(cmd),
|
cmd: Some(cmd),
|
||||||
|
env: if env.is_empty() { None } else { Some(env) },
|
||||||
user: Some("claude".to_string()),
|
user: Some("claude".to_string()),
|
||||||
..Default::default()
|
..Default::default()
|
||||||
},
|
},
|
||||||
|
|||||||
+14
-3
@@ -212,10 +212,14 @@ if [ -n "$CLAUDE_CODE_SETTINGS_JSON" ]; then
|
|||||||
fi
|
fi
|
||||||
|
|
||||||
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
# ── AWS SSO auth refresh command ──────────────────────────────────────────────
|
||||||
# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls
|
# When set (Bedrock + profile/SSO auth), inject awsAuthRefresh into
|
||||||
# triple-c-sso-refresh when AWS credentials expire mid-session.
|
# ~/.claude.json so Claude Code calls triple-c-sso-refresh when AWS credentials
|
||||||
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
# expire mid-session. When NOT set, strip any awsAuthRefresh left behind by a
|
||||||
|
# previous Bedrock-profile session — ~/.claude.json lives in the persisted home
|
||||||
|
# volume, so without this the container keeps trying to run the SSO refresh even
|
||||||
|
# after switching to a non-SSO backend (Anthropic/Ollama) or to static creds.
|
||||||
CLAUDE_JSON="/home/claude/.claude.json"
|
CLAUDE_JSON="/home/claude/.claude.json"
|
||||||
|
if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
||||||
if [ -f "$CLAUDE_JSON" ]; then
|
if [ -f "$CLAUDE_JSON" ]; then
|
||||||
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
if [ -n "$MERGED" ]; then
|
if [ -n "$MERGED" ]; then
|
||||||
@@ -227,6 +231,13 @@ if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then
|
|||||||
chown claude:claude "$CLAUDE_JSON"
|
chown claude:claude "$CLAUDE_JSON"
|
||||||
chmod 600 "$CLAUDE_JSON"
|
chmod 600 "$CLAUDE_JSON"
|
||||||
unset AWS_SSO_AUTH_REFRESH_CMD
|
unset AWS_SSO_AUTH_REFRESH_CMD
|
||||||
|
elif [ -f "$CLAUDE_JSON" ]; then
|
||||||
|
MERGED=$(jq 'del(.awsAuthRefresh)' "$CLAUDE_JSON" 2>/dev/null)
|
||||||
|
if [ -n "$MERGED" ]; then
|
||||||
|
printf '%s\n' "$MERGED" > "$CLAUDE_JSON"
|
||||||
|
chown claude:claude "$CLAUDE_JSON"
|
||||||
|
chmod 600 "$CLAUDE_JSON"
|
||||||
|
fi
|
||||||
fi
|
fi
|
||||||
|
|
||||||
# ── Docker socket permissions ────────────────────────────────────────────────
|
# ── Docker socket permissions ────────────────────────────────────────────────
|
||||||
|
|||||||
Reference in New Issue
Block a user