Add AWS Bedrock auth mode with per-project configuration
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:
2026-02-27 14:29:40 +00:00
parent 625260b060
commit ca51e73924
8 changed files with 332 additions and 11 deletions

View File

@@ -1,6 +1,6 @@
import { useState } from "react";
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 { useTerminal } from "../../hooks/useTerminal";
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) => {
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) {
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 selected = await open({ directory: true, multiple: false });
if (selected) {
@@ -122,16 +145,24 @@ export default function ProjectCard({ project }: Props) {
>
API key
</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>
{/* Action buttons */}
<div className="flex items-center gap-1">
{isStopped ? (
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
) : project.status === "running" ? (
<>
<ActionButton onClick={handleStop} disabled={loading} label="Stop" />
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
<ActionButton onClick={handleStart} disabled={loading} label="Start" />
<ActionButton
onClick={async () => {
setLoading(true);
@@ -142,6 +173,11 @@ export default function ProjectCard({ project }: Props) {
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)]">
{project.status}...
@@ -250,6 +286,124 @@ export default function ProjectCard({ project }: Props) {
{project.allow_docker_access ? "ON" : "OFF"}
</button>
</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>

View File

@@ -25,7 +25,7 @@ export default function ApiKeyInput() {
<div>
<label className="block text-sm font-medium mb-1">Authentication</label>
<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>
<label className="block text-xs text-[var(--text-secondary)] mb-1 mt-3">

View File

@@ -7,6 +7,11 @@ import { openUrl } from "@tauri-apps/plugin-opener";
import "@xterm/xterm/css/xterm.css";
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 {
sessionId: string;
active: boolean;
@@ -52,10 +57,13 @@ export default function TerminalView({ sessionId, active }: Props) {
const fitAddon = new 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) => {
openUrl(uri).catch((e) => console.error("Failed to open URL:", e));
});
}, { urlRegex });
term.loadAddon(webLinksAddon);
term.open(containerRef.current);
@@ -80,12 +88,49 @@ export default function TerminalView({ sessionId, active }: Props) {
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
let unlistenOutput: (() => void) | null = null;
let unlistenExit: (() => void) | null = null;
onOutput(sessionId, (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) => {
unlistenOutput = unlisten;
});
@@ -104,6 +149,7 @@ export default function TerminalView({ sessionId, active }: Props) {
resizeObserver.observe(containerRef.current);
return () => {
if (debounceTimer) clearTimeout(debounceTimer);
inputDisposable.dispose();
unlistenOutput?.();
unlistenExit?.();

View File

@@ -5,6 +5,7 @@ export interface Project {
container_id: string | null;
status: ProjectStatus;
auth_mode: AuthMode;
bedrock_config: BedrockConfig | null;
allow_docker_access: boolean;
ssh_key_path: string | null;
git_token: string | null;
@@ -21,7 +22,21 @@ export type ProjectStatus =
| "stopping"
| "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 {
container_id: string;