Compare commits
2 Commits
v0.1.80
...
v0.1.82-ma
| Author | SHA1 | Date | |
|---|---|---|---|
| 58a10c65e9 | |||
| d56c6e3845 |
@@ -1,7 +1,74 @@
|
|||||||
use tauri::{AppHandle, Emitter, State};
|
use tauri::{AppHandle, Emitter, State};
|
||||||
|
|
||||||
|
use crate::models::{AuthMode, BedrockAuthMethod, Project};
|
||||||
use crate::AppState;
|
use crate::AppState;
|
||||||
|
|
||||||
|
/// Build the command to run in the container terminal.
|
||||||
|
///
|
||||||
|
/// For Bedrock Profile projects, wraps `claude` in a bash script that validates
|
||||||
|
/// the AWS session first. If the SSO session is expired, runs `aws sso login`
|
||||||
|
/// so the user can re-authenticate (the URL is clickable via xterm.js WebLinksAddon).
|
||||||
|
fn build_terminal_cmd(project: &Project, state: &AppState) -> Vec<String> {
|
||||||
|
let is_bedrock_profile = project.auth_mode == AuthMode::Bedrock
|
||||||
|
&& project
|
||||||
|
.bedrock_config
|
||||||
|
.as_ref()
|
||||||
|
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
|
||||||
|
.unwrap_or(false);
|
||||||
|
|
||||||
|
if !is_bedrock_profile {
|
||||||
|
return vec![
|
||||||
|
"claude".to_string(),
|
||||||
|
"--dangerously-skip-permissions".to_string(),
|
||||||
|
];
|
||||||
|
}
|
||||||
|
|
||||||
|
// Resolve AWS profile: project-level → global settings → "default"
|
||||||
|
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());
|
||||||
|
|
||||||
|
// Build a bash wrapper that validates credentials, re-auths if needed,
|
||||||
|
// then exec's into claude.
|
||||||
|
let script = format!(
|
||||||
|
r#"
|
||||||
|
echo "Validating AWS session for profile '{profile}'..."
|
||||||
|
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:"
|
||||||
|
echo ""
|
||||||
|
aws sso login --profile '{profile}'
|
||||||
|
if [ $? -ne 0 ]; then
|
||||||
|
echo ""
|
||||||
|
echo "SSO login failed or was cancelled. Starting Claude anyway..."
|
||||||
|
echo "You may see authentication errors."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
else
|
||||||
|
echo "Profile '{profile}' does not use SSO. Check your AWS credentials."
|
||||||
|
echo "Starting Claude anyway..."
|
||||||
|
echo ""
|
||||||
|
fi
|
||||||
|
fi
|
||||||
|
exec claude --dangerously-skip-permissions
|
||||||
|
"#,
|
||||||
|
profile = profile
|
||||||
|
);
|
||||||
|
|
||||||
|
vec![
|
||||||
|
"bash".to_string(),
|
||||||
|
"-c".to_string(),
|
||||||
|
script,
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn open_terminal_session(
|
pub async fn open_terminal_session(
|
||||||
project_id: String,
|
project_id: String,
|
||||||
@@ -19,10 +86,7 @@ pub async fn open_terminal_session(
|
|||||||
.as_ref()
|
.as_ref()
|
||||||
.ok_or_else(|| "Container not running".to_string())?;
|
.ok_or_else(|| "Container not running".to_string())?;
|
||||||
|
|
||||||
let cmd = vec![
|
let cmd = build_terminal_cmd(&project, &state);
|
||||||
"claude".to_string(),
|
|
||||||
"--dangerously-skip-permissions".to_string(),
|
|
||||||
];
|
|
||||||
|
|
||||||
let output_event = format!("terminal-output-{}", session_id);
|
let output_event = format!("terminal-output-{}", session_id);
|
||||||
let exit_event = format!("terminal-exit-{}", session_id);
|
let exit_event = format!("terminal-exit-{}", session_id);
|
||||||
|
|||||||
@@ -508,7 +508,7 @@ pub async fn create_container(
|
|||||||
if let Some(ref aws_path) = aws_dir {
|
if let Some(ref aws_path) = aws_dir {
|
||||||
if aws_path.exists() {
|
if aws_path.exists() {
|
||||||
mounts.push(Mount {
|
mounts.push(Mount {
|
||||||
target: Some("/home/claude/.aws".to_string()),
|
target: Some("/tmp/.host-aws".to_string()),
|
||||||
source: Some(aws_path.to_string_lossy().to_string()),
|
source: Some(aws_path.to_string_lossy().to_string()),
|
||||||
typ: Some(MountTypeEnum::BIND),
|
typ: Some(MountTypeEnum::BIND),
|
||||||
read_only: Some(true),
|
read_only: Some(true),
|
||||||
|
|||||||
@@ -82,6 +82,25 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
// Send initial size
|
// Send initial size
|
||||||
resize(sessionId, term.cols, term.rows);
|
resize(sessionId, term.cols, term.rows);
|
||||||
|
|
||||||
|
// Handle OSC 52 clipboard write sequences from programs inside the container.
|
||||||
|
// When a program (e.g. Claude Code) copies text via xclip/xsel/pbcopy, the
|
||||||
|
// container's shim emits an OSC 52 escape sequence which xterm.js routes here.
|
||||||
|
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||||
|
const idx = data.indexOf(";");
|
||||||
|
if (idx === -1) return false;
|
||||||
|
const payload = data.substring(idx + 1);
|
||||||
|
if (payload === "?") return false; // clipboard read request, not supported
|
||||||
|
try {
|
||||||
|
const decoded = atob(payload);
|
||||||
|
navigator.clipboard.writeText(decoded).catch((e) =>
|
||||||
|
console.error("OSC 52 clipboard write failed:", e),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
console.error("OSC 52 decode failed:", e);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
// Handle user input -> backend
|
// Handle user input -> backend
|
||||||
const inputDisposable = term.onData((data) => {
|
const inputDisposable = term.onData((data) => {
|
||||||
sendInput(sessionId, data);
|
sendInput(sessionId, data);
|
||||||
@@ -170,6 +189,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
|||||||
aborted = true;
|
aborted = true;
|
||||||
detector.dispose();
|
detector.dispose();
|
||||||
detectorRef.current = null;
|
detectorRef.current = null;
|
||||||
|
osc52Disposable.dispose();
|
||||||
inputDisposable.dispose();
|
inputDisposable.dispose();
|
||||||
scrollDisposable.dispose();
|
scrollDisposable.dispose();
|
||||||
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
containerRef.current?.removeEventListener("paste", handlePaste, { capture: true });
|
||||||
|
|||||||
59
app/src/components/terminal/osc52.test.ts
Normal file
59
app/src/components/terminal/osc52.test.ts
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
|
||||||
|
* Extracted here to validate the decode/write path independently.
|
||||||
|
*/
|
||||||
|
|
||||||
|
// Mirrors the handler registered in TerminalView.tsx
|
||||||
|
function handleOsc52(data: string): string | null {
|
||||||
|
const idx = data.indexOf(";");
|
||||||
|
if (idx === -1) return null;
|
||||||
|
const payload = data.substring(idx + 1);
|
||||||
|
if (payload === "?") return null;
|
||||||
|
try {
|
||||||
|
return atob(payload);
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
describe("OSC 52 clipboard handler", () => {
|
||||||
|
it("decodes a valid clipboard write sequence", () => {
|
||||||
|
// "c;BASE64" where BASE64 encodes "https://example.com"
|
||||||
|
const encoded = btoa("https://example.com");
|
||||||
|
const result = handleOsc52(`c;${encoded}`);
|
||||||
|
expect(result).toBe("https://example.com");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("decodes multi-line content", () => {
|
||||||
|
const text = "line1\nline2\nline3";
|
||||||
|
const encoded = btoa(text);
|
||||||
|
const result = handleOsc52(`c;${encoded}`);
|
||||||
|
expect(result).toBe(text);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles primary selection target (p)", () => {
|
||||||
|
const encoded = btoa("selected text");
|
||||||
|
const result = handleOsc52(`p;${encoded}`);
|
||||||
|
expect(result).toBe("selected text");
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for clipboard read request (?)", () => {
|
||||||
|
expect(handleOsc52("c;?")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for missing semicolon", () => {
|
||||||
|
expect(handleOsc52("invalid")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("returns null for invalid base64", () => {
|
||||||
|
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||||
|
});
|
||||||
|
|
||||||
|
it("handles empty payload after selection target", () => {
|
||||||
|
// btoa("") = ""
|
||||||
|
const result = handleOsc52("c;");
|
||||||
|
expect(result).toBe("");
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -101,6 +101,16 @@ WORKDIR /workspace
|
|||||||
|
|
||||||
# ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
|
# ── Switch back to root for entrypoint (handles UID/GID remapping) ─────────
|
||||||
USER root
|
USER root
|
||||||
|
|
||||||
|
# ── OSC 52 clipboard support ─────────────────────────────────────────────
|
||||||
|
# Provides xclip/xsel/pbcopy shims that emit OSC 52 escape sequences,
|
||||||
|
# allowing programs inside the container to copy to the host clipboard.
|
||||||
|
COPY osc52-clipboard /usr/local/bin/osc52-clipboard
|
||||||
|
RUN chmod +x /usr/local/bin/osc52-clipboard \
|
||||||
|
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xclip \
|
||||||
|
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/xsel \
|
||||||
|
&& ln -sf /usr/local/bin/osc52-clipboard /usr/local/bin/pbcopy
|
||||||
|
|
||||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||||
RUN chmod +x /usr/local/bin/entrypoint.sh
|
RUN chmod +x /usr/local/bin/entrypoint.sh
|
||||||
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
COPY triple-c-scheduler /usr/local/bin/triple-c-scheduler
|
||||||
|
|||||||
@@ -73,6 +73,19 @@ su -s /bin/bash claude -c '
|
|||||||
sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts
|
sort -u -o /home/claude/.ssh/known_hosts /home/claude/.ssh/known_hosts
|
||||||
'
|
'
|
||||||
|
|
||||||
|
# ── AWS config setup ──────────────────────────────────────────────────────────
|
||||||
|
# Host AWS dir is mounted read-only at /tmp/.host-aws.
|
||||||
|
# Copy to /home/claude/.aws so AWS CLI can write to sso/cache and cli/cache.
|
||||||
|
if [ -d /tmp/.host-aws ]; then
|
||||||
|
rm -rf /home/claude/.aws
|
||||||
|
cp -a /tmp/.host-aws /home/claude/.aws
|
||||||
|
chown -R claude:claude /home/claude/.aws
|
||||||
|
chmod 700 /home/claude/.aws
|
||||||
|
# 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
|
||||||
|
fi
|
||||||
|
|
||||||
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
|
# ── Git credential helper (for HTTPS token) ─────────────────────────────────
|
||||||
if [ -n "$GIT_TOKEN" ]; then
|
if [ -n "$GIT_TOKEN" ]; then
|
||||||
CRED_FILE="/home/claude/.git-credentials"
|
CRED_FILE="/home/claude/.git-credentials"
|
||||||
|
|||||||
26
container/osc52-clipboard
Normal file
26
container/osc52-clipboard
Normal file
@@ -0,0 +1,26 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
# OSC 52 clipboard provider — sends clipboard data to the host system clipboard
|
||||||
|
# via OSC 52 terminal escape sequences. Installed as xclip/xsel/pbcopy so that
|
||||||
|
# programs inside the container (e.g. Claude Code) can copy to clipboard.
|
||||||
|
#
|
||||||
|
# Supports common invocations:
|
||||||
|
# echo "text" | xclip -selection clipboard
|
||||||
|
# echo "text" | xsel --clipboard --input
|
||||||
|
# echo "text" | pbcopy
|
||||||
|
#
|
||||||
|
# Paste/output requests exit silently (not supported via OSC 52).
|
||||||
|
|
||||||
|
# Detect paste/output mode — exit silently since we can't read the host clipboard
|
||||||
|
for arg in "$@"; do
|
||||||
|
case "$arg" in
|
||||||
|
-o|--output) exit 0 ;;
|
||||||
|
esac
|
||||||
|
done
|
||||||
|
|
||||||
|
# Read all input from stdin
|
||||||
|
data=$(cat)
|
||||||
|
[ -z "$data" ] && exit 0
|
||||||
|
|
||||||
|
# Base64 encode and write OSC 52 escape sequence to the controlling terminal
|
||||||
|
encoded=$(printf '%s' "$data" | base64 | tr -d '\n')
|
||||||
|
printf '\033]52;c;%s\a' "$encoded" > /dev/tty 2>/dev/null
|
||||||
Reference in New Issue
Block a user