Compare commits

..

21 Commits

Author SHA1 Message Date
jknapp 59d89bcd1b Merge pull request 'Rename backup archive root so extraction dir mode isn't clobbered' (#10) from fix/backup-root-dir-mode into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 4m35s
Build App / build-linux (push) Successful in 5m1s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
2026-07-01 13:35:02 +00:00
shadow-test 26adccce5b Rename backup archive root so extraction dir mode isn't clobbered
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m14s
Build App / build-windows (pull_request) Successful in 4m26s
Build App / build-linux (pull_request) Successful in 5m0s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The transform used `s,^\./,workspace/,`, which rewrites the workspace
*contents* (`./foo` -> `workspace/foo`) but leaves tar's root member as a
bare `./`. That `./` entry carries the source root's mode/mtime, and on
extraction tar stamps them onto the extraction directory itself.

Match the leading `.` instead (`s,^\.,workspace,`) so the root member is
renamed `./` -> `workspace`, giving the archive a proper `workspace/`
directory entry and no bare `./`. The extraction directory is left
untouched. Contents, hidden files, excludes, symlink targets and the
`flags=rh` hardlink handling are unchanged.

Verified in-container: archive top level is exactly `workspace/` +
`home-claude/`, no `./` member, node_modules excluded, extraction into a
0755 dir leaves it 0755, workspace/.git preserved.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:33:02 -07:00
jknapp 876ba8a8fc Merge pull request 'Nest workspace under workspace/ in project backup' (#9) from fix/backup-workspace-nesting into main
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 4m36s
Build App / build-linux (push) Successful in 5m5s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
2026-07-01 13:23:08 +00:00
shadow-test 5cd528a4ef Use flags=rh so intra-workspace hardlinks survive the transform
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 4m24s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Review caught that `flags=r` disables rewriting of both symlink AND
hardlink target names. Leaving symlink targets alone is intended, but a
hardlink's stored target is an archive-internal reference to another
member's name — when member names become `workspace/...` but the
hardlink target stays `./hard_link`, extraction fails hard:

  tar: workspace/file.txt: Cannot hard link to './hard_link':
       No such file or directory

`flags=rh` rewrites regular member names and hardlink target names
together (keeping the pair consistent) while still leaving symlink
targets untouched. Verified in-container: extract exit 0, symlink target
preserved, hardlink pair shares one inode, nesting under workspace/ intact.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:22:14 -07:00
shadow-test c3fc029b1d Nest workspace under workspace/ in project backup
Build App / compute-version (pull_request) Successful in 4s
Build App / build-macos (pull_request) Successful in 2m17s
Build App / build-linux (pull_request) Successful in 5m7s
Build App / build-windows (pull_request) Successful in 5m9s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The backup archive placed the workspace at the archive root (`./...`)
while the sanitized home config sat under `home-claude/`. On extraction
the workspace files scattered loose into the extraction directory and
only `home-claude/` showed up as a distinct folder, so the backup read
as "config only, workspace missing" — and some archive viewers didn't
surface the root-level entries at all.

Add `--transform='flags=r;s,^\./,workspace/,'` so the workspace nests
under `workspace/`, parallel to `home-claude/`. `flags=r` scopes the
rewrite to member names only, leaving symlink targets (relative and
absolute) intact. Excludes still match the pre-transform names.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-01 06:18:08 -07:00
jknapp dc253e8da0 Merge pull request 'Fix backend-switch AWS auth + add /workspace backup and terminal file drag-and-drop' (#8) from fix/backend-switch-aws-creds into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 34s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 3m14s
Build App / build-linux (push) Successful in 6m22s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 10s
2026-06-30 22:20:00 +00:00
shadow-test 3e2e3f231b Third review pass: fix tar TOCTOU + transient backup status
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 31s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m9s
Build App / build-linux (pull_request) Successful in 5m53s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- F2: upload_host_file_to_container now reads the dropped file into a Vec
  inside the blocking task and sizes the tar entry from those exact bytes,
  rather than stat-then-stream where a file changing size between the
  stat and the read could desync the tar header and silently corrupt the
  archive. Still runs off the async worker; memory stays bounded by the
  256 MiB drop cap.
- F4: the "Backup saved" confirmation now auto-clears after 8s (guarded
  against clobbering a newer status message) instead of lingering in the
  project card's status line indefinitely.

F1 (claimed AWS CLI regression from empty-env neutralization) was a false
positive: verified against aws-cli 2.35 that an empty AWS_ACCESS_KEY_ID is
treated as absent and botocore falls through to ~/.aws/credentials (the
call reached AWS and returned InvalidClientTokenId for the file's key, not
PartialCredentialsError). No change needed.

cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:16:42 -07:00
shadow-test 01a8f5c503 Apply remaining review findings (L-e, L-f)
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 1m30s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m6s
Build App / build-linux (pull_request) Successful in 6m11s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L-e: route terminal file drops purely by a bounds hit-test instead of
  the `active` flag. Inactive panes are display:none (zero-size rect) so
  they never match; a zero-size guard makes that explicit. Correct for
  the current tabbed layout and future-proof for split panes, where a
  drop on a visible-but-unfocused pane previously matched no handler.
- L-f: stream the dropped file straight into the upload tar inside a
  blocking task (new exec::upload_host_file_to_container) instead of
  reading the whole file into a Vec and then re-packing it. Peak memory
  drops from ~2x to ~1x the file size, and the synchronous file IO no
  longer runs on the async worker.

cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 15:05:25 -07:00
shadow-test 0945e21eb1 Address second review pass: fix start-time race + minor cleanups
Build App / compute-version (pull_request) Successful in 9s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-macos (pull_request) Successful in 2m16s
Build App / build-windows (pull_request) Successful in 3m11s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- M1 (race): don't mount the host AWS dir for static-credential Bedrock.
  sync_bedrock_credentials() is the sole writer of ~/.aws/credentials in
  that mode, and mounting /tmp/.host-aws let the entrypoint's
  `rm -rf ~/.aws; cp -a` race that write at startup (only when a global
  aws_config_path was also set). Static keys + AWS_REGION env are
  self-sufficient and don't need the host config, so skipping the mount
  removes the dual-writer entirely.
- L-a: exit codes are now read via wait_for_exec_exit(), which polls
  inspect_exec until the exec reports finished, so a non-zero tar/cred
  exit isn't missed by reading exit_code too early. The backup only fails
  on a definitively non-zero code (falls back to the empty-output check
  if undeterminable).
- L-b: fixed two comments that referenced the old
  write_bedrock_static_credentials name (now sync_bedrock_credentials).
- L-c: entrypoint only rewrites ~/.claude.json when awsAuthRefresh is
  actually present, avoiding a needless jq reformat on every non-SSO
  start.
- L-d: backup script traps EXIT to remove its mktemp staging dir even
  when tar fails, so failed backups don't accumulate temp dirs (with the
  sanitized config copy) in the container.

L-e (drop routing) is a non-issue: the layout is tabbed, so only one
terminal pane is ever visible; the active-guard routing is correct.

Verified the race fix, trap cleanup, grep guard, and exit-code polling.
cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:59:48 -07:00
shadow-test d65872dc94 Address remaining review items: L4/M1 + L2/L5 cleanup
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 57s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m54s
Build App / build-linux (pull_request) Successful in 5m44s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
- L4: sync_bedrock_credentials (renamed from write_bedrock_static_
  credentials) now also clears a stale ~/.aws/credentials when the
  project no longer uses static-credential Bedrock, so static keys don't
  linger unused in the persistent home volume after switching backends.
  Skipped when /tmp/.host-aws is mounted (host-managed ~/.aws). HOME is
  also set explicitly on the exec env for robustness.
- M1: the Backup button now has a tooltip and the success toast notes
  that the archive includes MCP/config which may contain MCP-embedded
  API keys (OAuth tokens are excluded) — keep it private.
- L2: backup now uses async file IO (tokio::fs::File + AsyncWriteExt,
  tokio::fs::remove_file) instead of blocking std::fs between awaits;
  dropped-file reads use tokio::fs::metadata/read.
- L5: upload_host_file_to_terminal explicitly `mkdir -p`s
  /tmp/triple-c-drops instead of relying on Docker's tar extractor to
  create the parent dir.

Verified L4 cleanup guard, L5 mkdir, async IO, and exit-code paths
against real containers. cargo check / tsc / vitest all pass.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:37:33 -07:00
shadow-test edf0698774 Address PR review: backup correctness/security + drop hardening
Build App / compute-version (pull_request) Successful in 8s
Build Container / build-container (pull_request) Successful in 51s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 2m52s
Build App / build-linux (pull_request) Successful in 6m5s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Fixes from the code review of this branch:

- Backup requires a running container (it runs via `docker exec`, which
  can't run on a stopped one). Removed the misleading "Backup" button
  from the stopped-project actions, added an explicit running check with
  a clear error, and corrected the doc comment. (H1)
- jq sanitization fallback no longer leaks secrets: if ~/.claude.json
  can't be parsed, the backup substitutes an empty object and warns to
  stderr instead of copying the raw file (which held primaryApiKey /
  oauthAccount). Verified the raw key never reaches the archive. (H2)
- Dropped-file paths typed into the terminal are now always single-quoted
  (with '\'' escaping), not only when they contain whitespace — a name
  like `foo$(whoami).txt` was previously sent raw into the shell. (M2)
- write_bedrock_static_credentials checks the exec exit code via the new
  exec_oneshot_env_status and fails loudly on a write/chmod error instead
  of silently reporting success. exec_oneshot keeps its
  ignore-exit-code behavior so list_container_files is unaffected. (M4)
- Backup removes a partial/truncated archive on any stream error and
  treats a non-zero tar exit code as failure (a truncated gzip was
  previously reported as success). (L1)
- Dropped files are capped at 256 MiB to avoid ballooning host RAM
  (the file is read fully into memory then re-tarred). (M3)
- Stopped excluding .git/objects from the backup so git history,
  including unpushed commits, is preserved faithfully. (L3)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:28:00 -07:00
shadow-test 84e0bdf7b4 Add file drag-and-drop onto the terminal
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m25s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 4m14s
Build App / build-linux (pull_request) Successful in 4m54s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Drop files onto a terminal pane and they're copied into the container and
their in-container paths typed into the prompt, so Claude Code can read
them for reference — mirroring the existing image-paste flow.

Backend: upload_host_file_to_terminal reads the dropped host file and
writes it under /tmp/triple-c-drops/<name> in the session's container,
returning that path. Rejects directories and unreadable paths.

Frontend: TerminalView subscribes to Tauri's webview onDragDropEvent
(OS file drops are intercepted at the webview level, so HTML5 ondrop
wouldn't expose paths). The window-wide event is guarded by the pane's
`active` flag plus a bounds hit-test so a drop only affects the terminal
it landed on; multiple files are uploaded and their paths inserted
space-separated (quoted when they contain spaces).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:11:09 -07:00
shadow-test 10e689eaa6 Include sanitized home config in project backup
Extends download_container_backup to also capture the container's home
config so MCP servers, settings, and skills set up directly via Claude
Code (stored in ~/.claude.json / ~/.claude, on the home/config volumes
that a Reset wipes) survive a backup/restore cycle.

Secrets are stripped per the "exclude secrets" choice: ~/.claude.json is
filtered through jq to drop primaryApiKey/oauthAccount/customApiKeyResponses
(mcpServers and settings are kept), and ~/.claude/.credentials.json (the
OAuth tokens) is omitted. Staged config is archived under home-claude/ in
the tarball. Verified on the Ubuntu/jq container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 14:04:19 -07:00
shadow-test d07dcdfea9 Add "Backup" action to download a project's /workspace as .tar.gz
Adds a manual backup button on each project card (next to Start/Reset
when stopped, and next to Files when running) that saves a gzipped
tarball of the container's /workspace to a host path via the native save
dialog.

Backend: download_container_backup runs `tar czf -` inside the container
(so excludes + compression happen there rather than streaming a 16 GB
workspace) and pipes stdout straight to the chosen file. Regenerable
build artifacts (node_modules, target, .git/objects) are excluded so the
archive stays restore-sized. Returns bytes written; stderr is captured
for error reporting and a zero-byte result is treated as failure.

Works whether the container is running or stopped (only requires that it
exists). Verified on the Ubuntu/GNU-tar container base.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-30 13:48:04 -07:00
shadow-test 424ab04ca8 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>
2026-06-30 13:34:43 -07:00
jknapp 1716fb5c82 Merge pull request 'Fix xterm clipping; move mic + Jump to Current into status bar' (#7) from terminal-layout-statusbar into main
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 4m58s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #7
2026-06-29 15:23:35 +00:00
shadow-test da7b7b9bd5 Address review: pin STT transcript, clear stale scroll state
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m15s
Build App / build-windows (pull_request) Successful in 3m49s
Build App / build-linux (pull_request) Successful in 4m48s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Follow-up to PR review on terminal-layout-statusbar:

- [Major] Pin STT transcripts to the originating terminal. The single
  useSTT instance is bound to the live active session, which can change
  mid-recording. Capture the session id at recording start in a ref and
  inject the transcript there instead of the live sessionId, so text
  always lands in the terminal where recording began.
- [Minor] Clear the status-bar scroll state when the active terminal
  unmounts, and null out termRef on dispose, so scrollActiveToBottom
  can't point at a disposed terminal. Tab switches don't unmount, so
  this only fires when the active session is actually closed.
- [Nit] Fix the terminal padding comment to match the symmetric value.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:33:27 -07:00
shadow-test 3d2d979197 Fix xterm clipping; move mic + Jump to Current into status bar
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m19s
Build App / build-linux (pull_request) Successful in 5m3s
Build App / build-windows (pull_request) Successful in 7m34s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Terminal layout fixes for the xterm pane:

- Stop the terminal grid from clipping its rightmost column / bottom
  row. The padding was on the element xterm mounts into, which the
  FitAddon measures; the grid overhang got clipped. Padding now lives on
  a wrapper and the xterm host fills it with no padding.
- Move the STT mic from a floating bottom-left overlay into the status
  bar (far right). A single useSTT instance bound to the active session
  now lives in App; Ctrl+Shift+M routes through the store.
- Move "Jump to Current" from a floating terminal overlay into the
  status bar. The active TerminalView surfaces its scroll state and
  scroll action via the store.
- Tighten terminal padding (was 8/12/48/16) now that nothing floats over
  it, so the terminal claims as much area as possible.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-28 19:26:05 -07:00
jknapp de2752557d Merge pull request 'Fix Windows release upload: idempotent get-or-create + fail-loud' (#6) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m39s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 5m51s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 13s
Reviewed-on: #6
2026-06-24 16:28:58 +00:00
jknapp 9221c25474 Merge branch 'main' into feature/ux-improvements
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m35s
Build App / build-windows (pull_request) Successful in 3m51s
Build App / build-linux (pull_request) Successful in 5m20s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-06-24 16:28:45 +00:00
shadow-test 997e1ab3a9 Fix Windows release upload: idempotent get-or-create + fail-loud
Build App / compute-version (pull_request) Successful in 9s
Build App / build-macos (pull_request) Successful in 2m36s
Build App / build-windows (pull_request) Successful in 2m50s
Build App / build-linux (pull_request) Successful in 6m26s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
The cmd-batch upload step POSTed to /releases unconditionally. On a
re-run the v{VERSION}-win tag already exists, so Gitea returns 409, the
findstr id parse yields an empty RELEASE_ID, and uploads go to a
malformed .../releases//assets URL -- all silently swallowed by cmd and
`curl -s`, so the step reported success while attaching no assets.

Rewrite in PowerShell mirroring the macOS job: look the release up by
tag first and create only on 404, throw if the id can't be resolved,
delete same-named assets left over from partial runs before re-upload,
and fail loudly (ErrorActionPreference=Stop, curl.exe -fsS with retries,
$LASTEXITCODE check).

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-24 09:22:55 -07:00
16 changed files with 859 additions and 87 deletions
+52 -10
View File
@@ -428,20 +428,62 @@ jobs:
- name: Upload to Gitea release - name: Upload to Gitea release
if: gitea.event_name == 'push' if: gitea.event_name == 'push'
shell: powershell
env: env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }} TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }} COMMIT_SHA: ${{ gitea.sha }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: | run: |
set "TAG=v${{ needs.compute-version.outputs.version }}-win" $ErrorActionPreference = "Stop"
echo Creating release %TAG%... $tag = "v$env:VERSION-win"
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json $headers = @{ Authorization = "token $env:TOKEN" }
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found $api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
:found
echo Release ID: %RELEASE_ID% # Idempotent get-or-create. The old cmd-batch version swallowed
for %%f in (artifacts\*) do ( # curl errors and parsed the release id with findstr, so a 409 on
echo Uploading %%~nxf... # a pre-existing tag yielded an empty RELEASE_ID and uploads went to
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/octet-stream" --data-binary "@%%f" "%GITEA_URL%/api/v1/repos/%REPO%/releases/%RELEASE_ID%/assets?name=%%~nxf" # a malformed .../releases//assets URL while the step still reported
) # success. Look the release up by tag first; create only on 404.
try {
$release = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/tags/$tag"
Write-Host "Release $tag already exists, reusing"
} catch {
if ($_.Exception.Response.StatusCode.value__ -eq 404) {
Write-Host "Release $tag not found, creating"
$body = @{
tag_name = $tag
name = "Triple-C v$env:VERSION (Windows)"
body = "Automated build from commit $env:COMMIT_SHA"
} | ConvertTo-Json
$release = Invoke-RestMethod -Method Post -Headers $headers `
-ContentType "application/json" -Body $body -Uri "$api/releases"
} else {
throw
}
}
$releaseId = $release.id
if (-not $releaseId) { throw "Failed to resolve release id for $tag" }
Write-Host "Release ID: $releaseId"
# Upload each artifact. Delete any same-named asset left over from a
# partial prior run first, so the upload replaces rather than 409s.
$existing = Invoke-RestMethod -Method Get -Headers $headers -Uri "$api/releases/$releaseId/assets"
foreach ($file in Get-ChildItem -File -Path artifacts\*) {
$name = $file.Name
$dupe = $existing | Where-Object { $_.name -eq $name }
if ($dupe) {
Write-Host "Deleting existing asset $name (id $($dupe.id))"
Invoke-RestMethod -Method Delete -Headers $headers -Uri "$api/releases/$releaseId/assets/$($dupe.id)" | Out-Null
}
Write-Host "Uploading $name..."
$uploadUri = "$api/releases/$releaseId/assets?name=$([uri]::EscapeDataString($name))"
curl.exe -fsS --retry 5 --retry-all-errors --retry-delay 5 --max-time 600 `
-X POST -H "Authorization: token $env:TOKEN" `
-H "Content-Type: application/octet-stream" `
--data-binary "@$($file.FullName)" $uploadUri
if ($LASTEXITCODE -ne 0) { throw "Upload of $name failed (curl exit $LASTEXITCODE)" }
}
create-tag: create-tag:
runs-on: ubuntu-latest runs-on: ubuntu-latest
+194 -1
View File
@@ -1,4 +1,5 @@
use bollard::container::{DownloadFromContainerOptions, UploadToContainerOptions}; use bollard::container::{DownloadFromContainerOptions, LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, StartExecResults};
use futures_util::StreamExt; use futures_util::StreamExt;
use serde::Serialize; use serde::Serialize;
use tauri::State; use tauri::State;
@@ -151,6 +152,198 @@ pub async fn download_container_file(
Ok(()) Ok(())
} }
/// Create a `.tar.gz` backup of the container and stream it to a host file.
/// The archive contains:
/// - the workspace (default /workspace), minus regenerable build artifacts
/// (node_modules, target), under `workspace/`, and
/// - a sanitized copy of the home config under `home-claude/`: ~/.claude.json
/// with secret-bearing keys removed (mcpServers/settings kept) and ~/.claude/
/// minus the OAuth `.credentials.json`, so MCP servers, settings and skills
/// set up via Claude Code survive a Reset.
/// `.git` is kept in full so the backup faithfully preserves git history,
/// including unpushed commits. Build + gzip happen inside the container so a
/// large workspace isn't streamed in full. The container must be RUNNING (the
/// backup runs via `docker exec`). Returns the number of bytes written.
#[tauri::command]
pub async fn download_container_backup(
project_id: String,
host_path: String,
container_path: Option<String>,
state: State<'_, AppState>,
) -> Result<u64, String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
let docker = get_docker()?;
// The backup runs inside the container via `docker exec`, which requires it
// to be running. Fail with a clear message rather than a raw Docker error.
let running = docker
.inspect_container(container_id, None)
.await
.ok()
.and_then(|info| info.state)
.and_then(|s| s.running)
.unwrap_or(false);
if !running {
return Err("Start the project before backing up — the backup runs inside the running container.".to_string());
}
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
// Stage a sanitized home config, then tar+gzip workspace + staged config to
// stdout. mktemp/jq output go nowhere near stdout, so the only thing the
// exec emits on stdout is the archive itself. --ignore-failed-read keeps a
// transient unreadable file from aborting the whole backup. If jq can't
// parse ~/.claude.json we substitute an empty object — never the raw file —
// so secrets can't leak through the sanitization fallback.
// The `--transform` nests the workspace under `workspace/` (parallel to
// `home-claude/`) so an extracted archive has both clearly labeled instead
// of scattering the workspace files into the extraction dir. Rewriting the
// leading `.` (rather than `./`) also renames tar's root member from `./` to
// `workspace`, so the archive carries a proper `workspace/` dir entry rather
// than a bare `./` that would stamp the source root's mode/mtime onto the
// extraction directory. `flags=rh` rewrites regular member names AND
// hardlink target names (so an intra-workspace hardlink pair still resolves
// on extract) while leaving symlink targets untouched (rewriting those would
// corrupt relative/absolute links).
let script = r#"set -e
STAGE=$(mktemp -d)
trap 'rm -rf "$STAGE"' EXIT
mkdir -p "$STAGE/home-claude"
if [ -f "$HOME/.claude.json" ]; then
if ! jq 'del(.primaryApiKey, .oauthAccount, .customApiKeyResponses)' "$HOME/.claude.json" \
> "$STAGE/home-claude/.claude.json" 2>/dev/null; then
echo "warning: could not sanitize .claude.json; omitting it from backup" >&2
printf '{}' > "$STAGE/home-claude/.claude.json"
fi
fi
if [ -d "$HOME/.claude" ]; then
cp -a "$HOME/.claude" "$STAGE/home-claude/.claude" 2>/dev/null || true
rm -f "$STAGE/home-claude/.claude/.credentials.json"
fi
tar czf - --ignore-failed-read \
--exclude='*/node_modules' --exclude='*/target' \
--transform='flags=rh;s,^\.,workspace,' \
-C "$TC_BACKUP_SRC" . \
-C "$STAGE" home-claude"#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(cmd),
env: Some(vec![
"HOME=/home/claude".to_string(),
format!("TC_BACKUP_SRC={}", path),
]),
user: Some("claude".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create backup exec: {}", e))?;
let result = docker
.start_exec(&exec.id, None)
.await
.map_err(|e| format!("Failed to start backup exec: {}", e))?;
let mut output = match result {
StartExecResults::Attached { output, .. } => output,
StartExecResults::Detached => return Err("Backup exec started detached".to_string()),
};
use tokio::io::AsyncWriteExt;
let file = tokio::fs::File::create(&host_path)
.await
.map_err(|e| format!("Failed to create backup file: {}", e))?;
let mut writer = tokio::io::BufWriter::new(file);
let mut total: u64 = 0;
let mut stderr_text = String::new();
let mut stream_err: Option<String> = None;
while let Some(msg) = output.next().await {
match msg {
Ok(LogOutput::StdOut { message }) => {
if let Err(e) = writer.write_all(&message).await {
stream_err = Some(format!("Failed to write backup file: {}", e));
break;
}
total += message.len() as u64;
}
Ok(LogOutput::StdErr { message }) => {
stderr_text.push_str(&String::from_utf8_lossy(&message));
}
Ok(_) => {}
Err(e) => {
stream_err = Some(format!("Backup stream error: {}", e));
break;
}
}
}
if stream_err.is_none() {
if let Err(e) = writer.flush().await {
stream_err = Some(format!("Failed to finalize backup file: {}", e));
}
}
drop(writer);
// The tar pipeline can abort mid-stream (producing a truncated archive) and
// still have sent bytes, so a non-zero exit must be treated as failure even
// when `total > 0`. Poll until the exec actually reports finished so the
// exit code is reliably populated; if it can't be determined we fall back to
// the `total == 0` check below.
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
stream_err = Some(format!(
"Backup command failed (exit {}){}",
exit_code.unwrap_or(-1),
if stderr_text.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr_text.trim())
}
));
}
if stream_err.is_none() && total == 0 {
stream_err = Some(format!(
"Backup produced no data{}",
if stderr_text.trim().is_empty() {
String::new()
} else {
format!(": {}", stderr_text.trim())
}
));
}
if let Some(err) = stream_err {
// Don't leave a partial/corrupt archive behind.
let _ = tokio::fs::remove_file(&host_path).await;
return Err(err);
}
log::info!(
"Wrote {} byte backup for project {} to {}",
total,
project_id,
host_path
);
Ok(total)
}
#[tauri::command] #[tauri::command]
pub async fn upload_file_to_container( pub async fn upload_file_to_container(
project_id: String, project_id: String,
@@ -432,6 +432,13 @@ pub async fn start_project_container(
new_id new_id
}; };
// Sync Bedrock credentials on every start: refresh static/session creds
// so rotated keys are picked up without a full container recreation, and
// clear stale creds when the project no longer uses static-cred Bedrock.
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
}
Ok(container_id) Ok(container_id)
}.await; }.await;
@@ -183,6 +183,55 @@ pub async fn paste_image_to_terminal(
.await .await
} }
/// Copy a host file (e.g. dragged onto the terminal) into the container so
/// Claude Code can read it, and return the in-container path. Mirrors the
/// image-paste flow: the file is placed under /tmp/triple-c-drops/ keeping its
/// original name. Returns an error for paths that aren't readable regular files
/// (e.g. a dropped directory).
#[tauri::command]
pub async fn upload_host_file_to_terminal(
session_id: String,
host_path: String,
state: State<'_, AppState>,
) -> Result<String, String> {
let container_id = state.exec_manager.get_container_id(&session_id).await?;
let meta = tokio::fs::metadata(&host_path)
.await
.map_err(|e| format!("Cannot access {}: {}", host_path, e))?;
if meta.is_dir() {
return Err(format!("{} is a directory — drop individual files", host_path));
}
// Guard against ballooning host RAM: the file is packed into an in-memory
// tar before upload, so cap the size of a dropped file.
const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024; // 256 MiB
if meta.len() > MAX_DROP_BYTES {
return Err(format!(
"File too large to drop into the terminal ({:.0} MB; limit {} MB). Mount it into the project or use the Files panel instead.",
meta.len() as f64 / (1024.0 * 1024.0),
MAX_DROP_BYTES / (1024 * 1024)
));
}
let base = std::path::Path::new(&host_path)
.file_name()
.map(|s| s.to_string_lossy().to_string())
.filter(|s| !s.is_empty())
.unwrap_or_else(|| "dropped-file".to_string());
// Ensure the destination directory exists rather than relying on Docker's
// archive extractor to create the parent for the uploaded tar entry.
crate::docker::exec::exec_oneshot(
&container_id,
vec!["mkdir".to_string(), "-p".to_string(), "/tmp/triple-c-drops".to_string()],
)
.await?;
let file_name = format!("triple-c-drops/{}", base);
crate::docker::exec::upload_host_file_to_container(&container_id, &host_path, &file_name).await
}
#[tauri::command] #[tauri::command]
pub async fn start_audio_bridge( pub async fn start_audio_bridge(
session_id: String, session_id: String,
+178 -15
View File
@@ -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
// sync_bedrock_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
} // sync_bedrock_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_"];
@@ -897,7 +934,19 @@ pub async fn create_container(
false false
}; };
if should_mount_aws || aws_config_path.is_some() { // For static-credential Bedrock, sync_bedrock_credentials() is the sole
// owner of ~/.aws/credentials (it rewrites it on every start). Mounting the
// host AWS dir would make the entrypoint's `rm -rf ~/.aws; cp -a` race that
// write at startup, so we never mount it in that case — the static keys
// (+ AWS_REGION env) are self-sufficient and don't need the host config.
let is_bedrock_static = project.backend == Backend::Bedrock
&& project
.bedrock_config
.as_ref()
.map(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
.unwrap_or(false);
if (should_mount_aws || aws_config_path.is_some()) && !is_bedrock_static {
let aws_dir = aws_config_path let aws_dir = aws_config_path
.map(|p| std::path::PathBuf::from(p)) .map(|p| std::path::PathBuf::from(p))
.or_else(|| dirs::home_dir().map(|h| h.join(".aws"))); .or_else(|| dirs::home_dir().map(|h| h.join(".aws")));
@@ -1060,10 +1109,124 @@ pub fn get_snapshot_image_name(project: &Project) -> String {
format!("triple-c-snapshot-{}:latest", project.id) format!("triple-c-snapshot-{}:latest", project.id)
} }
/// Keep the container's `~/.aws/credentials` in sync with the project's Bedrock
/// auth on every container start:
/// - **Bedrock + static credentials**: (re)write `~/.aws/credentials` from the
/// latest keychain values and drop a stale `~/.aws/config` left by a prior
/// profile/SSO session, so rotated keys are picked up without recreating the
/// container.
/// - **Any other backend / auth method**: remove a stale `~/.aws/credentials`
/// written by a previous static-credential session, so the secrets don't
/// linger unused in the persistent home volume after switching away.
///
/// Both cleanups are skipped when `/tmp/.host-aws` is mounted (a global
/// `aws_config_path` is configured), since the entrypoint already refreshes
/// `~/.aws` from the host on every start in that case.
pub async fn sync_bedrock_credentials(
container_id: &str,
project: &Project,
) -> Result<(), String> {
let static_bedrock = if project.backend == Backend::Bedrock {
project
.bedrock_config
.as_ref()
.filter(|b| b.auth_method == BedrockAuthMethod::StaticCredentials)
} else {
None
};
let bedrock = match static_bedrock {
Some(b) if b.aws_access_key_id.as_deref().is_some_and(|k| !k.is_empty()) => b,
_ => {
// Not static-credential Bedrock (or static selected but no key set):
// remove a stale credentials file from a previous static session.
if matches!(static_bedrock, Some(_)) {
log::warn!("Bedrock static auth selected but no AWS access key id is set");
}
let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#;
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
let env = vec!["HOME=/home/claude".to_string()];
if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await {
log::warn!(
"Failed to clear stale AWS credentials in container {}: {}",
container_id,
e
);
}
return Ok(());
}
};
let key_id = bedrock.aws_access_key_id.as_deref().unwrap_or("");
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![
"HOME=/home/claude".to_string(),
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()];
let (output, exit_code) =
crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env)
.await
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
if exit_code != 0 {
return Err(format!(
"Writing AWS credentials into container failed (exit {}): {}",
exit_code,
output.trim()
));
}
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);
+113 -4
View File
@@ -279,8 +279,90 @@ impl ExecSessionManager {
} }
} }
/// Upload a host file into the container's `/tmp` under `dest_name`. The file is
/// read and packed into the tar inside a blocking task, so the synchronous IO
/// runs off the async worker. The tar's declared entry size is taken from the
/// bytes actually read (not a separate `stat`), so a file changing size between
/// a size check and the read can't desync the header and corrupt the archive.
/// Returns the in-container path (`/tmp/<dest_name>`).
pub async fn upload_host_file_to_container(
container_id: &str,
host_path: &str,
dest_name: &str,
) -> Result<String, String> {
let host_path = host_path.to_string();
let dest_name = dest_name.to_string();
let dest_for_blk = dest_name.clone();
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
let data = std::fs::read(&host_path)
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
let mut tar_buf = Vec::with_capacity(data.len() + 1024);
{
let mut builder = tar::Builder::new(&mut tar_buf);
let mut header = tar::Header::new_gnu();
// Size comes from the bytes in hand, so header and payload can't disagree.
header.set_size(data.len() as u64);
header.set_mode(0o644);
header.set_cksum();
builder
.append_data(&mut header, &dest_for_blk, &data[..])
.map_err(|e| format!("Failed to create tar entry: {}", e))?;
builder
.finish()
.map_err(|e| format!("Failed to finalize tar: {}", e))?;
}
Ok(tar_buf)
})
.await
.map_err(|e| format!("Upload task panicked: {}", e))??;
let docker = get_docker()?;
docker
.upload_to_container(
container_id,
Some(UploadToContainerOptions {
path: "/tmp".to_string(),
..Default::default()
}),
tar_buf.into(),
)
.await
.map_err(|e| format!("Failed to upload file to container: {}", e))?;
Ok(format!("/tmp/{}", dest_name))
}
/// 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`.
///
/// NOTE: the command's exit code is NOT checked — callers that need to know
/// whether the command succeeded should use `exec_oneshot_env_status`.
pub async fn exec_oneshot_env(
container_id: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<String, String> {
exec_oneshot_env_status(container_id, cmd, env)
.await
.map(|(output, _exit_code)| output)
}
/// Like `exec_oneshot_env`, but also returns the command's exit code (0 on
/// success). The returned string contains both stdout and stderr, interleaved
/// in arrival order, which is useful for surfacing failure detail.
pub async fn exec_oneshot_env_status(
container_id: &str,
cmd: Vec<String>,
env: Vec<String>,
) -> Result<(String, i64), String> {
let docker = get_docker()?; let docker = get_docker()?;
let exec = docker let exec = docker
@@ -290,6 +372,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()
}, },
@@ -302,17 +385,43 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
.await .await
.map_err(|e| format!("Failed to start exec: {}", e))?; .map_err(|e| format!("Failed to start exec: {}", e))?;
let mut combined = String::new();
match result { match result {
StartExecResults::Attached { mut output, .. } => { StartExecResults::Attached { mut output, .. } => {
let mut stdout = String::new();
while let Some(msg) = output.next().await { while let Some(msg) = output.next().await {
match msg { match msg {
Ok(data) => stdout.push_str(&String::from_utf8_lossy(&data.into_bytes())), Ok(data) => combined.push_str(&String::from_utf8_lossy(&data.into_bytes())),
Err(e) => return Err(format!("Exec output error: {}", e)), Err(e) => return Err(format!("Exec output error: {}", e)),
} }
} }
Ok(stdout)
} }
StartExecResults::Detached => Err("Exec started in detached mode".to_string()), StartExecResults::Detached => return Err("Exec started in detached mode".to_string()),
}
// The output stream draining doesn't strictly guarantee inspect_exec has the
// final exit_code populated yet, so poll until the exec reports finished.
let exit_code = wait_for_exec_exit(&exec.id).await.unwrap_or(0);
Ok((combined, exit_code))
}
/// Poll `inspect_exec` until the exec reports finished and return its exit code.
/// Returns `None` if the code can't be determined (inspect error, or the exec
/// doesn't report finished within ~1s — which shouldn't happen once its output
/// stream has drained).
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
let docker = get_docker().ok()?;
for _ in 0..40 {
match docker.inspect_exec(exec_id).await {
Ok(info) => {
if info.running != Some(true) {
// Finished: use the reported code (default 0 if somehow absent).
return Some(info.exit_code.unwrap_or(0));
} }
} }
Err(_) => return None,
}
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
}
None
}
+2
View File
@@ -178,12 +178,14 @@ pub fn run() {
commands::terminal_commands::terminal_resize, commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session, commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal, commands::terminal_commands::paste_image_to_terminal,
commands::terminal_commands::upload_host_file_to_terminal,
commands::terminal_commands::start_audio_bridge, commands::terminal_commands::start_audio_bridge,
commands::terminal_commands::send_audio_data, commands::terminal_commands::send_audio_data,
commands::terminal_commands::stop_audio_bridge, commands::terminal_commands::stop_audio_bridge,
// Files // Files
commands::file_commands::list_container_files, commands::file_commands::list_container_files,
commands::file_commands::download_container_file, commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container, commands::file_commands::upload_file_to_container,
// MCP // MCP
commands::mcp_commands::list_mcp_servers, commands::mcp_commands::list_mcp_servers,
+14 -3
View File
@@ -10,6 +10,8 @@ import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects"; import { useProjects } from "./hooks/useProjects";
import { useMcpServers } from "./hooks/useMcpServers"; import { useMcpServers } from "./hooks/useMcpServers";
import { useUpdates } from "./hooks/useUpdates"; import { useUpdates } from "./hooks/useUpdates";
import { useTerminal } from "./hooks/useTerminal";
import { useSTT } from "./hooks/useSTT";
import { useAppState } from "./store/appState"; import { useAppState } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands"; import { reconcileProjectStatuses } from "./lib/tauri-commands";
@@ -19,11 +21,20 @@ export default function App() {
const { refresh } = useProjects(); const { refresh } = useProjects();
const { refresh: refreshMcp } = useMcpServers(); const { refresh: refreshMcp } = useMcpServers();
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates(); const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, setProjects } = useAppState( const { sessions, activeSessionId, setProjects, setSttToggle } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects })) useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects, setSttToggle: s.setSttToggle }))
); );
const [showInstallDialog, setShowInstallDialog] = useState(false); const [showInstallDialog, setShowInstallDialog] = useState(false);
// Single STT instance bound to the active session. The mic lives in the
// StatusBar; the terminal's Ctrl+Shift+M shortcut calls stt.toggle via the
// store (registered below).
const { sendInput } = useTerminal();
const stt = useSTT(activeSessionId ?? "", sendInput);
useEffect(() => {
setSttToggle(stt.toggle);
}, [stt.toggle, setSttToggle]);
// Initialize on mount // Initialize on mount
useEffect(() => { useEffect(() => {
loadSettings(); loadSettings();
@@ -82,7 +93,7 @@ export default function App() {
)} )}
</main> </main>
</div> </div>
<StatusBar /> <StatusBar stt={stt} />
{showInstallDialog && ( {showInstallDialog && (
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} /> <DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
)} )}
+40 -3
View File
@@ -1,9 +1,26 @@
import { useShallow } from "zustand/react/shallow"; import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import SttButton from "../terminal/SttButton";
import type { useSTT } from "../../hooks/useSTT";
export default function StatusBar() { interface Props {
const { projects, sessions, terminalHasSelection } = useAppState( stt: ReturnType<typeof useSTT>;
useShallow(s => ({ projects: s.projects, sessions: s.sessions, terminalHasSelection: s.terminalHasSelection })) }
export default function StatusBar({ stt }: Props) {
const {
projects, sessions, terminalHasSelection, activeSessionId, sttEnabled,
terminalAtBottom, scrollActiveToBottom,
} = useAppState(
useShallow(s => ({
projects: s.projects,
sessions: s.sessions,
terminalHasSelection: s.terminalHasSelection,
activeSessionId: s.activeSessionId,
sttEnabled: s.appSettings?.stt?.enabled,
terminalAtBottom: s.terminalAtBottom,
scrollActiveToBottom: s.scrollActiveToBottom,
}))
); );
const running = projects.filter((p) => p.status === "running").length; const running = projects.filter((p) => p.status === "running").length;
@@ -28,6 +45,26 @@ export default function StatusBar() {
</span> </span>
</> </>
)} )}
{/* Right-aligned controls: Jump to Current + STT mic */}
<div className="ml-auto flex items-center gap-3 pl-2">
{activeSessionId && !terminalAtBottom && (
<button
onClick={() => scrollActiveToBottom()}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] cursor-pointer"
title="Scroll the terminal to the latest output"
>
Jump to Current
</button>
)}
{sttEnabled && activeSessionId && (
<SttButton
state={stt.state}
error={stt.error}
onToggle={stt.toggle}
onCancel={stt.cancelRecording}
/>
)}
</div>
</div> </div>
); );
} }
+41 -1
View File
@@ -1,5 +1,6 @@
import { useState, useEffect } from "react"; import { useState, useEffect } from "react";
import { open } from "@tauri-apps/plugin-dialog"; import { open, save } from "@tauri-apps/plugin-dialog";
import * as commands from "../../lib/tauri-commands";
import { listen } from "@tauri-apps/api/event"; import { listen } from "@tauri-apps/api/event";
import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types"; import type { Project, ProjectPath, Backend, BedrockConfig, BedrockAuthMethod, OllamaConfig, OpenAiCompatibleConfig } from "../../lib/types";
import { useProjects } from "../../hooks/useProjects"; import { useProjects } from "../../hooks/useProjects";
@@ -37,6 +38,7 @@ export default function ProjectCard({ project }: Props) {
const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null); const [activeOperation, setActiveOperation] = useState<"starting" | "stopping" | "resetting" | null>(null);
const [operationCompleted, setOperationCompleted] = useState(false); const [operationCompleted, setOperationCompleted] = useState(false);
const [showRemoveModal, setShowRemoveModal] = useState(false); const [showRemoveModal, setShowRemoveModal] = useState(false);
const [backingUp, setBackingUp] = useState(false);
const [isEditingName, setIsEditingName] = useState(false); const [isEditingName, setIsEditingName] = useState(false);
const [editName, setEditName] = useState(project.name); const [editName, setEditName] = useState(project.name);
const isSelected = selectedProjectId === project.id; const isSelected = selectedProjectId === project.id;
@@ -177,6 +179,35 @@ export default function ProjectCard({ project }: Props) {
} }
}; };
const handleBackup = async () => {
if (!project.container_id) {
setError("Start the project at least once before backing up.");
return;
}
const stamp = new Date().toISOString().slice(0, 19).replace(/[:T]/g, "-");
const safeName = project.name.replace(/[^a-zA-Z0-9_-]+/g, "_");
try {
const hostPath = await save({
defaultPath: `${safeName}-backup-${stamp}.tar.gz`,
filters: [{ name: "Gzipped tarball", extensions: ["tar.gz"] }],
});
if (!hostPath) return;
setBackingUp(true);
setError(null);
const bytes = await commands.downloadContainerBackup(project.id, hostPath);
const mb = (bytes / (1024 * 1024)).toFixed(1);
const msg = `Backup saved (${mb} MB). Note: includes MCP/config — may contain MCP API keys. Keep it private.`;
setProgressMsg(msg);
// Auto-clear so the transient confirmation doesn't linger in the card
// status; guard against clobbering a newer message (e.g. a later op).
setTimeout(() => setProgressMsg((prev) => (prev === msg ? null : prev)), 8000);
} catch (e) {
setError(String(e));
} finally {
setBackingUp(false);
}
};
const closeModal = () => { const closeModal = () => {
setActiveOperation(null); setActiveOperation(null);
setOperationCompleted(false); setOperationCompleted(false);
@@ -504,6 +535,12 @@ export default function ProjectCard({ project }: Props) {
<ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent /> <ActionButton onClick={handleOpenTerminal} disabled={loading} label="Terminal" accent />
<ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" /> <ActionButton onClick={handleOpenBashShell} disabled={loading} label="Shell" />
<ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" /> <ActionButton onClick={() => setShowFileManager(true)} disabled={loading} label="Files" />
<ActionButton
onClick={handleBackup}
disabled={loading || backingUp}
label={backingUp ? "Backing up…" : "Backup"}
title="Downloads /workspace plus a sanitized home config (MCP servers, settings, skills). OAuth tokens are excluded, but MCP server configs may embed their own API keys/tokens — keep the archive private."
/>
</> </>
) : ( ) : (
<> <>
@@ -1193,12 +1230,14 @@ function ActionButton({
label, label,
accent, accent,
danger, danger,
title,
}: { }: {
onClick: (e?: React.MouseEvent) => void; onClick: (e?: React.MouseEvent) => void;
disabled: boolean; disabled: boolean;
label: string; label: string;
accent?: boolean; accent?: boolean;
danger?: boolean; danger?: boolean;
title?: string;
}) { }) {
let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"; let color = "text-[var(--text-secondary)] hover:text-[var(--text-primary)]";
if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]"; if (accent) color = "text-[var(--accent)] hover:text-[var(--accent-hover)]";
@@ -1208,6 +1247,7 @@ function ActionButton({
<button <button
onClick={(e) => { e.stopPropagation(); onClick(e); }} onClick={(e) => { e.stopPropagation(); onClick(e); }}
disabled={disabled} disabled={disabled}
title={title}
className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`} className={`text-xs px-2 py-0.5 rounded transition-colors disabled:opacity-50 ${color} hover:bg-[var(--bg-primary)]`}
> >
{label} {label}
+16 -18
View File
@@ -62,7 +62,15 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
}; };
return ( return (
<div className="absolute bottom-2 left-2 z-50 flex items-center gap-2"> <div className="flex items-center gap-1.5">
{state === "recording" && (
<span className="text-[#f85149] font-mono">{formatTime(elapsed)}</span>
)}
{state === "error" && error && (
<span className="text-[#f85149] max-w-[180px] truncate" title={error}>
{error}
</span>
)}
<div className="relative"> <div className="relative">
<button <button
onClick={handleClick} onClick={handleClick}
@@ -71,44 +79,34 @@ export default function SttButton({ state, error, onToggle, onCancel }: Props) {
onMouseEnter={() => setHovered(true)} onMouseEnter={() => setHovered(true)}
onMouseLeave={() => setHovered(false)} onMouseLeave={() => setHovered(false)}
disabled={state === "transcribing"} disabled={state === "transcribing"}
className={`w-8 h-8 rounded-full flex items-center justify-center transition-all cursor-pointer ${ className={`w-5 h-5 rounded-full flex items-center justify-center transition-all cursor-pointer ${
state === "recording" state === "recording"
? "bg-[#f85149] text-white shadow-lg animate-pulse" ? "bg-[#f85149] text-white animate-pulse"
: state === "transcribing" : state === "transcribing"
? "bg-[#1f2937] text-[#58a6ff] border border-[#30363d] opacity-80" ? "text-[#58a6ff] opacity-80"
: "bg-[#1f2937]/80 text-[#8b949e] border border-[#30363d] hover:text-[#e6edf3] hover:bg-[#2d3748]" : "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
}`} }`}
> >
{state === "transcribing" ? ( {state === "transcribing" ? (
<svg className="w-4 h-4 animate-spin" viewBox="0 0 24 24" fill="none"> <svg className="w-3 h-3 animate-spin" viewBox="0 0 24 24" fill="none">
<circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" /> <circle cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="2" opacity="0.25" />
<path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" /> <path d="M12 2a10 10 0 0 1 10 10" stroke="currentColor" strokeWidth="2" strokeLinecap="round" />
</svg> </svg>
) : ( ) : (
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="currentColor"> <svg className="w-3 h-3" viewBox="0 0 24 24" fill="currentColor">
<path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" /> <path d="M12 14c1.66 0 3-1.34 3-3V5c0-1.66-1.34-3-3-3S9 3.34 9 5v6c0 1.66 1.34 3 3 3z" />
<path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" /> <path d="M17 11c0 2.76-2.24 5-5 5s-5-2.24-5-5H5c0 3.53 2.61 6.43 6 6.92V21h2v-3.08c3.39-.49 6-3.39 6-6.92h-2z" />
</svg> </svg>
)} )}
</button> </button>
{hovered && state !== "recording" && ( {hovered && state !== "recording" && (
<div className="absolute bottom-full left-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none"> <div className="absolute bottom-full right-0 mb-1.5 px-2 py-1 text-[11px] leading-snug text-[#e6edf3] bg-[#21262d] border border-[#30363d] rounded shadow-lg whitespace-nowrap pointer-events-none z-50">
{state === "transcribing" ? "Transcribing..." : ( {state === "transcribing" ? "Transcribing..." : (
<>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></> <>Speech to text <kbd className="ml-1 px-1 py-0.5 text-[10px] bg-[#0d1117] border border-[#30363d] rounded font-mono">Ctrl+Shift+M</kbd></>
)} )}
</div> </div>
)} )}
</div> </div>
{state === "recording" && (
<span className="text-xs text-[#f85149] font-mono bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d]">
{formatTime(elapsed)}
</span>
)}
{state === "error" && error && (
<span className="text-xs text-[#f85149] bg-[#1f2937] px-2 py-0.5 rounded border border-[#30363d] max-w-[200px] truncate">
{error}
</span>
)}
</div> </div>
); );
} }
+103 -21
View File
@@ -7,9 +7,8 @@ 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";
import { useAppState } from "../../store/appState"; import { useAppState } from "../../store/appState";
import { useSTT } from "../../hooks/useSTT"; import { awsSsoRefresh, uploadHostFileToTerminal } from "../../lib/tauri-commands";
import SttButton from "./SttButton"; import { getCurrentWebview } from "@tauri-apps/api/webview";
import { awsSsoRefresh } from "../../lib/tauri-commands";
import { UrlDetector } from "../../lib/urlDetector"; import { UrlDetector } from "../../lib/urlDetector";
import UrlToast from "./UrlToast"; import UrlToast from "./UrlToast";
import { trimSelection } from "./trimSelection"; import { trimSelection } from "./trimSelection";
@@ -29,10 +28,8 @@ export default function TerminalView({ sessionId, active }: Props) {
const detectorRef = useRef<UrlDetector | null>(null); const detectorRef = useRef<UrlDetector | null>(null);
const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal(); const { sendInput, pasteImage, resize, onOutput, onExit } = useTerminal();
const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection); const setTerminalHasSelection = useAppState(s => s.setTerminalHasSelection);
const sttEnabled = useAppState(s => s.appSettings?.stt?.enabled); const setTerminalAtBottom = useAppState(s => s.setTerminalAtBottom);
const stt = useSTT(sessionId, sendInput); const setScrollActiveToBottom = useAppState(s => s.setScrollActiveToBottom);
const sttToggleRef = useRef(stt.toggle);
sttToggleRef.current = stt.toggle;
const ssoBufferRef = useRef(""); const ssoBufferRef = useRef("");
const ssoTriggeredRef = useRef(false); const ssoTriggeredRef = useRef(false);
@@ -51,6 +48,72 @@ export default function TerminalView({ sessionId, active }: Props) {
const autoFollowRef = useRef(true); const autoFollowRef = useRef(true);
const lastUserScrollTimeRef = useRef(0); const lastUserScrollTimeRef = useRef(0);
// Keep latest `active` readable inside long-lived listeners (drag-drop below,
// and the unmount-cleanup effect further down).
const activeRef = useRef(active);
activeRef.current = active;
// File drag-and-drop: dropped files are copied into the container and their
// in-container paths typed into the prompt so Claude Code can read them.
// Tauri intercepts OS file drops at the webview level, so we use
// onDragDropEvent (HTML5 ondrop on the element wouldn't expose file paths).
// The listener is window-wide, so we route purely by a hit-test against this
// terminal's bounds: the pane the drop lands on handles it. Inactive panes are
// `display:none` (zero-size rect) so they never match — this works for the
// current tabbed layout and would also do the right thing with split panes.
useEffect(() => {
let unlisten: (() => void) | undefined;
let cancelled = false;
const insideThisTerminal = (pos: { x: number; y: number }): boolean => {
const rect = containerRef.current?.getBoundingClientRect();
// A hidden (display:none) pane has a zero-size rect — never a drop target.
if (!rect || rect.width === 0 || rect.height === 0) return false;
const dpr = window.devicePixelRatio || 1;
const x = pos.x / dpr;
const y = pos.y / dpr;
return x >= rect.left && x <= rect.right && y >= rect.top && y <= rect.bottom;
};
// Always single-quote: a dropped filename can contain shell metacharacters
// ($(), &&, ', spaces) even with no whitespace, and this path is typed into
// a live shell. Single-quoting with '\'' escaping neutralizes all of them.
const quote = (p: string) => `'${p.replace(/'/g, "'\\''")}'`;
(async () => {
const un = await getCurrentWebview().onDragDropEvent(async (event) => {
if (event.payload.type !== "drop") return;
if (!insideThisTerminal(event.payload.position)) return;
const paths = event.payload.paths ?? [];
if (paths.length === 0) return;
setImagePasteMsg(`Adding ${paths.length} file${paths.length > 1 ? "s" : ""}`);
const containerPaths: string[] = [];
for (const p of paths) {
try {
containerPaths.push(await uploadHostFileToTerminal(sessionId, p));
} catch (err) {
console.error("File drop upload failed for", p, err);
}
}
if (containerPaths.length === 0) {
setImagePasteMsg("File drop failed");
return;
}
sendInput(sessionId, containerPaths.map(quote).join(" ") + " ");
setImagePasteMsg(`Added ${containerPaths.length} file path${containerPaths.length > 1 ? "s" : ""}`);
});
if (cancelled) un();
else unlisten = un;
})();
return () => {
cancelled = true;
unlisten?.();
};
}, [sessionId, sendInput]);
useEffect(() => { useEffect(() => {
if (!containerRef.current) return; if (!containerRef.current) return;
@@ -111,9 +174,10 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
return false; // prevent xterm from processing this key return false; // prevent xterm from processing this key
} }
// Ctrl+Shift+M toggles speech-to-text recording // Ctrl+Shift+M toggles speech-to-text recording (mic lives in the status
// bar, bound to the active session; trigger it via the store).
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") { if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "M") {
sttToggleRef.current(); useAppState.getState().sttToggle();
return false; return false;
} }
return true; return true;
@@ -319,6 +383,7 @@ export default function TerminalView({ sessionId, active }: Props) {
try { webglRef.current?.dispose(); } catch { /* may already be disposed */ } try { webglRef.current?.dispose(); } catch { /* may already be disposed */ }
webglRef.current = null; webglRef.current = null;
term.dispose(); term.dispose();
termRef.current = null;
}; };
}, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps }, [sessionId]); // eslint-disable-line react-hooks/exhaustive-deps
@@ -393,6 +458,27 @@ export default function TerminalView({ sessionId, active }: Props) {
} }
}, []); }, []);
// Surface this terminal's scroll state to the status bar's "Jump to Current"
// control, but only while it's the active (visible) terminal.
useEffect(() => {
if (!active) return;
setTerminalAtBottom(isAtBottom);
setScrollActiveToBottom(handleScrollToBottom);
}, [active, isAtBottom, handleScrollToBottom, setTerminalAtBottom, setScrollActiveToBottom]);
// On unmount, if this was the active terminal, clear the status-bar scroll
// state so it doesn't point at a disposed terminal. (Tab switches don't
// unmount — the deactivating terminal stays mounted but hidden — so this
// only fires when the active session is actually closed.)
useEffect(() => {
return () => {
if (activeRef.current) {
setTerminalAtBottom(true);
setScrollActiveToBottom(() => {});
}
};
}, [setTerminalAtBottom, setScrollActiveToBottom]);
const writeSelection = useCallback((mode: "trimmed" | "raw") => { const writeSelection = useCallback((mode: "trimmed" | "raw") => {
const term = termRef.current; const term = termRef.current;
if (!term) return; if (!term) return;
@@ -457,23 +543,19 @@ export default function TerminalView({ sessionId, active }: Props) {
> >
{isAutoFollow ? "▼ Following" : "▽ Paused"} {isAutoFollow ? "▼ Following" : "▽ Paused"}
</button> </button>
{/* STT mic button - bottom left */} {/* Padding lives on this wrapper, NOT on the xterm host element. xterm's
{sttEnabled && <SttButton state={stt.state} error={stt.error} onToggle={stt.toggle} onCancel={stt.cancelRecording} />} FitAddon measures the host element it's mounted into; padding there
{/* Jump to Current - bottom right, when scrolled up */} causes the grid to overhang and clip the rightmost column / bottom
{!isAtBottom && ( row. The host below fills this wrapper's content box with no padding.
<button Kept to a tight, even gutter so the terminal claims as much area as
onClick={handleScrollToBottom} possible while leaving a little breathing room beside the scrollbar. */}
className="absolute bottom-4 right-4 z-50 px-3 py-1.5 rounded-md text-xs font-medium bg-[#1f2937] text-[#58a6ff] border border-[#30363d] shadow-lg hover:bg-[#2d3748] transition-colors cursor-pointer" <div className="w-full h-full" style={{ padding: "4px 8px 4px 8px" }}>
>
Jump to Current
</button>
)}
<div <div
ref={containerRef} ref={containerRef}
className="w-full h-full" className="w-full h-full"
style={{ padding: "8px 12px 48px 16px" }}
onContextMenu={handleContextMenu} onContextMenu={handleContextMenu}
/> />
</div>
{contextMenu && ( {contextMenu && (
<TerminalContextMenu <TerminalContextMenu
x={contextMenu.x} x={contextMenu.x}
+9 -3
View File
@@ -13,6 +13,11 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
const streamRef = useRef<MediaStream | null>(null); const streamRef = useRef<MediaStream | null>(null);
const workletRef = useRef<AudioWorkletNode | null>(null); const workletRef = useRef<AudioWorkletNode | null>(null);
const chunksRef = useRef<Int16Array[]>([]); const chunksRef = useRef<Int16Array[]>([]);
// Pin the transcript to the terminal that was active when recording STARTED.
// The hook is bound to the live active session, which can change mid-recording
// (the user switches tabs); without this the transcript would land in whatever
// tab is active at stop time.
const recordingSessionIdRef = useRef(sessionId);
const appSettings = useAppState((s) => s.appSettings); const appSettings = useAppState((s) => s.appSettings);
const deviceId = appSettings?.default_microphone; const deviceId = appSettings?.default_microphone;
@@ -22,6 +27,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
setState("recording"); setState("recording");
setError(null); setError(null);
chunksRef.current = []; chunksRef.current = [];
recordingSessionIdRef.current = sessionId;
try { try {
const audioConstraints: MediaTrackConstraints = { const audioConstraints: MediaTrackConstraints = {
@@ -57,7 +63,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
setError(msg); setError(msg);
setState("error"); setState("error");
} }
}, [state, deviceId]); }, [state, deviceId, sessionId]);
const stopRecording = useCallback(async () => { const stopRecording = useCallback(async () => {
if (state !== "recording") return; if (state !== "recording") return;
@@ -102,7 +108,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
const text = await commands.transcribeAudio(audioData); const text = await commands.transcribeAudio(audioData);
if (text) { if (text) {
await sendInput(sessionId, text); await sendInput(recordingSessionIdRef.current, text);
} }
setState("idle"); setState("idle");
} catch (e) { } catch (e) {
@@ -112,7 +118,7 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
// Reset to idle after a brief delay so the UI shows the error // Reset to idle after a brief delay so the UI shows the error
setTimeout(() => setState("idle"), 3000); setTimeout(() => setState("idle"), 3000);
} }
}, [state, sessionId, sendInput]); }, [state, sendInput]);
const cancelRecording = useCallback(async () => { const cancelRecording = useCallback(async () => {
workletRef.current?.disconnect(); workletRef.current?.disconnect();
+4
View File
@@ -55,6 +55,8 @@ export const closeTerminalSession = (sessionId: string) =>
invoke<void>("close_terminal_session", { sessionId }); invoke<void>("close_terminal_session", { sessionId });
export const pasteImageToTerminal = (sessionId: string, imageData: number[]) => export const pasteImageToTerminal = (sessionId: string, imageData: number[]) =>
invoke<string>("paste_image_to_terminal", { sessionId, imageData }); invoke<string>("paste_image_to_terminal", { sessionId, imageData });
export const uploadHostFileToTerminal = (sessionId: string, hostPath: string) =>
invoke<string>("upload_host_file_to_terminal", { sessionId, hostPath });
export const startAudioBridge = (sessionId: string) => export const startAudioBridge = (sessionId: string) =>
invoke<void>("start_audio_bridge", { sessionId }); invoke<void>("start_audio_bridge", { sessionId });
export const sendAudioData = (sessionId: string, data: number[]) => export const sendAudioData = (sessionId: string, data: number[]) =>
@@ -76,6 +78,8 @@ export const listContainerFiles = (projectId: string, path: string) =>
invoke<FileEntry[]>("list_container_files", { projectId, path }); invoke<FileEntry[]>("list_container_files", { projectId, path });
export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) => export const downloadContainerFile = (projectId: string, containerPath: string, hostPath: string) =>
invoke<void>("download_container_file", { projectId, containerPath, hostPath }); invoke<void>("download_container_file", { projectId, containerPath, hostPath });
export const downloadContainerBackup = (projectId: string, hostPath: string, containerPath?: string) =>
invoke<number>("download_container_backup", { projectId, hostPath, containerPath });
export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) => export const uploadFileToContainer = (projectId: string, hostPath: string, containerDir: string) =>
invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir }); invoke<void>("upload_file_to_container", { projectId, hostPath, containerDir });
+16
View File
@@ -44,6 +44,16 @@ interface AppState {
// UI state // UI state
terminalHasSelection: boolean; terminalHasSelection: boolean;
setTerminalHasSelection: (has: boolean) => void; setTerminalHasSelection: (has: boolean) => void;
// STT toggle for the active session, registered by App so the terminal's
// Ctrl+Shift+M shortcut can trigger the single status-bar mic instance.
sttToggle: () => void;
setSttToggle: (fn: () => void) => void;
// Active terminal scroll state, surfaced so the status bar can host the
// "Jump to Current" control. Only the active TerminalView writes these.
terminalAtBottom: boolean;
setTerminalAtBottom: (v: boolean) => void;
scrollActiveToBottom: () => void;
setScrollActiveToBottom: (fn: () => void) => void;
sidebarView: "projects" | "mcp" | "settings"; sidebarView: "projects" | "mcp" | "settings";
setSidebarView: (view: "projects" | "mcp" | "settings") => void; setSidebarView: (view: "projects" | "mcp" | "settings") => void;
sidebarCollapsed: boolean; sidebarCollapsed: boolean;
@@ -125,6 +135,12 @@ export const useAppState = create<AppState>((set) => ({
// UI state // UI state
terminalHasSelection: false, terminalHasSelection: false,
setTerminalHasSelection: (has) => set({ terminalHasSelection: has }), setTerminalHasSelection: (has) => set({ terminalHasSelection: has }),
sttToggle: () => {},
setSttToggle: (fn) => set({ sttToggle: fn }),
terminalAtBottom: true,
setTerminalAtBottom: (v) => set({ terminalAtBottom: v }),
scrollActiveToBottom: () => {},
setScrollActiveToBottom: (fn) => set({ scrollActiveToBottom: fn }),
sidebarView: "projects", sidebarView: "projects",
setSidebarView: (view) => set({ sidebarView: view }), setSidebarView: (view) => set({ sidebarView: view }),
sidebarCollapsed: loadSidebarCollapsed(), sidebarCollapsed: loadSidebarCollapsed(),
+16 -3
View File
@@ -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,15 @@ 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" ] && grep -q '"awsAuthRefresh"' "$CLAUDE_JSON" 2>/dev/null; then
# Only rewrite when the key is actually present, to avoid a needless jq
# reformat of ~/.claude.json on every start of a non-SSO backend.
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 ────────────────────────────────────────────────