diff --git a/app/src-tauri/src/commands/aws_commands.rs b/app/src-tauri/src/commands/aws_commands.rs new file mode 100644 index 0000000..c1b8db7 --- /dev/null +++ b/app/src-tauri/src/commands/aws_commands.rs @@ -0,0 +1,30 @@ +use tauri::State; +use crate::AppState; + +#[tauri::command] +pub async fn aws_sso_refresh( + project_id: String, + state: State<'_, AppState>, +) -> Result<(), String> { + let project = state.projects_store.get(&project_id) + .ok_or_else(|| format!("Project {} not found", project_id))?; + + let profile = project.bedrock_config.as_ref() + .and_then(|b| b.aws_profile.clone()) + .or_else(|| state.settings_store.get().global_aws.aws_profile.clone()) + .unwrap_or_else(|| "default".to_string()); + + log::info!("Running host-side AWS SSO login for profile '{}'", profile); + + let status = tokio::process::Command::new("aws") + .args(["sso", "login", "--profile", &profile]) + .status() + .await + .map_err(|e| format!("Failed to run aws sso login: {}", e))?; + + if !status.success() { + return Err("SSO login failed or was cancelled".to_string()); + } + + Ok(()) +} diff --git a/app/src-tauri/src/commands/mod.rs b/app/src-tauri/src/commands/mod.rs index 6f0d0bd..92a906f 100644 --- a/app/src-tauri/src/commands/mod.rs +++ b/app/src-tauri/src/commands/mod.rs @@ -1,3 +1,4 @@ +pub mod aws_commands; pub mod docker_commands; pub mod file_commands; pub mod mcp_commands; diff --git a/app/src-tauri/src/commands/terminal_commands.rs b/app/src-tauri/src/commands/terminal_commands.rs index aede054..f0702af 100644 --- a/app/src-tauri/src/commands/terminal_commands.rs +++ b/app/src-tauri/src/commands/terminal_commands.rs @@ -40,11 +40,12 @@ if aws sts get-caller-identity --profile '{profile}' >/dev/null 2>&1; then echo "AWS session valid." else echo "AWS session expired or invalid." - # Check if this profile uses SSO (has sso_start_url configured) - if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1; then - echo "Starting SSO login — click the URL below to authenticate:" + # Check if this profile uses SSO (has sso_start_url or sso_session configured) + if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \ + aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then + echo "Starting SSO login..." echo "" - aws sso login --profile '{profile}' + triple-c-sso-refresh if [ $? -ne 0 ]; then echo "" echo "SSO login failed or was cancelled. Starting Claude anyway..." diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 4e09342..7d2a41d 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -459,6 +459,7 @@ pub async fn create_container( if let Some(p) = profile { env_vars.push(format!("AWS_PROFILE={}", p)); } + env_vars.push("AWS_SSO_AUTH_REFRESH_CMD=triple-c-sso-refresh".to_string()); } BedrockAuthMethod::BearerToken => { if let Some(ref token) = bedrock.aws_bearer_token { diff --git a/app/src-tauri/src/lib.rs b/app/src-tauri/src/lib.rs index e98d66c..f84dbd4 100644 --- a/app/src-tauri/src/lib.rs +++ b/app/src-tauri/src/lib.rs @@ -114,6 +114,8 @@ pub fn run() { commands::mcp_commands::add_mcp_server, commands::mcp_commands::update_mcp_server, commands::mcp_commands::remove_mcp_server, + // AWS + commands::aws_commands::aws_sso_refresh, // Updates commands::update_commands::get_app_version, commands::update_commands::check_for_updates, diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 2c1e29d..22258a7 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -6,6 +6,8 @@ import { WebLinksAddon } from "@xterm/addon-web-links"; import { openUrl } from "@tauri-apps/plugin-opener"; import "@xterm/xterm/css/xterm.css"; import { useTerminal } from "../../hooks/useTerminal"; +import { useAppState } from "../../store/appState"; +import { awsSsoRefresh } from "../../lib/tauri-commands"; import { UrlDetector } from "../../lib/urlDetector"; import UrlToast from "./UrlToast"; @@ -23,6 +25,12 @@ export default function TerminalView({ sessionId, active }: Props) { const detectorRef = useRef(null); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); + const ssoBufferRef = useRef(""); + const ssoTriggeredRef = useRef(false); + const projectId = useAppState( + (s) => s.sessions.find((sess) => sess.id === sessionId)?.projectId + ); + const [detectedUrl, setDetectedUrl] = useState(null); const [imagePasteMsg, setImagePasteMsg] = useState(null); const [isAtBottom, setIsAtBottom] = useState(true); @@ -152,10 +160,30 @@ export default function TerminalView({ sessionId, active }: Props) { const detector = new UrlDetector((url) => setDetectedUrl(url)); detectorRef.current = detector; + const SSO_MARKER = "###TRIPLE_C_SSO_REFRESH###"; + const textDecoder = new TextDecoder(); + const outputPromise = onOutput(sessionId, (data) => { if (aborted) return; term.write(data); detector.feed(data); + + // Scan for SSO refresh marker in terminal output + if (!ssoTriggeredRef.current && projectId) { + const text = textDecoder.decode(data, { stream: true }); + // Combine with overlap from previous chunk to handle marker spanning chunks + const combined = ssoBufferRef.current + text; + if (combined.includes(SSO_MARKER)) { + ssoTriggeredRef.current = true; + ssoBufferRef.current = ""; + awsSsoRefresh(projectId).catch((e) => + console.error("AWS SSO refresh failed:", e) + ); + } else { + // Keep last N chars as overlap for next chunk + ssoBufferRef.current = combined.slice(-SSO_MARKER.length); + } + } }).then((unlisten) => { if (aborted) unlisten(); return unlisten; @@ -189,6 +217,8 @@ export default function TerminalView({ sessionId, active }: Props) { aborted = true; detector.dispose(); detectorRef.current = null; + ssoTriggeredRef.current = false; + ssoBufferRef.current = ""; osc52Disposable.dispose(); inputDisposable.dispose(); scrollDisposable.dispose(); diff --git a/app/src/lib/tauri-commands.ts b/app/src/lib/tauri-commands.ts index 9671477..eaa5809 100644 --- a/app/src/lib/tauri-commands.ts +++ b/app/src/lib/tauri-commands.ts @@ -40,6 +40,10 @@ export const listAwsProfiles = () => export const detectHostTimezone = () => invoke("detect_host_timezone"); +// AWS +export const awsSsoRefresh = (projectId: string) => + invoke("aws_sso_refresh", { projectId }); + // Terminal export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string) => invoke("open_terminal_session", { projectId, sessionId, sessionType }); diff --git a/container/Dockerfile b/container/Dockerfile index cf873e8..227df80 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -119,6 +119,9 @@ RUN chmod +x /usr/local/bin/audio-shim \ && ln -sf /usr/local/bin/audio-shim /usr/local/bin/rec \ && ln -sf /usr/local/bin/audio-shim /usr/local/bin/arecord +COPY triple-c-sso-refresh /usr/local/bin/triple-c-sso-refresh +RUN chmod +x /usr/local/bin/triple-c-sso-refresh + COPY entrypoint.sh /usr/local/bin/entrypoint.sh RUN chmod +x /usr/local/bin/entrypoint.sh COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler diff --git a/container/entrypoint.sh b/container/entrypoint.sh index acb7a20..4ca8e18 100644 --- a/container/entrypoint.sh +++ b/container/entrypoint.sh @@ -84,6 +84,31 @@ if [ -d /tmp/.host-aws ]; then # Ensure writable cache directories exist mkdir -p /home/claude/.aws/sso/cache /home/claude/.aws/cli/cache chown -R claude:claude /home/claude/.aws/sso /home/claude/.aws/cli + + # Inline sso_session properties into profile sections so AWS SDKs that don't + # support the sso_session indirection format can resolve sso_region, etc. + if [ -f /home/claude/.aws/config ]; then + python3 -c ' +import configparser, sys +c = configparser.ConfigParser() +c.read(sys.argv[1]) +for sec in c.sections(): + if not sec.startswith("profile ") and sec != "default": + continue + session = c.get(sec, "sso_session", fallback=None) + if not session or c.has_option(sec, "sso_start_url"): + continue + ss = f"sso-session {session}" + if not c.has_section(ss): + continue + for key in ("sso_start_url", "sso_region", "sso_registration_scopes"): + val = c.get(ss, key, fallback=None) + if val: + c.set(sec, key, val) +with open(sys.argv[1], "w") as f: + c.write(f) +' /home/claude/.aws/config 2>/dev/null || true + fi fi # ── Git credential helper (for HTTPS token) ───────────────────────────────── @@ -164,6 +189,24 @@ if [ -n "$MCP_SERVERS_JSON" ]; then unset MCP_SERVERS_JSON fi +# ── AWS SSO auth refresh command ────────────────────────────────────────────── +# When set, inject awsAuthRefresh into ~/.claude.json so Claude Code calls +# triple-c-sso-refresh when AWS credentials expire mid-session. +if [ -n "$AWS_SSO_AUTH_REFRESH_CMD" ]; then + CLAUDE_JSON="/home/claude/.claude.json" + if [ -f "$CLAUDE_JSON" ]; then + MERGED=$(jq --arg cmd "$AWS_SSO_AUTH_REFRESH_CMD" '.awsAuthRefresh = $cmd' "$CLAUDE_JSON" 2>/dev/null) + if [ -n "$MERGED" ]; then + printf '%s\n' "$MERGED" > "$CLAUDE_JSON" + fi + else + printf '{"awsAuthRefresh":"%s"}\n' "$AWS_SSO_AUTH_REFRESH_CMD" > "$CLAUDE_JSON" + fi + chown claude:claude "$CLAUDE_JSON" + chmod 600 "$CLAUDE_JSON" + unset AWS_SSO_AUTH_REFRESH_CMD +fi + # ── Docker socket permissions ──────────────────────────────────────────────── if [ -S /var/run/docker.sock ]; then DOCKER_GID=$(stat -c '%g' /var/run/docker.sock) diff --git a/container/triple-c-sso-refresh b/container/triple-c-sso-refresh new file mode 100755 index 0000000..530b53d --- /dev/null +++ b/container/triple-c-sso-refresh @@ -0,0 +1,33 @@ +#!/bin/bash +# Signal Triple-C to perform host-side AWS SSO login, then sync the result. +CACHE_DIR="$HOME/.aws/sso/cache" +HOST_CACHE="/tmp/.host-aws/sso/cache" +MARKER="/tmp/.sso-refresh-marker" + +touch "$MARKER" + +# Emit marker for Triple-C app to detect in terminal output +echo "###TRIPLE_C_SSO_REFRESH###" +echo "Waiting for SSO login to complete on host..." + +TIMEOUT=120 +ELAPSED=0 +while [ $ELAPSED -lt $TIMEOUT ]; do + if [ -d "$HOST_CACHE" ]; then + NEW=$(find "$HOST_CACHE" -name "*.json" -newer "$MARKER" 2>/dev/null | head -1) + if [ -n "$NEW" ]; then + mkdir -p "$CACHE_DIR" + cp -f "$HOST_CACHE"/*.json "$CACHE_DIR/" 2>/dev/null + chown -R "$(whoami)" "$CACHE_DIR" + echo "AWS SSO credentials refreshed successfully." + rm -f "$MARKER" + exit 0 + fi + fi + sleep 2 + ELAPSED=$((ELAPSED + 2)) +done + +echo "SSO refresh timed out (${TIMEOUT}s). Please try again." +rm -f "$MARKER" +exit 1