Add AWS Bedrock auth mode with per-project configuration
All checks were successful
Build Container / build-container (push) Successful in 3m29s
All checks were successful
Build Container / build-container (push) Successful in 3m29s
Introduces a third auth mode alongside Login and API Key, allowing projects to authenticate Claude Code via AWS Bedrock. Includes support for static credentials, profile-based, and bearer-token auth methods with full UI controls. Also adds a URL accumulator to the terminal to reassemble long OAuth URLs split across hard newlines, and installs the AWS CLI v2 in the container image. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -69,6 +69,15 @@ pub async fn start_project_container(
|
|||||||
// Auth state persists in the .claude config volume.
|
// Auth state persists in the .claude config volume.
|
||||||
None
|
None
|
||||||
}
|
}
|
||||||
|
AuthMode::Bedrock => {
|
||||||
|
// Bedrock mode: no Anthropic API key needed, uses AWS credentials.
|
||||||
|
let bedrock = project.bedrock_config.as_ref()
|
||||||
|
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
|
||||||
|
if bedrock.aws_region.is_empty() {
|
||||||
|
return Err("AWS region is required for Bedrock auth mode.".to_string());
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// Update status to starting
|
// Update status to starting
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ use bollard::models::{ContainerSummary, HostConfig, Mount, MountTypeEnum};
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
|
||||||
use super::client::get_docker;
|
use super::client::get_docker;
|
||||||
use crate::models::{container_config, ContainerInfo, Project};
|
use crate::models::{container_config, AuthMode, BedrockAuthMethod, ContainerInfo, Project};
|
||||||
|
|
||||||
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
pub async fn find_existing_container(project: &Project) -> Result<Option<String>, String> {
|
||||||
let docker = get_docker()?;
|
let docker = get_docker()?;
|
||||||
@@ -78,6 +78,46 @@ pub async fn create_container(
|
|||||||
env_vars.push(format!("GIT_USER_EMAIL={}", email));
|
env_vars.push(format!("GIT_USER_EMAIL={}", email));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Bedrock configuration
|
||||||
|
if project.auth_mode == AuthMode::Bedrock {
|
||||||
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
|
env_vars.push("CLAUDE_CODE_USE_BEDROCK=1".to_string());
|
||||||
|
env_vars.push(format!("AWS_REGION={}", bedrock.aws_region));
|
||||||
|
|
||||||
|
match bedrock.auth_method {
|
||||||
|
BedrockAuthMethod::StaticCredentials => {
|
||||||
|
if let Some(ref key_id) = bedrock.aws_access_key_id {
|
||||||
|
env_vars.push(format!("AWS_ACCESS_KEY_ID={}", key_id));
|
||||||
|
}
|
||||||
|
if let Some(ref secret) = bedrock.aws_secret_access_key {
|
||||||
|
env_vars.push(format!("AWS_SECRET_ACCESS_KEY={}", secret));
|
||||||
|
}
|
||||||
|
if let Some(ref token) = bedrock.aws_session_token {
|
||||||
|
env_vars.push(format!("AWS_SESSION_TOKEN={}", token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BedrockAuthMethod::Profile => {
|
||||||
|
if let Some(ref profile) = bedrock.aws_profile {
|
||||||
|
env_vars.push(format!("AWS_PROFILE={}", profile));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
BedrockAuthMethod::BearerToken => {
|
||||||
|
if let Some(ref token) = bedrock.aws_bearer_token {
|
||||||
|
env_vars.push(format!("AWS_BEARER_TOKEN_BEDROCK={}", token));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if let Some(ref model) = bedrock.model_id {
|
||||||
|
env_vars.push(format!("ANTHROPIC_MODEL={}", model));
|
||||||
|
}
|
||||||
|
|
||||||
|
if bedrock.disable_prompt_caching {
|
||||||
|
env_vars.push("DISABLE_PROMPT_CACHING=1".to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let mut mounts = vec![
|
let mut mounts = vec![
|
||||||
// Project directory -> /workspace
|
// Project directory -> /workspace
|
||||||
Mount {
|
Mount {
|
||||||
@@ -108,6 +148,26 @@ pub async fn create_container(
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AWS config mount (read-only, for profile-based auth)
|
||||||
|
if project.auth_mode == AuthMode::Bedrock {
|
||||||
|
if let Some(ref bedrock) = project.bedrock_config {
|
||||||
|
if bedrock.auth_method == BedrockAuthMethod::Profile {
|
||||||
|
if let Some(home) = dirs::home_dir() {
|
||||||
|
let aws_dir = home.join(".aws");
|
||||||
|
if aws_dir.exists() {
|
||||||
|
mounts.push(Mount {
|
||||||
|
target: Some("/home/claude/.aws".to_string()),
|
||||||
|
source: Some(aws_dir.to_string_lossy().to_string()),
|
||||||
|
typ: Some(MountTypeEnum::BIND),
|
||||||
|
read_only: Some(true),
|
||||||
|
..Default::default()
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Docker socket (only if allowed)
|
// Docker socket (only if allowed)
|
||||||
if project.allow_docker_access {
|
if project.allow_docker_access {
|
||||||
mounts.push(Mount {
|
mounts.push(Mount {
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ pub struct Project {
|
|||||||
pub container_id: Option<String>,
|
pub container_id: Option<String>,
|
||||||
pub status: ProjectStatus,
|
pub status: ProjectStatus,
|
||||||
pub auth_mode: AuthMode,
|
pub auth_mode: AuthMode,
|
||||||
|
pub bedrock_config: Option<BedrockConfig>,
|
||||||
pub allow_docker_access: bool,
|
pub allow_docker_access: bool,
|
||||||
pub ssh_key_path: Option<String>,
|
pub ssh_key_path: Option<String>,
|
||||||
pub git_token: Option<String>,
|
pub git_token: Option<String>,
|
||||||
@@ -30,11 +31,13 @@ pub enum ProjectStatus {
|
|||||||
/// How the project authenticates with Claude.
|
/// How the project authenticates with Claude.
|
||||||
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
/// - `Login`: User runs `claude login` inside the container (OAuth, persisted via config volume)
|
||||||
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
/// - `ApiKey`: Uses the API key stored in the OS keychain
|
||||||
|
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
#[serde(rename_all = "snake_case")]
|
#[serde(rename_all = "snake_case")]
|
||||||
pub enum AuthMode {
|
pub enum AuthMode {
|
||||||
Login,
|
Login,
|
||||||
ApiKey,
|
ApiKey,
|
||||||
|
Bedrock,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for AuthMode {
|
impl Default for AuthMode {
|
||||||
@@ -43,6 +46,35 @@ impl Default for AuthMode {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// How Bedrock authenticates with AWS.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum BedrockAuthMethod {
|
||||||
|
StaticCredentials,
|
||||||
|
Profile,
|
||||||
|
BearerToken,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for BedrockAuthMethod {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self::StaticCredentials
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// AWS Bedrock configuration for a project.
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct BedrockConfig {
|
||||||
|
pub auth_method: BedrockAuthMethod,
|
||||||
|
pub aws_region: String,
|
||||||
|
pub aws_access_key_id: Option<String>,
|
||||||
|
pub aws_secret_access_key: Option<String>,
|
||||||
|
pub aws_session_token: Option<String>,
|
||||||
|
pub aws_profile: Option<String>,
|
||||||
|
pub aws_bearer_token: Option<String>,
|
||||||
|
pub model_id: Option<String>,
|
||||||
|
pub disable_prompt_caching: bool,
|
||||||
|
}
|
||||||
|
|
||||||
impl Project {
|
impl Project {
|
||||||
pub fn new(name: String, path: String) -> Self {
|
pub fn new(name: String, path: String) -> Self {
|
||||||
let now = chrono::Utc::now().to_rfc3339();
|
let now = chrono::Utc::now().to_rfc3339();
|
||||||
@@ -53,6 +85,7 @@ impl Project {
|
|||||||
container_id: None,
|
container_id: None,
|
||||||
status: ProjectStatus::Stopped,
|
status: ProjectStatus::Stopped,
|
||||||
auth_mode: AuthMode::default(),
|
auth_mode: AuthMode::default(),
|
||||||
|
bedrock_config: None,
|
||||||
allow_docker_access: false,
|
allow_docker_access: false,
|
||||||
ssh_key_path: None,
|
ssh_key_path: None,
|
||||||
git_token: None,
|
git_token: None,
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { useState } from "react";
|
import { useState } from "react";
|
||||||
import { open } from "@tauri-apps/plugin-dialog";
|
import { open } from "@tauri-apps/plugin-dialog";
|
||||||
import type { Project, AuthMode } from "../../lib/types";
|
import type { Project, AuthMode, BedrockConfig, BedrockAuthMethod } from "../../lib/types";
|
||||||
import { useProjects } from "../../hooks/useProjects";
|
import { useProjects } from "../../hooks/useProjects";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
import { useAppState } from "../../store/appState";
|
import { useAppState } from "../../store/appState";
|
||||||
@@ -51,14 +51,37 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const defaultBedrockConfig: BedrockConfig = {
|
||||||
|
auth_method: "static_credentials",
|
||||||
|
aws_region: "us-east-1",
|
||||||
|
aws_access_key_id: null,
|
||||||
|
aws_secret_access_key: null,
|
||||||
|
aws_session_token: null,
|
||||||
|
aws_profile: null,
|
||||||
|
aws_bearer_token: null,
|
||||||
|
model_id: null,
|
||||||
|
disable_prompt_caching: false,
|
||||||
|
};
|
||||||
|
|
||||||
const handleAuthModeChange = async (mode: AuthMode) => {
|
const handleAuthModeChange = async (mode: AuthMode) => {
|
||||||
try {
|
try {
|
||||||
await update({ ...project, auth_mode: mode });
|
const updates: Partial<Project> = { auth_mode: mode };
|
||||||
|
if (mode === "bedrock" && !project.bedrock_config) {
|
||||||
|
updates.bedrock_config = defaultBedrockConfig;
|
||||||
|
}
|
||||||
|
await update({ ...project, ...updates });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
setError(String(e));
|
setError(String(e));
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const updateBedrockConfig = async (patch: Partial<BedrockConfig>) => {
|
||||||
|
try {
|
||||||
|
const current = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
await update({ ...project, bedrock_config: { ...current, ...patch } });
|
||||||
|
} catch {}
|
||||||
|
};
|
||||||
|
|
||||||
const handleBrowseSSH = async () => {
|
const handleBrowseSSH = async () => {
|
||||||
const selected = await open({ directory: true, multiple: false });
|
const selected = await open({ directory: true, multiple: false });
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -122,16 +145,24 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
>
|
>
|
||||||
API key
|
API key
|
||||||
</button>
|
</button>
|
||||||
|
<button
|
||||||
|
onClick={(e) => { e.stopPropagation(); handleAuthModeChange("bedrock"); }}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={`px-2 py-0.5 rounded transition-colors ${
|
||||||
|
project.auth_mode === "bedrock"
|
||||||
|
? "bg-[var(--accent)] text-white"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
Bedrock
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Action buttons */}
|
{/* Action buttons */}
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
{isStopped ? (
|
{isStopped ? (
|
||||||
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
|
||||||
) : project.status === "running" ? (
|
|
||||||
<>
|
<>
|
||||||
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
|
||||||
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
|
||||||
<ActionButton
|
<ActionButton
|
||||||
onClick={async () => {
|
onClick={async () => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -142,6 +173,11 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
label="Reset"
|
label="Reset"
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
) : project.status === "running" ? (
|
||||||
|
<>
|
||||||
|
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
|
||||||
|
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<span className="text-xs text-[var(--text-secondary)]">
|
<span className="text-xs text-[var(--text-secondary)]">
|
||||||
{project.status}...
|
{project.status}...
|
||||||
@@ -250,6 +286,124 @@ export default function ProjectCard({ project }: Props) {
|
|||||||
{project.allow_docker_access ? "ON" : "OFF"}
|
{project.allow_docker_access ? "ON" : "OFF"}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Bedrock config */}
|
||||||
|
{project.auth_mode === "bedrock" && (() => {
|
||||||
|
const bc = project.bedrock_config ?? defaultBedrockConfig;
|
||||||
|
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50";
|
||||||
|
return (
|
||||||
|
<div className="space-y-2 pt-1 border-t border-[var(--border-color)]">
|
||||||
|
<label className="block text-xs font-medium text-[var(--text-primary)]">AWS Bedrock</label>
|
||||||
|
|
||||||
|
{/* Sub-method selector */}
|
||||||
|
<div className="flex items-center gap-1 text-xs">
|
||||||
|
<span className="text-[var(--text-secondary)] mr-1">Method:</span>
|
||||||
|
{(["static_credentials", "profile", "bearer_token"] as BedrockAuthMethod[]).map((m) => (
|
||||||
|
<button
|
||||||
|
key={m}
|
||||||
|
onClick={() => updateBedrockConfig({ auth_method: m })}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={`px-2 py-0.5 rounded transition-colors ${
|
||||||
|
bc.auth_method === m
|
||||||
|
? "bg-[var(--accent)] text-white"
|
||||||
|
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)]"
|
||||||
|
} disabled:opacity-50`}
|
||||||
|
>
|
||||||
|
{m === "static_credentials" ? "Keys" : m === "profile" ? "Profile" : "Token"}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* AWS Region (always shown) */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Region</label>
|
||||||
|
<input
|
||||||
|
value={bc.aws_region}
|
||||||
|
onChange={(e) => updateBedrockConfig({ aws_region: e.target.value })}
|
||||||
|
placeholder="us-east-1"
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Static credentials fields */}
|
||||||
|
{bc.auth_method === "static_credentials" && (
|
||||||
|
<>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Access Key ID</label>
|
||||||
|
<input
|
||||||
|
value={bc.aws_access_key_id ?? ""}
|
||||||
|
onChange={(e) => updateBedrockConfig({ aws_access_key_id: e.target.value || null })}
|
||||||
|
placeholder="AKIA..."
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Secret Access Key</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={bc.aws_secret_access_key ?? ""}
|
||||||
|
onChange={(e) => updateBedrockConfig({ aws_secret_access_key: e.target.value || null })}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Session Token (optional)</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={bc.aws_session_token ?? ""}
|
||||||
|
onChange={(e) => updateBedrockConfig({ aws_session_token: e.target.value || null })}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Profile field */}
|
||||||
|
{bc.auth_method === "profile" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">AWS Profile</label>
|
||||||
|
<input
|
||||||
|
value={bc.aws_profile ?? ""}
|
||||||
|
onChange={(e) => updateBedrockConfig({ aws_profile: e.target.value || null })}
|
||||||
|
placeholder="default"
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Bearer token field */}
|
||||||
|
{bc.auth_method === "bearer_token" && (
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Bearer Token</label>
|
||||||
|
<input
|
||||||
|
type="password"
|
||||||
|
value={bc.aws_bearer_token ?? ""}
|
||||||
|
onChange={(e) => updateBedrockConfig({ aws_bearer_token: e.target.value || null })}
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Model override */}
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Model ID (optional)</label>
|
||||||
|
<input
|
||||||
|
value={bc.model_id ?? ""}
|
||||||
|
onChange={(e) => updateBedrockConfig({ model_id: e.target.value || null })}
|
||||||
|
placeholder="anthropic.claude-sonnet-4-20250514-v1:0"
|
||||||
|
disabled={!isStopped}
|
||||||
|
className={inputCls}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})()}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -25,7 +25,7 @@ export default function ApiKeyInput() {
|
|||||||
<div>
|
<div>
|
||||||
<label className="block text-sm font-medium mb-1">Authentication</label>
|
<label className="block text-sm font-medium mb-1">Authentication</label>
|
||||||
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
<p className="text-xs text-[var(--text-secondary)] mb-3">
|
||||||
Each project can use either <strong>claude login</strong> (OAuth, run inside the terminal) or an <strong>API key</strong>. Set auth mode per-project.
|
Each project can use <strong>claude login</strong> (OAuth, run inside the terminal), an <strong>API key</strong>, or <strong>AWS Bedrock</strong>. Set auth mode per-project.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
|
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">
|
||||||
|
|||||||
@@ -7,6 +7,11 @@ import { openUrl } from "@tauri-apps/plugin-opener";
|
|||||||
import "@xterm/xterm/css/xterm.css";
|
import "@xterm/xterm/css/xterm.css";
|
||||||
import { useTerminal } from "../../hooks/useTerminal";
|
import { useTerminal } from "../../hooks/useTerminal";
|
||||||
|
|
||||||
|
/** Strip ANSI escape sequences from a string. */
|
||||||
|
function stripAnsi(s: string): string {
|
||||||
|
return s.replace(/\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07]*\x07/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
sessionId: string;
|
sessionId: string;
|
||||||
active: boolean;
|
active: boolean;
|
||||||
@@ -52,10 +57,13 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
const fitAddon = new FitAddon();
|
const fitAddon = new FitAddon();
|
||||||
term.loadAddon(fitAddon);
|
term.loadAddon(fitAddon);
|
||||||
|
|
||||||
// Web links addon — opens URLs in host browser via Tauri
|
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
||||||
|
// that matches URLs even if they lack trailing path segments (the default regex
|
||||||
|
// misses OAuth URLs that end mid-line).
|
||||||
|
const urlRegex = /https?:\/\/[^\s'"\x07]+/;
|
||||||
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
const webLinksAddon = new WebLinksAddon((_event, uri) => {
|
||||||
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
|
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
|
||||||
});
|
}, { urlRegex });
|
||||||
term.loadAddon(webLinksAddon);
|
term.loadAddon(webLinksAddon);
|
||||||
|
|
||||||
term.open(containerRef.current);
|
term.open(containerRef.current);
|
||||||
@@ -80,12 +88,49 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// ── URL accumulator ──────────────────────────────────────────────
|
||||||
|
// Claude Code login emits a long OAuth URL that gets split across
|
||||||
|
// hard newlines (\n / \r\n). The WebLinksAddon only joins
|
||||||
|
// soft-wrapped lines (the `isWrapped` flag), so the URL match is
|
||||||
|
// truncated and the link fails when clicked.
|
||||||
|
//
|
||||||
|
// Fix: buffer recent output, strip ANSI codes, and after a short
|
||||||
|
// debounce check for a URL that spans multiple lines. When found,
|
||||||
|
// write a single clean clickable copy to the terminal.
|
||||||
|
let outputBuffer = "";
|
||||||
|
let debounceTimer: ReturnType<typeof setTimeout> | null = null;
|
||||||
|
|
||||||
|
const flushUrlBuffer = () => {
|
||||||
|
const plain = stripAnsi(outputBuffer);
|
||||||
|
// Reassemble: strip hard newlines and carriage returns to join
|
||||||
|
// fragments that were split across terminal lines.
|
||||||
|
const joined = plain.replace(/[\r\n]+/g, "");
|
||||||
|
// Look for a long OAuth/auth URL (Claude login URLs contain
|
||||||
|
// "oauth" or "console.anthropic.com" or "/authorize").
|
||||||
|
const match = joined.match(/https?:\/\/[^\s'"\x07]{80,}/);
|
||||||
|
if (match) {
|
||||||
|
const url = match[0];
|
||||||
|
term.write("\r\n\x1b[36m🔗 Clickable login URL:\x1b[0m\r\n");
|
||||||
|
term.write(`\x1b[4;34m${url}\x1b[0m\r\n`);
|
||||||
|
}
|
||||||
|
outputBuffer = "";
|
||||||
|
};
|
||||||
|
|
||||||
// Handle backend output -> terminal
|
// Handle backend output -> terminal
|
||||||
let unlistenOutput: (() => void) | null = null;
|
let unlistenOutput: (() => void) | null = null;
|
||||||
let unlistenExit: (() => void) | null = null;
|
let unlistenExit: (() => void) | null = null;
|
||||||
|
|
||||||
onOutput(sessionId, (data) => {
|
onOutput(sessionId, (data) => {
|
||||||
term.write(data);
|
term.write(data);
|
||||||
|
|
||||||
|
// Accumulate for URL detection
|
||||||
|
outputBuffer += data;
|
||||||
|
// Cap buffer size to avoid memory growth
|
||||||
|
if (outputBuffer.length > 8192) {
|
||||||
|
outputBuffer = outputBuffer.slice(-4096);
|
||||||
|
}
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
|
debounceTimer = setTimeout(flushUrlBuffer, 150);
|
||||||
}).then((unlisten) => {
|
}).then((unlisten) => {
|
||||||
unlistenOutput = unlisten;
|
unlistenOutput = unlisten;
|
||||||
});
|
});
|
||||||
@@ -104,6 +149,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
resizeObserver.observe(containerRef.current);
|
resizeObserver.observe(containerRef.current);
|
||||||
|
|
||||||
return () => {
|
return () => {
|
||||||
|
if (debounceTimer) clearTimeout(debounceTimer);
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
unlistenOutput?.();
|
unlistenOutput?.();
|
||||||
unlistenExit?.();
|
unlistenExit?.();
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface Project {
|
|||||||
container_id: string | null;
|
container_id: string | null;
|
||||||
status: ProjectStatus;
|
status: ProjectStatus;
|
||||||
auth_mode: AuthMode;
|
auth_mode: AuthMode;
|
||||||
|
bedrock_config: BedrockConfig | null;
|
||||||
allow_docker_access: boolean;
|
allow_docker_access: boolean;
|
||||||
ssh_key_path: string | null;
|
ssh_key_path: string | null;
|
||||||
git_token: string | null;
|
git_token: string | null;
|
||||||
@@ -21,7 +22,21 @@ export type ProjectStatus =
|
|||||||
| "stopping"
|
| "stopping"
|
||||||
| "error";
|
| "error";
|
||||||
|
|
||||||
export type AuthMode = "login" | "api_key";
|
export type AuthMode = "login" | "api_key" | "bedrock";
|
||||||
|
|
||||||
|
export type BedrockAuthMethod = "static_credentials" | "profile" | "bearer_token";
|
||||||
|
|
||||||
|
export interface BedrockConfig {
|
||||||
|
auth_method: BedrockAuthMethod;
|
||||||
|
aws_region: string;
|
||||||
|
aws_access_key_id: string | null;
|
||||||
|
aws_secret_access_key: string | null;
|
||||||
|
aws_session_token: string | null;
|
||||||
|
aws_profile: string | null;
|
||||||
|
aws_bearer_token: string | null;
|
||||||
|
model_id: string | null;
|
||||||
|
disable_prompt_caching: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
export interface ContainerInfo {
|
export interface ContainerInfo {
|
||||||
container_id: string;
|
container_id: string;
|
||||||
|
|||||||
@@ -60,6 +60,10 @@ RUN install -m 0755 -d /etc/apt/keyrings \
|
|||||||
&& apt-get update && apt-get install -y docker-ce-cli \
|
&& apt-get update && apt-get install -y docker-ce-cli \
|
||||||
&& rm -rf /var/lib/apt/lists/*
|
&& rm -rf /var/lib/apt/lists/*
|
||||||
|
|
||||||
|
# ── AWS CLI v2 ───────────────────────────────────────────────────────────────
|
||||||
|
RUN curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip" \
|
||||||
|
&& unzip awscliv2.zip && ./aws/install && rm -rf awscliv2.zip aws
|
||||||
|
|
||||||
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
# ── Non-root user with passwordless sudo ─────────────────────────────────────
|
||||||
RUN useradd -m -s /bin/bash claude \
|
RUN useradd -m -s /bin/bash claude \
|
||||||
&& echo "claude ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/claude \
|
&& echo "claude ALL=(ALL) NOPASSWD:ALL" > /etc/sudoers.d/claude \
|
||||||
|
|||||||
Reference in New Issue
Block a user