Compare commits

...
Author SHA1 Message Date
shadow-testandClaude Opus 5 0aa8315514 CI: restore the MSI now that the 32-bit bundlers can resolve their paths
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 32s
Build App / build-macos (pull_request) Successful in 2m26s
Build App / build-windows (pull_request) Successful in 4m51s
Build App / build-linux (pull_request) Successful in 6m11s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Dropping the MSI did not help: makensis.exe is 32-bit like candle.exe
and failed the same way ("Unable to start child process, error 0x2").
The cause was WOW64 redirection sending 32-bit processes reading
C:\Windows\System32 to SysWOW64, where the toolset directory does not
exist.

The build VM now carries junctions from the SysWOW64 view of
systemprofile\AppData\Local\tauri and systemprofile\.cache to the
System32 originals. Verified on the runner: candle.exe reports WiX
3.14.1.8722 and makensis reports v3.11, both exiting 0 from the path
that previously failed.

Both targets build again, so the .msi comes back. Artifact collection
fails if either installer is missing.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:34:59 -07:00
shadow-testandClaude Opus 5 fdc161fd9c CI: build NSIS only on Windows, dropping the MSI target
Build App / compute-version (pull_request) Successful in 17s
Build Container / build-container (pull_request) Successful in 1m22s
Build App / build-macos (pull_request) Successful in 2m22s
Build App / build-windows (pull_request) Failing after 4m41s
Build App / build-linux (pull_request) Successful in 5m51s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
WiX's candle.exe/light.exe are 32-bit. On a SYSTEM-run runner Tauri
caches WiX under C:\Windows\system32\config\systemprofile\..., and WOW64
redirection sends 32-bit processes to SysWOW64 where that directory does
not exist, so candle exits 0x80131700. Tauri aborts the whole bundle on
one target's failure, so the MSI was suppressing the NSIS installer too
and Windows produced no artifact at all.

NSIS is what the project already relies on for Windows upgrades. Drops
the .NET 3.5 gate, which existed only for WiX; keeps the MSVC step,
which is what makes the app link. Artifact collection now fails when no
installer is produced rather than tolerating an empty directory.

To restore the MSI, run the runner as a normal user and set
--bundles msi,nsis.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:24:42 -07:00
shadow-testandClaude Opus 5 763af91042 Revert: LOCALAPPDATA override does not move Tauri's WiX cache
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 32s
Build App / build-macos (pull_request) Successful in 2m23s
Build App / build-windows (pull_request) Failing after 4m35s
Build App / build-linux (pull_request) Successful in 5m6s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Rust's `dirs` crate resolves LOCALAPPDATA on Windows through
SHGetKnownFolderPath, which reads the process token rather than the
environment, so the override changed nothing and 32-bit candle.exe still
hit WOW64 redirection under the SYSTEM profile.

Removing it rather than leaving a plausible-looking non-fix in the
workflow.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:19:12 -07:00
shadow-testandClaude Opus 5 03384409e7 CI: keep the WiX toolset out of System32 so 32-bit candle.exe can run
Build App / compute-version (pull_request) Successful in 13s
Build Container / build-container (pull_request) Successful in 44s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / build-windows (pull_request) Failing after 4m39s
Build App / build-linux (pull_request) Successful in 6m44s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
WiX's candle.exe/light.exe are 32-bit. A SYSTEM-run runner has
%LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, where
Tauri caches the WiX toolset — and WOW64 redirection sends 32-bit
processes reading System32 to SysWOW64, which has no such directory. The
CLR then fails to start with 0x80131700 and Tauri reports only "failed
to run candle.exe".

Verified: the same binary and identity exits 0 from C:\wixtest and
0x80131700 from the systemprofile path.

Pointing LOCALAPPDATA outside System32 avoids redirection, needs no
stored credential, and is a no-op for runners already running as a
normal user.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 22:12:27 -07:00
shadow-testandClaude Opus 5 c6bb7fdf1d CI: fix the MSVC exit-code check and verify .NET 3.5 before bundling
Build App / compute-version (pull_request) Successful in 6s
Build Container / build-container (pull_request) Successful in 1m48s
Build App / build-windows (pull_request) Failing after 5m14s
Build App / build-linux (pull_request) Successful in 6m2s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
%VSEXIT% and %ERRORLEVEL% inside a parenthesised cmd block are
substituted at parse time, not run time, so the installer's real exit
code was never read. Uses delayed expansion now.

Also checks for the .NET 3.5 runtime before building: WiX candle.exe
needs it, and Tauri aborts the whole bundle when the MSI target fails,
which silently suppresses the NSIS installer too. Fails early with the
exact dism command rather than at bundle time.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 21:50:15 -07:00
shadow-testandClaude Opus 5 ca0f944712 CI: install MSVC build tools on Windows runners that lack them
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 30s
Build App / build-linux (pull_request) Successful in 5m24s
Build App / build-windows (pull_request) Failing after 15m29s
Build App / build-macos (pull_request) Successful in 2m24s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
build-windows failed on this PR with "linker `link.exe` not found",
while build-linux and build-container passed — the code was fine, the
runner environment was not.

The job installs Rust and Node conditionally but assumed the MSVC C++
toolchain was hand-provisioned. A runner without it registers normally,
advertises windows-latest, accepts the job, downloads the entire crate
graph and only then fails at link time. That also means a bare runner
coming online turns a job that would have queued for a capable machine
into a failed build.

Installs the VC++ workload when vswhere cannot find it, matching the
existing conditional Rust and Node steps. rustc locates MSVC through
vswhere and the registry rather than PATH, so no dev-shell activation is
needed. Installer exit 3010 (success, reboot pending) is treated as
success.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 19:36:14 -07:00
shadow-testandClaude Opus 5 7d00390e1f Add scheduled task creation, and stop a bad cron unscheduling everything
Build App / compute-version (pull_request) Successful in 4s
Build Container / build-container (pull_request) Successful in 9m35s
Build App / build-linux (pull_request) Successful in 5m35s
Build App / build-windows (pull_request) Failing after 2m26s
Build App / build-macos (pull_request) Successful in 2m49s
Build App / create-tag (pull_request) Skipped
Build App / sync-to-github (pull_request) Skipped
Completes the Automation tab: it could list, toggle, run, log and remove
tasks but not create them, so task creation still meant dropping to the
CLI. Adds add_scheduled_task and update_scheduled_task, plus a task
editor with cron presets and a plain-English reading of the expression.

Every field is free user text, so all of it goes to the scheduler as a
bare argv vector through bollard — no shell, no quoting. Validation is
shape-only rather than metacharacter scrubbing: length caps, no control
characters in single-line fields, no leading-dash name, absolute
working_dir. Verified by round-tripping a prompt containing
`; rm -rf /`, `$(id)`, backticks and newlines: it landed byte-for-byte
in the task JSON with nothing executed.

The scheduler CLI has no `edit`, so update is add-then-remove with the
add first — a rejected edit leaves the original intact. The new id is
surfaced in the editor rather than hidden.

Root-cause fix, and the more serious half of this commit:
triple-c-scheduler never validated --schedule, and rebuild_crontab
regenerates the entire crontab and pipes it to `crontab`, which rejects
the whole file if any line is malformed — with the error thrown away by
`2>/dev/null || true`. A single bad schedule therefore silently
unscheduled every other task in the container while reporting success.
Reproduced directly. It matters because the global CLAUDE.md tells
Claude to drive this CLI, so Claude could trigger it unprompted.

`add` now validates the expression and exits non-zero, and
rebuild_crontab reports a rejected crontab instead of swallowing it,
keeping the offending file for inspection. Verified against the real CLI
in this container: a bad schedule is refused without disturbing an
existing task's crontab entry, and `0 9 * * 1-5`, `*/30 * * * *`,
`0,30 8-17 * * *` and `0 0 1 1 *` are all still accepted. The Rust layer
validates independently, agreeing with vixie cron on 23 probed
expressions including `1/2` and `*/0` being invalid.

121 frontend tests, 44 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:20:52 -07:00
shadow-testandClaude Opus 5 cf3b021c72 Confirm before Reset, and rewrite the docs for the new UI
Reset is destructive in a way its name does not advertise:
rebuild_project_container deletes both project volumes, so it wipes the
claude login, anything installed in the container, and every saved
session transcript. It was a single unconfirmed click in the overflow
menu, while the comparably destructive Remove already confirmed. Adds
ConfirmResetModal, which names each loss and says explicitly that the
host-side mounted folders are untouched.

Docs: the user guides still described the pre-Project-Home UI. Sixteen
factually wrong statements corrected, including "expand the Config
panel" (six sites), the actions table (Reset and Remove are in an
overflow menu, Files is a tab), a progress modal that no longer exists,
a double-click-to-rename gesture ProjectRow never had, the Full
Permissions boolean, an incomplete reserved-env list, and the claim in
TECHNICAL.md that OAuth tokens survive a Reset. Both layout diagrams and
the project tree were rebuilt from the filesystem.

New sections cover permission modes with the exact CLI mapping, Project
Home, Sessions, capability tiles, Automation, shared authentication, the
Auth Bridge and its security posture, and keyboard shortcuts.

Known gap recorded rather than papered over: the Automation tab manages
existing scheduled tasks but cannot create them — no add command is
registered — so task creation remains `triple-c-scheduler add` in the
terminal.

87 frontend tests, 34 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 12:02:35 -07:00
shadow-testandClaude Opus 5 d95ba54a69 Add shared-auth-token UI and make cancelling actually cancel
UI for the shared Claude token: a Settings section showing token state
with Authenticate and Revoke, an acquisition modal built on the shared
Modal (sign-in link handed to the host browser via the opener plugin,
plus the code input that answers `setup-token`'s stdin prompt — the flow
cannot complete without it), and a per-project opt-out toggle shown only
for the Anthropic backend.

Cancellation: acquire_claude_token previously had only two exits,
completion and a 15-minute timeout, and held the single-flight guard for
the whole time. Closing the dialog therefore locked the user out of
retrying for up to 15 minutes. Adds cancel_claude_token, backed by a
oneshot claimed and released in lockstep with the input guard, selected
on in the run loop so it wins the race and tears the exec down. The
dialog's Cancel now calls it and closes either way.

Also refreshes CLAUDE.md, which had drifted: it documented the deleted
ProjectCard, and asserted that new IPC commands need permission grants
in capabilities/default.json — they do not, that file covers plugin
commands only. Adds the conventions that would otherwise bite:
container_needs_recreation() is purely label-based and never diffs env,
so container-affecting state needs its own label; and #[serde(default)]
on a bool yields false regardless of intent.

Corrects the claim that Reset preserves credentials. Reset calls
remove_project_volumes, which deletes both the home and claude-config
volumes, so it wipes ~/.claude, the OAuth token, installed skills and
session transcripts.

84 frontend tests, 34 Rust tests, both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:49:03 -07:00
shadow-testandClaude Opus 5 01a2f6aec8 Add Project Home, Auth Bridge, shared auth token, and Tier-1 polish
Project Home (DESIGN-REVIEW §B2): the project is promoted from a 280px
sidebar card to a first-class main-area view. ProjectCard.tsx (1,257
lines) is replaced by a select-only ProjectRow plus tabs for Overview,
Sessions, Automation, Config and Files. The PortMappings, FileManager
and ContainerProgress modals are absorbed rather than reimplemented.
Config gains a Saved/Saving/Failed indicator — save-on-blur failures
previously reached only console.error.

Tier-1 polish (DESIGN-REVIEW §A): new elevation, muted-accent, disabled
and focus-ring tokens; a global :focus-visible ring with every
focus:outline-none removed; filled buttons moved to --accent-emphasis
and white-on-success toggles retired, fixing three WCAG AA failures
(2.1:1, 2.5:1, 2.4:1); a shared Modal primitive with role="dialog",
focus trap and restore, adopted by all remaining modals; status
indicators that carry a glyph and word rather than colour alone.

Ctrl+Shift+W closes a tab, deliberately not Ctrl+W — that is readline's
kill-word, used constantly in the terminal this app is built around.

Auth Bridge: a general loopback-callback bridge so browser logins run
inside a container (aws sso login, Concourse fly login, claude login)
can complete against the host browser. Listeners are discovered from
/proc/net/tcp{,6} — ss/netstat/lsof are absent from the image — bound on
host 127.0.0.1 only, and tunnelled in over the Docker API via socat,
which keeps working on Docker Desktop where container IPs are not
routable. Falls back to [::1] because Node resolves localhost to IPv6
first, so claude login often binds ::1 alone. Opt-in per project.

This extracts create_attached_exec() and moves the existing terminal
session path onto it, so there is one attached-exec implementation
rather than two.

Shared auth token: `claude setup-token` is run in a container, the token
is stored in the OS keychain and injected as CLAUDE_CODE_OAUTH_TOKEN
into Anthropic-backend projects. Contrary to the initial design note,
setup-token uses an Anthropic-hosted redirect and blocks on a stdin
paste prompt rather than a loopback callback, so a stdin command is
required for the flow to complete.

The token is never logged, never returned to the frontend, and is
redacted from the streamed output with a stateful matcher that withholds
any tail that could still grow into a secret. Change detection uses a
random rotation id rather than a hash, since a hash in a docker-inspect
readable label would be an offline verification oracle.

Frontend 33 -> 51 tests; Rust 34 tests. Both builds clean.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 11:35:42 -07:00
shadow-testandClaude Opus 5 f68d10d5c2 Add DESIGN-REVIEW.md and ROADMAP.md
DESIGN-REVIEW.md is Fable's review of the v0.3.0 UI: token gaps and three
WCAG AA contrast failures, the modal/accessibility audit, and an IA
proposal that promotes the project from a sidebar card to a tabbed
main-area view.

ROADMAP.md covers Claude Code feature coverage — the five settings.json
keys currently surfaced, the gaps worth closing, the ones deliberately
skipped, the authentication handoff design, and phase sequencing.

Also published as an artifact for easier reading.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:56:26 -07:00
shadow-testandClaude Opus 5 0ac4e5030c Add permission modes and container introspection backend
Permission modes: replaces the binary full_permissions flag with a
PermissionMode enum (Plan/Default/AcceptEdits/Bypass). Flag mapping is
defined once in PermissionMode::cli_args() and used by the terminal, the
web terminal, and the scheduler:
  Plan        -> --permission-mode plan
  Default     -> (no flag)
  AcceptEdits -> --permission-mode acceptEdits
  Bypass      -> --dangerously-skip-permissions
Choices verified against `claude --permission-mode` on 2.1.226.

full_permissions is retained and effective_permission_mode() falls back
to it, so existing projects.json needs no migration.

Bug fix: triple-c-task-runner ran `claude -p ... --dangerously-skip-
permissions` unconditionally, ignoring the project's setting entirely.
It now reads TRIPLE_C_PERMISSION_MODE, which is injected into the
container, added to the reserved env blocklist, propagated through the
entrypoint's cron env filter, and tracked by a new
triple-c.permission-mode label so a change forces recreation.

Introspection: new commands/inspect_commands.rs exposes read-only views
into the container over docker exec — Claude sessions (parsed from
~/.claude/projects/<cwd>/<uuid>.jsonl), installed capabilities (skills,
agents, commands, hooks, plugins, natively-configured MCP servers), and
the triple-c-scheduler task list, logs and notifications.

Task/session ids are validated against a strict allowlist and every
parameterized call runs as a bare argv vector via bollard, so no shell
is involved. Stopped containers return empty results rather than errors.

No UI yet; that lands with the Project Home view.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:51:34 -07:00
shadow-testandClaude Opus 5 d0bb631d4d Remove MCP backend, entrypoint injection, and docs; add migration shim
Completes the removal begun in the previous commit.

Backend: deletes models/mcp_server.rs, storage/mcp_store.rs and
commands/mcp_commands.rs, the McpStore on AppState, the four IPC
handlers, Project::enabled_mcp_servers, build_mcp_servers_json(),
compute_mcp_fingerprint(), the MCP_SERVERS_JSON env injection, the
mcp-fingerprint label, and the whole MCP container lifecycle.
create_container() and container_needs_recreation() lose their
mcp_servers/network_name parameters.

Container: entrypoint.sh no longer merges MCP_SERVERS_JSON into
~/.claude.json. MCP_SERVERS_JSON stays in the reserved env blocklist.

Security: the Docker socket is no longer auto-mounted for stdio+Docker
MCP servers — it now mounts only when allow_docker_access is set.

Migration: old containers were created with
network_mode=triple-c-net-<projectId> and refuse to start once that
network is gone. docker/network.rs becomes docker/legacy_cleanup.rs with
label-driven, best-effort removal of leftover MCP containers and the
per-project network, called on both delete and recreate.
container_needs_recreation() now forces a rebuild for any container
carrying a non-empty triple-c.mcp-fingerprint label or attached to a
triple-c-net-* network, moving it onto the default bridge. Both can be
dropped a release later.

Docs: drops the MCP sections from README/HOW-TO-USE/TECHNICAL and adds a
short note pointing at Claude Code's native `claude mcp` / `/mcp` /
.mcp.json instead.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:31:18 -07:00
shadow-testandClaude Opus 5 657c61939f Remove MCP tab and per-project MCP UI from frontend
Claude Code manages MCP natively now (`claude mcp add/list/remove`,
`.mcp.json`, `/mcp`), so Triple-C's own MCP server library is redundant.

Deletes components/mcp/, hooks/useMcpServers.ts, the MCP sidebar tab and
rail icon, the per-project enable checkboxes on ProjectCard, the
mcpServers slice of the Zustand store, the four IPC wrappers, and the
McpServer/McpTransportType types.

Rust backend is untouched in this commit; the commands simply become
unreachable. Backend removal and the legacy container/network cleanup
follow separately.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-09 10:04:25 -07:00
jknapp 401e28a658 Merge pull request 'Fix conflicting --global/--file flags in entrypoint git config' (#12) from fix/entrypoint-gitconfig-flags into main
Build Container / build-container (push) Successful in 2m47s
2026-07-27 14:16:17 +00:00
shadow-testandClaude Opus 5 7c39e3cf11 Fix conflicting --global/--file flags in entrypoint git config
Build Container / build-container (pull_request) Successful in 10m15s
git rejects `--global` and `--file` together ("error: only one config
file at a time"), so the credential helper and user.name/user.email
were never written — /home/claude/.gitconfig was left nonexistent and
containers had no git identity or HTTPS token helper.

Drop `--global` and keep `--file /home/claude/.gitconfig`, which is the
intended target: the entrypoint runs as root at that point, so
`--global` would have resolved to /root/.gitconfig, and the existing
`chown claude:claude /home/claude/.gitconfig` already assumes the
--file path.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-07-27 06:05:06 -07:00
jknapp ccdfc52dce Merge pull request 'docs: document terminal layout & StatusBar control gotchas' (#11) from docs/terminal-layout-gotchas into main 2026-07-19 16:26:02 +00:00
shadow-testandClaude Opus 4.8 2e661979ea docs: document terminal layout & StatusBar control gotchas
Capture the non-obvious implementation gotchas from PR #7
(terminal-layout-statusbar) in TECHNICAL.md: wrapper-vs-host xterm
padding, global StatusBar controls, recordingSessionIdRef transcript
pinning, active-only Jump-to-Current state, and the Zustand
object-merge rule for publishing action callbacks.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-07-19 09:12:54 -07:00
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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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-testandClaude Opus 4.8 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
jknapp 2be6b9d9a8 Merge pull request 'UX improvements: Claude auto-update on container start + file-list scroll' (#5) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m3s
Build App / build-linux (push) Successful in 4m58s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Failing after 18m35s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Reviewed-on: #5
2026-06-24 01:51:49 +00:00
jknapp ebfe1f11a6 Merge branch 'main' into feature/ux-improvements
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 30s
Build App / build-linux (pull_request) Successful in 5m1s
Build App / build-windows (pull_request) Failing after 14m55s
Build App / build-macos (pull_request) Successful in 2m14s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-06-24 01:50:36 +00:00
shadow-testandClaude Opus 4.8 7f8102985e Update Claude on container start; harden file-list scroll
Build App / compute-version (pull_request) Successful in 3s
Build Container / build-container (pull_request) Successful in 7m34s
Build App / build-linux (pull_request) Successful in 5m2s
Build App / build-windows (pull_request) Failing after 16m56s
Build App / build-macos (pull_request) Successful in 2m37s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Add a time-bounded `claude update` to entrypoint.sh that runs as the
claude user before the container is marked ready, so every terminal
session launches the latest CLI. Non-fatal and capped at 120s so an
offline/slow network never blocks container readiness; PATH covers both
~/.claude/bin and ~/.local/bin install locations.

Add flex-shrink-0 to the FileManagerModal header/footer so a long file
list can't squeeze them and the scroll region stays robust.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 15:24:37 -07:00
jknapp 1a5fbd6be4 Merge pull request 'UX: collapsible sidebar, settings accordion, global backend defaults, tab rename' (#4) from feature/ux-improvements into main
Build App / compute-version (push) Successful in 3s
Build Container / build-container (push) Successful in 1m1s
Build App / build-macos (push) Successful in 2m30s
Build App / build-windows (push) Successful in 2m50s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #4
2026-05-24 16:39:34 +00:00
shadow-testandClaude Opus 4.7 2fa6abeae0 Allow renaming terminal tabs (persisted per project)
Build App / compute-version (pull_request) Successful in 3s
Build App / build-windows (pull_request) Successful in 5m33s
Build Container / build-container (pull_request) Successful in 7m58s
Build App / build-linux (pull_request) Successful in 4m51s
Build App / build-macos (pull_request) Successful in 2m39s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Right-click a tab (or double-click) to rename. Renamed labels show
as "ProjectName: CustomName" and are stored in the project's
renamed_session_names map. The entry is cleared on tab close.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:50:48 -07:00
shadow-testandClaude Opus 4.7 5b1c801cf1 Add global backend defaults with runtime fallback
New fields: GlobalAwsSettings.default_model_id, plus
GlobalOllamaSettings and GlobalOpenAiCompatibleSettings (base_url +
default_model_id each). When a per-project base_url or model_id is
blank, the container env vars and config fingerprints fall back to
the global value. Container recreation is triggered whenever the
resolved value changes, so editing a global default updates existing
projects on next start.

UI: added the new fields to AwsSettings and two new global settings
components, slotted into the Backends accordion.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:49:06 -07:00
shadow-testandClaude Opus 4.7 9b78b4bc62 Group Settings panel into accordion sections
Multiple-open accordion with per-section state persisted to
localStorage. Sections: General, Backends, Container, Git/SSH,
Tools, Updates. General is open by default; the rest are collapsed.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:42:18 -07:00
shadow-testandClaude Opus 4.7 7acc8b8d39 Add collapsible sidebar with icon rail
Persist collapsed state in localStorage. When collapsed, render a
narrow rail with Projects/MCP/Settings icon buttons that expand the
sidebar to that view on click.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-24 08:40:54 -07:00
shadow-testandClaude Opus 4.7 7840bddbb4 Sync bundled mission-control to upstream 15fbc94
Pulls in 15 upstream commits since the April 3 bundling snapshot
(msieurthenardier/mission-control). Notable changes:

- agentic-workflow rewritten as the "fast" variant: per-leg design and
  implement, single review and commit across the whole flight
- New Skill-Project Boundary section: skills no longer read or write
  project-owned artifacts by literal heading
- routine-maintenance scoped to post-mission only; adds state-machine
  reachability and cache freshness audits
- Test metrics capture threaded through debrief, maintenance, and flight
- Crew prompts no longer carry skill-required instructions; SKILL.md is
  the protocol
- Worktree git strategy removed; standardized on {target-project}
- Jira artifact template removed upstream

Local URL correction in init-project/README.md preserved
(anthropics/flight-control -> msieurthenardier/mission-control).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-18 08:10:32 -07:00
shadow-testandClaude Opus 4.7 4588bdf40c Make macOS release upload idempotent across re-runs
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 4m42s
Build App / build-linux (push) Successful in 8m54s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Previous fix only addressed the network flake; a re-run after any
upload failure still tripped over the leftover release record. The
naive POST /releases got 409 from Gitea, the grep-pipe parser yielded
an empty RELEASE_ID, and pipefail aborted with an opaque exit 1.

Now:
- Look up the release by tag first; reuse on 200, create on 404, fail
  loudly on anything else.
- Validate RELEASE_ID is non-empty and surface the response body if
  parsing fails.
- Before uploading each asset, check whether the release already has
  an asset with that name (from a partial prior run) and DELETE it so
  the POST is replace-not-conflict.
- Set -euo pipefail explicitly so the script's failure modes are
  predictable rather than dependent on the runner's default flags.

Network hardening from the previous commit (HTTP/1.1, retries, -f) is
preserved. Linux and Windows blocks unchanged.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 18:13:25 -07:00
shadow-testandClaude Opus 4.7 b607cf3681 Harden macOS release upload against curl exit 92
Build App / compute-version (push) Successful in 3s
Build App / build-windows (push) Successful in 4m5s
Build App / build-linux (push) Successful in 9m53s
Build App / build-macos (push) Failing after 2m30s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
macOS upload has been intermittently failing with curl exit 92
("HTTP/2 stream not closed cleanly") for several releases (v0.3.12,
v0.3.10, v0.3.1 all landed with empty asset arrays despite the per-tag
release record being created). It is not a size issue — Linux uploads
the 81MB AppImage on the same Gitea instance without trouble while the
Mac dmg is only 13.6MB.

Adds `--http1.1` to sidestep HTTP/2 stream multiplexing flakes on the
macOS runner, `-f` so HTTP errors no longer fail silently under `-s`,
and `--retry 5 --retry-all-errors --retry-delay 5 --max-time 600` to
absorb transient drops. Linux and Windows blocks unchanged; an inline
note in the YAML calls out where to mirror this if those start
failing.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:28:08 -07:00
shadow-testandClaude Opus 4.7 21a85dc977 Bump @tauri-apps/api and @tauri-apps/cli to 2.11.0 in package-lock
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Failing after 3m24s
Build App / build-windows (push) Successful in 4m9s
Build App / build-linux (push) Successful in 7m20s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Mac/Windows release builds failed the Tauri version-mismatch check:
tauri (2.11.0) vs @tauri-apps/api (2.10.1). The Linux fix only updated
the Rust lockfile; the npm lockfile was still at 2.10.x. Both lockfiles
now resolve to 2.11.0.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 17:09:22 -07:00
shadow-testandClaude Opus 4.7 272eb28863 Bump tauri Rust crate to 2.11.0 to match @tauri-apps/api
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Failing after 6s
Build App / build-windows (push) Failing after 24s
Build App / build-linux (push) Successful in 6m52s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
CI's pre-build version check failed: tauri (2.10.2) vs @tauri-apps/api
(2.11.0). Both the Cargo.toml and package.json caret-pin to 2, so this is
purely a lockfile resolution fix — `cargo update -p tauri --precise
2.11.0` brings the Rust side up to match. Schema regeneration is included
since the gen/schemas/ output is keyed to the Tauri version.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 13:32:02 -07:00
jknapp 1ef6efca9f Merge pull request 'feature/docker-install-helper' (#3) from feature/docker-install-helper into main
Build App / compute-version (push) Successful in 2s
Build Container / build-container (push) Successful in 1m1s
Build App / build-linux (push) Failing after 2m0s
Build App / build-macos (push) Failing after 3m27s
Build App / build-windows (push) Successful in 4m8s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
Reviewed-on: #3
2026-05-01 20:01:58 +00:00
shadow-testandClaude Opus 4.7 5974347913 Add per-project sandbox mode and Bedrock service-tier
Build App / compute-version (pull_request) Successful in 2s
Build App / build-macos (pull_request) Successful in 2m31s
Build App / build-windows (pull_request) Successful in 8m1s
Build Container / build-container (pull_request) Successful in 8m11s
Build App / build-linux (pull_request) Failing after 1m53s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Sandbox mode: new per-project toggle that turns on Claude Code's bash
sandbox inside the container. Adds `bubblewrap` and `socat` to the
Dockerfile (the two Linux deps required by the sandbox), and emits a
managed `sandbox` block into `~/.claude/settings.json` via the existing
CLAUDE_CODE_SETTINGS_JSON entrypoint merge:

- `enabled` mirrors the Triple-C toggle and is always emitted, so the
  entrypoint's recursive jq merge clears any prior on-state from the
  persisted named volume — Triple-C is authoritative.
- `enableWeakerNestedSandbox: true` because we run inside Docker without
  privileged user namespaces.
- `allowUnsandboxedCommands: false` to disable the `dangerouslyDisableSandbox`
  escape hatch — opting into the sandbox shouldn't come with a runtime
  bypass.

When sandbox is on, a SANDBOX_INSTRUCTIONS section is appended to
CLAUDE_INSTRUCTIONS so Claude can guide users through allowing extra
paths/domains, excluding `docker *`/`watchman *` from the sandbox, and
the rule that `sandbox.enabled` is owned by Triple-C. The Claude-Code
settings fingerprint includes sandbox state (only when on, to avoid
spuriously flagging existing containers for recreation on upgrade).

Bedrock service tier: new optional field on the per-project Bedrock
config. When set, exported as ANTHROPIC_BEDROCK_SERVICE_TIER (added in
Claude Code 2.1.122) and included in the Bedrock fingerprint.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 12:58:54 -07:00
shadow-testandClaude Opus 4.7 805f815876 Regenerate Tauri ACL schemas after dialog plugin update
Picks up the deprecation notes on dialog `ask`/`confirm` permissions
(now aliased to `allow-message`/`deny-message` and slated for removal
in Tauri v3). No behavior change — generated artifacts only.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-05-01 11:57:22 -07:00
shadow-testandClaude Opus 4.7 5360f22b65 Make preview build workflow manual-only
Trigger is workflow_dispatch exclusively so builds happen only when
explicitly requested from the Actions UI, not on every branch push.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:22:11 -07:00
shadow-testandClaude Opus 4.7 0316234329 Add preview build workflow for non-main branches
Build App (Preview) / compute-version (push) Successful in 2s
Build App (Preview) / build-macos (push) Failing after 2m28s
Build App (Preview) / build-windows (push) Failing after 4m29s
Build App (Preview) / build-linux (push) Failing after 8m4s
Mirrors build-app.yml's three-platform matrix (Linux/macOS/Windows)
but uploads the bundles as workflow artifacts instead of creating
Gitea releases or syncing to GitHub, so feature branches can be
smoke-tested without cluttering the release streams.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:21:17 -07:00
shadow-testandClaude Opus 4.7 ee68cc820c Add Docker install helper for first-run setup
When Docker isn't detected on startup, surface a dialog offering a
one-click install (pkexec + get.docker.com on Linux, brew cask on
macOS, winget on Windows) with a graceful fallback to manual steps
and a link to official documentation. Install output streams back
to the UI via a tauri event.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-24 10:18:46 -07:00
shadow-testandClaude Opus 4.7 7f6655fbcf Trim whitespace on terminal copy by default, keep raw copy on Ctrl+Shift+Alt+C and right-click menu
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m42s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 17s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-17 08:58:56 -07:00
shadow-testandClaude Opus 4.7 b907ad0239 Add breathing room to terminal bottom-left so STT button clears Claude Code status
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 4m20s
Build App / build-linux (push) Successful in 5m45s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 16:27:30 -07:00
shadow-testandClaude Opus 4.7 de1d809de5 Update Flight Control reference URL to mission-control repo
Build Container / build-container (push) Successful in 1m13s
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:45:29 -07:00
shadow-testandClaude Opus 4.7 3c7852544b Fix TUI fullscreen mode cutting off Claude Code status line
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 3m56s
Build App / build-linux (push) Successful in 5m5s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 16s
Add bottom padding to terminal containers so FitAddon proposes one
fewer row, leaving visible space below Claude Code's mode indicator.
Previously the bottom status line (e.g. "bypass permissions on") was
clipped against the container edge in fullscreen TUI mode.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
2026-04-16 15:44:10 -07:00
shadow-testandClaude Opus 4.6 ddf44d97e5 Fix Docker build: manual NodeSource setup + retry loops on all apt-get updates
Build Container / build-container (push) Successful in 41m2s
The previous fix wasn't enough: the NodeSource setup_22.x script runs its
own internal `apt-get update` without retries. When that hit the Ubuntu
mirror-sync issue (stale Packages.gz with mismatched hash), the script
silently bailed without configuring the NodeSource repo. The next
`apt-get install -y nodejs` then installed Ubuntu's default nodejs 18,
which ships without npm, breaking the `npm install -g pnpm` step.

Changes:
- Replace the `curl ... | bash -` NodeSource setup with manual GPG key +
  repo file configuration, giving us direct control over apt-get update
  retries.
- Add the same 5-attempt retry loop (with 10s sleep and lists cleanup)
  to the Python 3 and Docker CLI steps, since both also do an
  apt-get update and would hit the same failure mode.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:50:42 -07:00
shadow-testandClaude Opus 4.6 d60124f1bd Fix CI: harden version computation and Dockerfile apt retries
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m44s
Build App / build-windows (push) Successful in 5m18s
Build App / build-linux (push) Successful in 46m30s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 11s
Build Container / build-container (push) Failing after 3m14s
Two fixes for the v0.3.x initial build failures:

1. **Compute Version step**: When no tags match v0.3.*, `grep` returns
   exit 1 which under `pipefail` killed the step before the empty-tag
   fallback could run. Added `|| true` to the pipeline so the fallback
   (`git rev-list --count HEAD`) runs correctly on first 0.3.x build.

2. **Dockerfile apt-get update**: Transient archive.ubuntu.com mirror
   sync failures (stale Packages.gz with mismatched hash) broke the
   GitHub CLI install step. Added a shell retry loop (5 attempts with
   10s sleep, clearing /var/lib/apt/lists/* between retries) to both
   the main system packages step and the GitHub CLI step, plus
   Acquire::Retries=3 on the other apt-get update calls for transient
   network failures.

Also includes the Cargo.lock 0.2.0 → 0.3.0 rev that went with the
previous version bump commit.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 09:09:30 -07:00
shadow-testandClaude Opus 4.6 4f23951379 Bump version to 0.3.0
Build App / compute-version (push) Failing after 3s
Build App / build-linux (push) Has been skipped
Build App / build-macos (push) Has been skipped
Build App / build-windows (push) Has been skipped
Build Container / build-container (push) Failing after 15m59s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
## What's New in v0.3.0

### Claude Code Settings (TUI Mode, Effort, Focus, Caching)
- New per-project and global settings for Claude Code CLI behavior
- **TUI Fullscreen Mode**: Flicker-free alt-screen rendering via CLAUDE_CODE_NO_FLICKER
- **Effort Level**: Control reasoning depth (low/medium/high)
- **Focus Mode**: Collapse tool output to one-line summaries
- **Thinking Summaries**: Show Claude's thinking process
- **Session Recap**: Get context when returning to a session
- **Auto-Scroll Disabled**: Disable auto-scroll in fullscreen TUI
- **Env Scrub**: Strip credentials from subprocess environments
- **Prompt Caching (1h)**: Enable 1-hour prompt cache TTL
- New ClaudeCodeSettingsModal accessible from project config and global settings
- Settings injected as env vars and ~/.claude/settings.json via entrypoint

### Session Naming
- Name Claude Code terminal sessions with the -n flag
- Session names displayed in terminal tabs instead of project name

### Global Default Fallbacks
- Global SSH key path now used when per-project SSH path is not set
- Global git name/email now used when per-project values are not set
- New UI in Settings panel for SSH key directory, git name, and git email

### Relaxed Environment Variable Filter
- CLAUDE_CODE_* env vars now allowed in custom env vars for power users
- Only specific internal vars (CLAUDE_INSTRUCTIONS, MCP_SERVERS_JSON, etc.) blocked

### Documentation
- Updated README, HOW-TO-USE, and CLAUDE.md with all new features
- New "Claude Code Tips" section documenting built-in CLI features
  (/focus, /recap, /color, /loop, /powerup, /team-onboarding, setup wizards)

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:48:19 -07:00
shadow-testandClaude Opus 4.6 d6ac3ae6c6 Add Claude Code settings infrastructure, TUI mode, session naming, and global defaults
Adds first-class support for Claude Code CLI features (2.1.71-2.1.110):

- New ClaudeCodeSettings struct with per-project and global defaults for
  TUI mode, effort level, focus mode, thinking summaries, session recap,
  auto-scroll, env scrub, and 1-hour prompt caching
- Settings injected as env vars (CLAUDE_CODE_NO_FLICKER, etc.) and
  ~/.claude/settings.json entries via entrypoint.sh merge block
- New ClaudeCodeSettingsModal component for configuring settings
- Session naming support (-n flag passed to claude CLI, shown in tabs)
- Relaxed reserved prefix filter: CLAUDE_CODE_* env vars now allowed in
  custom env vars UI for power users
- Global SSH key path, git name, and git email now used as fallbacks
  when per-project values are not set, with UI in SettingsPanel
- Fingerprint-based change detection triggers container recreation when
  Claude Code settings change
- Updated README, HOW-TO-USE, and CLAUDE.md documentation

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-16 08:46:03 -07:00
shadow-testandClaude Opus 4.6 ef67b447b3 Pre-validate AWS SSO session on host during container startup
Build App / compute-version (push) Successful in 3s
Build App / build-linux (push) Successful in 4m46s
Build App / build-windows (push) Successful in 6m57s
Build App / build-macos (push) Successful in 9m9s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
For Bedrock Profile projects, SSO credentials are now checked and
refreshed on the host before the container starts, so the entrypoint
copies already-valid tokens. This eliminates the delay where users
had to wait for the terminal to open before being prompted to login.

The terminal-time fallback remains for mid-session credential expiry.
Also consolidates duplicated profile resolution logic into a shared
helper in aws_commands.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-15 06:54:59 -07:00
shadow-testandClaude Opus 4.6 15b03173a5 Update README with Speech-to-Text documentation
Add STT section covering voice mode usage, hotkey (Ctrl+Shift+M), model
options, auto-start behavior, and transcription flow. Update Key Files
table with all STT-related files and fix outdated useVoice.ts reference.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:40:45 -07:00
shadow-testandClaude Opus 4.6 a0b4dca0bd Auto-start STT container on app launch when enabled in settings
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m31s
Build App / build-windows (push) Successful in 4m40s
Build App / build-linux (push) Successful in 4m45s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Previously the STT container only started on-demand (mic button click or
manual start in settings). Now it auto-starts during app setup if
stt.enabled is true, matching the web terminal auto-start pattern.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 06:34:36 -07:00
jknapp 17c5d699f9 Merge pull request 'STT improvements: hotkey, button position, and hover tooltip' (#2) from feature/stt into main
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 4m4s
Build App / build-linux (push) Successful in 4m56s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Reviewed-on: #2
2026-04-13 13:02:53 +00:00
shadow-test e62af502d3 Merge remote-tracking branch 'origin/main' into feature/stt
Build App / compute-version (pull_request) Successful in 2s
Build App / build-macos (pull_request) Successful in 2m23s
Build App / build-windows (pull_request) Successful in 3m53s
Build App / build-linux (pull_request) Successful in 4m47s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
2026-04-13 06:02:27 -07:00
shadow-testandClaude Opus 4.6 3e9053946f Add styled hover tooltip to STT button showing Ctrl+Shift+M shortcut
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m25s
Build App / build-windows (pull_request) Successful in 4m36s
Build App / build-linux (pull_request) Successful in 4m43s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Replaces the native title attribute with a custom tooltip that appears
instantly on hover, displaying the shortcut in a styled kbd element.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:58:29 -07:00
shadow-testandClaude Opus 4.6 3bbd7fd55f Move STT mic button to bottom-left corner to avoid clipping Claude Code status line
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:56:59 -07:00
shadow-testandClaude Opus 4.6 49d09e4447 Add Ctrl+Shift+M hotkey for speech-to-text toggle
Lifts useSTT hook from SttButton into TerminalView so both the hotkey
and the button share the same recording state. The hotkey keeps terminal
focus so after transcription the user just presses Enter. The button
also no longer steals focus via onMouseDown preventDefault.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 05:55:52 -07:00
jknapp 702ebb7247 Merge pull request 'Add speech-to-text via Faster Whisper container' (#1) from feature/stt into main
Build App / compute-version (push) Successful in 3s
Build STT Container / build-stt-container (push) Successful in 28s
Build App / build-macos (push) Successful in 2m28s
Build App / build-windows (push) Successful in 4m2s
Build App / build-linux (push) Successful in 4m42s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Reviewed-on: #1
2026-04-13 03:35:40 +00:00
shadow-testandClaude Opus 4.6 caf3e26816 Update @tauri-apps/plugin-dialog npm package to 2.7.0
Build App / compute-version (pull_request) Successful in 4s
Build STT Container / build-stt-container (pull_request) Successful in 14s
Build App / build-macos (pull_request) Successful in 2m23s
Build App / build-windows (pull_request) Successful in 4m5s
Build App / build-linux (pull_request) Successful in 4m38s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Aligns the npm lockfile with the Cargo crate version to fix the Tauri
build version mismatch check.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:28:03 -07:00
shadow-testandClaude Opus 4.6 765ba91d7b Fix tauri-plugin-dialog version mismatch (2.6.0 → 2.7.0)
Build App / compute-version (pull_request) Successful in 2s
Build App / build-macos (pull_request) Failing after 6s
Build STT Container / build-stt-container (pull_request) Successful in 12s
Build App / build-windows (pull_request) Failing after 24s
Build App / build-linux (pull_request) Successful in 4m50s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Cargo had resolved to 2.6.0 while npm had 2.7.0, causing the Tauri
build version check to fail.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:20:09 -07:00
shadow-testandClaude Opus 4.6 532de77927 Add speech-to-text feature using Faster Whisper container
Build App / compute-version (pull_request) Successful in 3s
Build App / build-macos (pull_request) Successful in 2m28s
Build STT Container / build-stt-container (pull_request) Successful in 3m18s
Build App / build-windows (pull_request) Successful in 4m40s
Build App / build-linux (pull_request) Failing after 1m46s
Build App / create-tag (pull_request) Has been skipped
Build App / sync-to-github (pull_request) Has been skipped
Adds a mic button to the terminal UI that captures speech, transcribes
it via a Faster Whisper sidecar container, and injects the text into
the terminal input. Includes settings panel for model selection
(tiny/small/medium), port config, and container lifecycle management.

- stt-container/: Dockerfile + FastAPI server for Whisper transcription
- Rust backend: STT container management, transcribe_audio IPC command
- Frontend: useSTT hook, SttButton, SttSettings, WAV encoder
- CI: Gitea Actions workflow for multi-arch STT image builds

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-12 20:02:39 -07:00
shadow-testandClaude Opus 4.6 8301fd3690 Add workflow to clean up old releases
Manual workflow that deletes old Gitea and GitHub releases,
keeping only the N most recent versions. Defaults to dry-run
mode for safe preview before deletion.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-07 08:19:31 -07:00
shadow-testandClaude Opus 4.6 2dffef0767 Bundle mission-control into Triple-C instead of cloning from GitHub
Build App / compute-version (push) Successful in 2s
Build App / build-macos (push) Successful in 2m47s
Build Container / build-container (push) Successful in 9m0s
Build App / build-linux (push) Successful in 4m41s
Build App / build-windows (push) Successful in 5m33s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
The mission-control (Flight Control) project is being closed upstream.
This embeds the project files directly in the repo under container/mission-control/,
bakes them into the Docker image at /opt/mission-control, and copies them into place
at container startup instead of git cloning from GitHub.

Also adds missing osc52-clipboard, audio-shim, and triple-c-sso-refresh to the
programmatic Docker build context in image.rs.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-03 09:09:15 -07:00
shadow-testandClaude Opus 4.6 57a7cee544 Make topbar and tab bar sticky in web terminal
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 4m13s
Build App / build-linux (push) Successful in 4m51s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Adds position:sticky to the topbar and tab bar so they stay pinned
at the top when the virtual keyboard opens on tablets. Also uses
100dvh (dynamic viewport height) so the layout properly shrinks
when the keyboard appears on mobile browsers.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-18 09:15:31 -07:00
shadow-testandClaude Opus 4.6 6369f7e0a8 Document web terminal feature across all docs
Adds web terminal documentation to README (architecture, key files),
HOW-TO-USE (setup guide, usage, security tips), TECHNICAL (system
diagram, communication flow, dependencies, project structure), and
CLAUDE.md (backend structure).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:09:05 -07:00
shadow-testandClaude Opus 4.6 9ee0d34c19 Fix tablet keyboard input lag and add scroll-to-bottom button
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m29s
Build App / build-windows (push) Successful in 3m55s
Build App / build-linux (push) Successful in 4m34s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 9s
Adds a dedicated input bar at the bottom that bypasses mobile IME
composition buffering — characters are sent immediately on each input
event instead of waiting for word boundaries. Also adds helper buttons
(Enter, Tab, Ctrl+C) and a floating scroll-to-bottom button that
appears when scrolled up from the terminal output.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 20:03:13 -07:00
shadow-testandClaude Opus 4.6 922543cc04 Add web terminal for remote tablet/phone access to project terminals
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m36s
Build App / build-windows (push) Successful in 4m39s
Build App / build-linux (push) Successful in 5m56s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Adds an axum HTTP+WebSocket server that runs alongside the Tauri app,
serving a standalone xterm.js-based terminal UI accessible from any
browser on the local network. Shares the existing ExecSessionManager
via Arc-wrapped stores, with token-based authentication and automatic
session cleanup on disconnect.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-17 19:31:16 -07:00
shadow-testandClaude Opus 4.6 13038989b8 Fix spurious container snapshot on every start for projects with env vars
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 4m7s
Build App / build-linux (push) Successful in 4m58s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
Migrate env-var-based checks in container_needs_recreation() to label-based
checks. When a container snapshot is committed, Docker merges the image's env
vars with the container's, causing get_env() to return stale values and
triggering an infinite snapshot→recreate loop. Labels are immutable after
creation and immune to this merging behavior.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-16 13:17:00 -07:00
shadow-testandClaude Opus 4.6 b55de8d75e Fix Jump to Current button not appearing on scroll-up
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m23s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 5m9s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 12s
The onScroll RAF optimization (only fire when atBottom changes) prevented
the button from showing because xterm's onScroll may not fire from wheel
events. Fix by setting isAtBottom(false) directly in the wheel handler
and removing the RAF guard to always schedule state updates.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 10:28:28 -07:00
shadow-testandClaude Opus 4.6 8512ca615d Fix terminal scroll glitch and add auto-follow toggle button
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m4s
Build App / build-linux (push) Successful in 4m53s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 11s
Prevent viewport jumping during Claude output by only re-enabling
auto-follow on user-initiated scrolls (wheel events within 300ms),
not on write-triggered xterm scroll events. Add a "Following/Paused"
toggle button in the top-right corner of the terminal.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-15 07:00:09 -07:00
shadow-testandClaude Opus 4.6 ebae39026f Fix terminal auto-scroll and jump-to-bottom button coexistence
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 2m33s
Build App / build-linux (push) Successful in 6m3s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
The previous fix checked isAtBottomRef inside the write callback, but
xterm's own scroll events during write processing could set the ref to
false (viewport desync), breaking auto-follow entirely.

Introduce a separate autoFollowRef that tracks user intent:
- Set to false only by explicit mouse wheel scroll-up (capture phase)
- Set to true when viewport reaches bottom or user clicks the button
- Write callback uses autoFollowRef so desync doesn't kill auto-follow
  but user scroll-up correctly pauses it

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 22:00:16 -07:00
shadow-testandClaude Opus 4.6 d34e8e2c6d Fix jump-to-bottom button broken during active terminal output
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m4s
Build App / build-linux (push) Successful in 5m31s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 11s
The shouldFollow flag was captured before term.write() but the callback
ran asynchronously — if the user scrolled up in between, the stale flag
forced the viewport back to bottom, preventing the button from appearing.

Check isAtBottomRef at callback time instead so user scroll-up is respected.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-14 21:35:58 -07:00
shadow-testandClaude Opus 4.6 3935104cb5 Fix xterm scroll jump and viewport desync during long output
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m24s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Auto-scroll viewport on new output when user is at bottom, debounce
scroll state updates to reduce re-renders, preserve scroll position
across resize reflows, and fix "Jump to Current" button by re-fitting
the terminal to clear viewport desync.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-03-13 12:59:19 -07:00
shadow-testandClaude Opus 4.6 b17c759bd6 Fix remaining repo.anhonesthost.net references in user-facing code
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m35s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 14s
- help_commands.rs: fetch HOW-TO-USE.md from GitHub raw instead of Gitea
- DockerSettings.tsx: display GHCR image address in settings UI
- HOW-TO-USE.md: update registry description to ghcr.io

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 11:31:46 -07:00
shadow-testandClaude Opus 4.6 bab1df1c57 Switch distribution from Gitea to GitHub/GHCR
Build App / compute-version (push) Successful in 6s
Build Container / build-container (push) Successful in 1m44s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m30s
Build App / build-linux (push) Successful in 4m52s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Work VPN blocks repo.anhonesthost.net, breaking update checks and image
pulls. Move all user-facing distribution to GitHub (releases API) and
GHCR (container images) while keeping Gitea as the source of truth for
development and CI.

- CI: push container images to GHCR alongside Gitea registry
- App updates: switch releases API to api.github.com, filter by asset
  filename instead of tag suffix for unified releases
- Image updates: switch registry to ghcr.io with anonymous token auth
- Container pull: point REGISTRY_IMAGE to ghcr.io/shadowdao/triple-c-sandbox
- Rename GiteaRelease/GiteaAsset structs to GitHubRelease/GitHubAsset

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 10:55:41 -07:00
shadow-testandClaude Opus 4.6 b952b8e8de Add per-project full permissions toggle for --dangerously-skip-permissions
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 2m35s
Build App / build-linux (push) Successful in 4m43s
Build App / create-tag (push) Successful in 4s
Build App / sync-to-github (push) Successful in 11s
New projects default to standard permission mode (Claude asks before acting).
Existing projects default to full permissions ON, preserving current behavior.
UI toggle uses red/caution styling to highlight the security implications.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 08:58:13 -07:00
shadow-testandClaude Opus 4.6 d7d7a83aec Rename LiteLLM backend to OpenAI Compatible
Build App / compute-version (push) Successful in 8s
Build App / build-macos (push) Successful in 2m25s
Build App / build-windows (push) Successful in 4m0s
Build App / build-linux (push) Successful in 4m47s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 12s
Reflects that this backend works with any OpenAI API-compatible endpoint
(LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.),
not just LiteLLM. Includes serde aliases for backward compatibility with
existing projects.json files.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-13 06:16:05 -07:00
shadow-testandClaude Opus 4.6 879322bc9a Add copy/paste keyboard shortcut docs to How-To guide
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:31:41 -07:00
shadow-testandClaude Opus 4.6 ecaa42fa77 Remove unused useShallow import to fix tsc build
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m27s
Build App / build-windows (push) Successful in 3m33s
Build App / build-linux (push) Successful in 4m49s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:20:08 -07:00
shadow-testandClaude Opus 4.6 280358166a Show copy hint in status bar when terminal text is selected
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Failing after 7s
Build App / build-windows (push) Failing after 19s
Build App / build-linux (push) Failing after 1m57s
Build App / create-tag (push) Has been skipped
Build App / sync-to-github (push) Has been skipped
When the user highlights text in the terminal, a "Ctrl+Shift+C to copy"
hint appears in the status bar next to the project/terminal counts.
The hint disappears when the selection is cleared.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:14:08 -07:00
shadow-testandClaude Opus 4.6 4732feb33e Add Ctrl+Shift+C keyboard shortcut for copying terminal text
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m25s
Build App / build-linux (push) Successful in 5m33s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Successful in 10s
Ctrl+C in the terminal sends SIGINT which cancels running Claude work.
This adds a custom key handler so Ctrl+Shift+C copies selected text to
the clipboard without interrupting the container.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 13:05:10 -07:00
shadow-testandClaude Opus 4.6 5977024953 Update Ollama docs and UI to mark model as required
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m22s
Build App / build-windows (push) Successful in 3m25s
Build App / build-linux (push) Successful in 4m48s
Build App / create-tag (push) Successful in 9s
Build App / sync-to-github (push) Successful in 14s
The model field must be set and the model must be pre-pulled in Ollama
before the container will work. Updated README, HOW-TO-USE, and the
ProjectCard UI label/tooltip to reflect this.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 12:53:24 -07:00
shadow-testandClaude Opus 4.6 27007b90e3 Fetch help content from repo, add TOC and marketplace troubleshooting
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m57s
Build App / build-linux (push) Successful in 5m2s
Build App / create-tag (push) Successful in 5s
Build App / sync-to-github (push) Successful in 10s
Help dialog now fetches HOW-TO-USE.md live from the gitea repo on open,
falling back to the compile-time embedded copy when offline. Content is
cached for the session. Removes the ~600-line hardcoded markdown constant
from HelpDialog.tsx in favor of a single source of truth.

Adds a Table of Contents with anchor links for quick navigation and a new
troubleshooting entry for the "Failed to install Anthropic marketplace"
error with the jq fix. Markdown renderer updated to support anchor links
and header id attributes.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 11:00:59 -07:00
shadow-testandClaude Opus 4.6 38e65619e9 Fix tooltips clipped by overflow containers, improve Backend tooltip text
Build App / compute-version (push) Successful in 4s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m44s
Build App / create-tag (push) Successful in 6s
Build App / sync-to-github (push) Successful in 12s
Rewrite Tooltip to use React portal (createPortal to document.body) so
tooltips render above all UI elements regardless of ancestor overflow:hidden.
Also increased max-width from 220px to 280px for longer descriptions.

Expanded Backend tooltip to explain each option (Anthropic, Bedrock,
Ollama, LiteLLM) with practical context for new users.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:56:50 -07:00
shadow-testandClaude Opus 4.6 d2c1c2108a Fix update checker to use full semver comparison and correct platform filtering
Build App / compute-version (push) Successful in 5s
Build App / build-macos (push) Successful in 2m20s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m21s
Build App / create-tag (push) Successful in 3s
Build App / sync-to-github (push) Has been cancelled
The version comparison was only comparing the patch number, ignoring major
and minor versions. This meant 0.1.75 (patch=75) appeared "newer" than
0.2.1 (patch=1), and updates within 0.2.x were missed entirely.

Also fixed platform filtering to handle -mac suffix (previously only
filtered -win, so Linux users would see macOS releases too).

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:45:43 -07:00
shadow-testandClaude Opus 4.6 cc163e6650 Add help dialog and tooltip indicators throughout the UI
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m29s
Build App / build-linux (push) Successful in 5m43s
Build App / create-tag (push) Successful in 7s
Build App / sync-to-github (push) Successful in 13s
- Add circled ? help button in TopBar that opens a dialog with HOW-TO-USE.md content
- Create reusable Tooltip component with viewport-aware positioning
- Add 32 tooltip indicators across project config and settings panels
- Covers backend selection, Bedrock/Ollama/LiteLLM fields, Docker, AWS, MCP, and more

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:35:04 -07:00
shadow-testandClaude Opus 4.6 38082059a5 Rename AuthMode to Backend, fix LiteLLM variant typo, add image update alerts, clean up Settings
Build App / compute-version (push) Successful in 6s
Build App / build-macos (push) Successful in 2m21s
Build App / build-windows (push) Successful in 3m28s
Build App / build-linux (push) Successful in 5m14s
Build App / create-tag (push) Successful in 2s
Build App / sync-to-github (push) Successful in 10s
- Fix serde deserialization error: TypeScript sent "lit_llm" but Rust expected "lite_llm"
- Rename AuthMode enum to Backend across Rust and TypeScript (with serde alias for backward compat)
- Add container image update checking via registry digest comparison
- Improve Settings page: fix image address display spacing, remove per-project auth section
- Update UI labels from "Auth" to "Backend" throughout

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 09:26:58 -07:00
shadow-testandClaude Opus 4.6 beae0942a1 Add dynamic versioning with VERSION file and tag-based patch counting
Build App / compute-version (push) Successful in 3s
Build App / build-macos (push) Successful in 2m19s
Build App / build-windows (push) Successful in 3m22s
Build App / build-linux (push) Successful in 6m14s
Build App / create-tag (push) Successful in 8s
Build App / sync-to-github (push) Successful in 10s
- Create VERSION file (currently `0.2`) as the single source of truth for major.minor
- Add compute-version job that reads VERSION and counts commits since the last
  matching v{major.minor}.N tag to derive the patch number
- Patch resets automatically when VERSION is bumped (no matching tags exist yet)
- Add create-tag job that tags the repo after all platform builds succeed,
  so subsequent builds count from the new tag
- All platform jobs now consume the shared version instead of computing their own
- VERSION file changes trigger the build workflow

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
2026-03-12 08:11:04 -07:00
199 changed files with 29296 additions and 4567 deletions
+317
View File
@@ -0,0 +1,317 @@
name: Build App (Preview)
# Builds the Tauri app for branches other than main and exposes the bundles as
# workflow artifacts. No Gitea release, no GitHub sync — intended for local
# smoke-testing of feature branches before they merge.
on:
workflow_dispatch:
jobs:
compute-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Compute preview version
id: version
run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
SHORT_SHA=$(git rev-parse --short HEAD)
VERSION="${MAJOR_MINOR}.0-preview.${SHORT_SHA}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed preview version: ${VERSION}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
OLD_NODE_DIR=$(dirname "$(which node)")
echo "Found Node.js $(node --version) at $(which node) (major: ${NODE_MAJOR})"
if [ "$NODE_MAJOR" -lt 22 ]; then
echo "Node.js ${NODE_MAJOR} is too old, removing before installing 22..."
sudo rm -f "${OLD_NODE_DIR}/node" "${OLD_NODE_DIR}/npm" "${OLD_NODE_DIR}/npx" "${OLD_NODE_DIR}/corepack"
hash -r
NEED_INSTALL=true
fi
else
echo "Node.js not found, installing 22..."
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
sudo apt-get install -y nodejs
hash -r
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
run: |
# Tauri / Cargo require a strict semver; strip the preview suffix for
# the bundle version but keep it in the artifact filename.
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${BASE_VERSION}"
- name: Install system dependencies
run: |
sudo apt-get update
sudo apt-get install -y \
libgtk-3-dev \
libwebkit2gtk-4.1-dev \
libayatana-appindicator3-dev \
librsvg2-dev \
libsoup-3.0-dev \
libssl-dev \
libxdo-dev \
patchelf \
pkg-config \
build-essential \
curl \
wget \
file \
xdg-utils
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules package-lock.json
npm install
- name: Install Tauri CLI
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build
- name: Collect artifacts
run: |
mkdir -p artifacts
cp app/src-tauri/target/release/bundle/appimage/*.AppImage artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/deb/*.deb artifacts/ 2>/dev/null || true
cp app/src-tauri/target/release/bundle/rpm/*.rpm artifacts/ 2>/dev/null || true
ls -la artifacts/
- name: Upload Linux artifacts
uses: actions/upload-artifact@v4
with:
name: triple-c-${{ needs.compute-version.outputs.version }}-linux
path: artifacts/
if-no-files-found: error
retention-days: 14
build-macos:
runs-on: macos-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
NEED_INSTALL=false
if command -v node >/dev/null 2>&1; then
NODE_MAJOR=$(node --version | sed 's/v\([0-9]*\).*/\1/')
if [ "$NODE_MAJOR" -lt 22 ]; then
NEED_INSTALL=true
fi
else
NEED_INSTALL=true
fi
if [ "$NEED_INSTALL" = true ]; then
brew install node@22
brew link --overwrite node@22
fi
node --version
npm --version
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
run: |
BASE_VERSION="$(echo '${{ needs.compute-version.outputs.version }}' | cut -d'-' -f1)"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"${BASE_VERSION}\"/" app/package.json
sed -i '' "s/^version = \".*\"/version = \"${BASE_VERSION}\"/" app/src-tauri/Cargo.toml
echo "Patched version to ${BASE_VERSION}"
- name: Install Rust stable
run: |
if command -v rustup >/dev/null 2>&1; then
rustup update stable
rustup default stable
else
curl --proto '=https' --tlsv1.2 -sSf https://sh.rustup.rs | sh -s -- -y --default-toolchain stable
fi
export PATH="$HOME/.cargo/bin:$PATH"
rustup target add aarch64-apple-darwin x86_64-apple-darwin
rustc --version
cargo --version
- name: Install frontend dependencies
working-directory: ./app
run: |
rm -rf node_modules
npm install
- name: Install Tauri CLI
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri --version || npm install @tauri-apps/cli
- name: Build Tauri app (universal)
working-directory: ./app
run: |
export PATH="$HOME/.cargo/bin:$PATH"
npx tauri build --target universal-apple-darwin
- name: Collect artifacts
run: |
mkdir -p artifacts
cp app/src-tauri/target/universal-apple-darwin/release/bundle/dmg/*.dmg artifacts/ 2>/dev/null || true
cp app/src-tauri/target/universal-apple-darwin/release/bundle/macos/*.app.tar.gz artifacts/ 2>/dev/null || true
ls -la artifacts/
- name: Upload macOS artifacts
uses: actions/upload-artifact@v4
with:
name: triple-c-${{ needs.compute-version.outputs.version }}-macos
path: artifacts/
if-no-files-found: error
retention-days: 14
build-windows:
runs-on: windows-latest
needs: [compute-version]
defaults:
run:
shell: cmd
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Set app version
shell: powershell
run: |
$raw = "${{ needs.compute-version.outputs.version }}"
$version = $raw.Split('-')[0]
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
Write-Host "Patched version to $version"
- name: Install Rust stable
run: |
where rustup >nul 2>&1 && (
rustup update stable
rustup default stable
) || (
curl -fSL -o rustup-init.exe https://win.rustup.rs/x86_64
rustup-init.exe -y --default-toolchain stable
del rustup-init.exe
)
- name: Install Node.js
run: |
where node >nul 2>&1 && (
node --version
) || (
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi"
msiexec /i node-install.msi /quiet /norestart
del node-install.msi
)
- name: Verify tools
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
rustc --version
cargo --version
node --version
npm --version
- name: Install Tauri CLI via cargo
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo install tauri-cli --version "^2"
- name: Fix npm platform detection
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm config set os win32
npm config list
- name: Install frontend dependencies
working-directory: ./app
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
if exist node_modules rmdir /s /q node_modules
npm ci
- name: Build frontend
working-directory: ./app
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
npm run build
- name: Build Tauri app
working-directory: ./app
env:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build
- name: Collect artifacts
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
mkdir artifacts
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
dir artifacts\
- name: Upload Windows artifacts
uses: actions/upload-artifact@v4
with:
name: triple-c-${{ needs.compute-version.outputs.version }}-windows
path: artifacts/
if-no-files-found: error
retention-days: 14
+238 -54
View File
@@ -5,11 +5,13 @@ on:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
pull_request:
branches: [main]
paths:
- "app/**"
- "VERSION"
- ".gitea/workflows/build-app.yml"
workflow_dispatch:
@@ -18,10 +20,44 @@ env:
REPO: ${{ gitea.repository }}
jobs:
build-linux:
compute-version:
runs-on: ubuntu-latest
outputs:
version: ${{ steps.version.outputs.VERSION }}
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Fetch all tags
run: git fetch --tags
- name: Compute version from VERSION file and tags
id: version
run: |
MAJOR_MINOR=$(cat VERSION | tr -d '[:space:]')
echo "Major.Minor: ${MAJOR_MINOR}"
# Find the latest tag matching v{MAJOR_MINOR}.N (exclude -mac, -win suffixes)
# `|| true` so an empty grep result doesn't fail the step under pipefail.
LATEST_TAG=$(git tag -l "v${MAJOR_MINOR}.*" --sort=-v:refname | grep -E "^v${MAJOR_MINOR}\.[0-9]+$" | head -1 || true)
if [ -n "$LATEST_TAG" ]; then
echo "Latest matching tag: ${LATEST_TAG}"
PATCH=$(git rev-list --count "${LATEST_TAG}..HEAD")
else
echo "No matching tag found for v${MAJOR_MINOR}.*, using total commit count"
PATCH=$(git rev-list --count HEAD)
fi
VERSION="${MAJOR_MINOR}.${PATCH}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
build-linux:
runs-on: ubuntu-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
@@ -54,17 +90,9 @@ jobs:
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.2.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
VERSION="${{ needs.compute-version.outputs.version }}"
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
@@ -133,7 +161,7 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="v${{ steps.version.outputs.VERSION }}"
TAG="v${{ needs.compute-version.outputs.version }}"
# Create release
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
@@ -156,6 +184,7 @@ jobs:
build-macos:
runs-on: macos-latest
needs: [compute-version]
steps:
- name: Install Node.js 22
run: |
@@ -183,17 +212,9 @@ jobs:
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
COMMIT_COUNT=$(git rev-list --count HEAD)
VERSION="0.2.${COMMIT_COUNT}"
echo "VERSION=${VERSION}" >> $GITHUB_OUTPUT
echo "Computed version: ${VERSION}"
- name: Set app version
run: |
VERSION="${{ steps.version.outputs.VERSION }}"
VERSION="${{ needs.compute-version.outputs.version }}"
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/src-tauri/tauri.conf.json
sed -i '' "s/\"version\": \".*\"/\"version\": \"${VERSION}\"/" app/package.json
sed -i '' "s/^version = \".*\"/version = \"${VERSION}\"/" app/src-tauri/Cargo.toml
@@ -243,21 +264,72 @@ jobs:
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
TAG="v${{ steps.version.outputs.VERSION }}-mac"
# Create release
curl -s -X POST \
set -euo pipefail
TAG="v${{ needs.compute-version.outputs.version }}-mac"
# Idempotent get-or-create. macOS upload has historically failed
# mid-stream (curl exit 92, exit 28), leaving the release record
# with empty assets. A naive POST /releases on the next run hits
# 409 from Gitea for the duplicate tag, the JSON parse below
# then yields an empty RELEASE_ID, and pipefail aborts with an
# opaque exit 1. Look the release up by tag first; create only
# if it doesn't exist; reuse the existing id otherwise.
HTTP_CODE=$(curl -sS -o release.json -w '%{http_code}' \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
RELEASE_ID=$(cat release.json | grep -o '"id":[0-9]*' | head -1 | grep -o '[0-9]*')
"${GITEA_URL}/api/v1/repos/${REPO}/releases/tags/${TAG}")
case "${HTTP_CODE}" in
200)
echo "Release ${TAG} already exists, reusing"
;;
404)
echo "Release ${TAG} not found, creating"
curl -fsS -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"name\": \"Triple-C v${{ needs.compute-version.outputs.version }} (macOS)\", \"body\": \"Automated build from commit ${{ gitea.sha }}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases" > release.json
;;
*)
echo "Unexpected HTTP ${HTTP_CODE} from get-release-by-tag" >&2
cat release.json >&2 || true
exit 1
;;
esac
RELEASE_ID=$(grep -o '"id":[0-9]*' release.json | head -1 | grep -o '[0-9]*' || true)
if [ -z "${RELEASE_ID}" ]; then
echo "Failed to parse release id; response was:" >&2
cat release.json >&2
exit 1
fi
echo "Release ID: ${RELEASE_ID}"
# Upload each artifact
# Upload each artifact. If an asset with the same name already
# exists on the release (left over from a partial prior run),
# delete it first so the upload is replace-not-conflict.
# Network hardening: HTTP/1.1 to dodge HTTP/2 stream flakes
# the macOS runner has hit, retries with backoff for transient
# drops, and -f so HTTP errors stop being silently swallowed.
for file in artifacts/*; do
[ -f "$file" ] || continue
filename=$(basename "$file")
EXISTING_ID=$(curl -sS \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets" \
| python3 -c "import json,sys; t=sys.argv[1]; print(next((a['id'] for a in json.load(sys.stdin) if a.get('name')==t), ''))" "${filename}" || true)
if [ -n "${EXISTING_ID}" ]; then
echo "Deleting existing asset ${filename} (id ${EXISTING_ID})"
curl -fsS -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}/assets/${EXISTING_ID}"
fi
echo "Uploading ${filename}..."
curl -s -X POST \
curl -fsS --http1.1 \
--retry 5 --retry-all-errors --retry-delay 5 \
--max-time 600 \
-X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/octet-stream" \
--data-binary "@${file}" \
@@ -266,6 +338,7 @@ jobs:
build-windows:
runs-on: windows-latest
needs: [compute-version]
defaults:
run:
shell: cmd
@@ -275,23 +348,53 @@ jobs:
with:
fetch-depth: 0
- name: Compute version
id: version
run: |
for /f %%i in ('git rev-list --count HEAD') do set "COMMIT_COUNT=%%i"
set "VERSION=0.2.%COMMIT_COUNT%"
echo VERSION=%VERSION%>> %GITHUB_OUTPUT%
echo Computed version: %VERSION%
- name: Set app version
shell: powershell
run: |
$version = "${{ steps.version.outputs.VERSION }}"
$version = "${{ needs.compute-version.outputs.version }}"
(Get-Content app/src-tauri/tauri.conf.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/src-tauri/tauri.conf.json
(Get-Content app/package.json) -replace '"version": ".*?"', "`"version`": `"$version`"" | Set-Content app/package.json
(Get-Content app/src-tauri/Cargo.toml) -replace '^version = ".*?"', "version = `"$version`"" | Set-Content app/src-tauri/Cargo.toml
Write-Host "Patched version to $version"
- name: Install MSVC C++ build tools
shell: cmd
run: |
rem Tauri links with MSVC, so rustc needs link.exe and the Windows SDK.
rem This job previously assumed a hand-provisioned runner; a runner
rem without them registers fine, advertises windows-latest, accepts the
rem job, downloads the whole crate graph and only then fails at link
rem time with "linker `link.exe` not found".
rem
rem rustc finds MSVC via vswhere and the registry rather than PATH, so
rem installing is enough - no dev-shell activation needed here.
rem
rem Delayed expansion is required: %VAR% inside a parenthesised block
rem is substituted when the block is PARSED, not when it runs, so both
rem %ERRORLEVEL% and %VSEXIT% would read as their pre-block values.
setlocal enabledelayedexpansion
set "VCPATH="
set "VSWHERE=%ProgramFiles(x86)%\Microsoft Visual Studio\Installer\vswhere.exe"
if exist "%VSWHERE%" (
for /f "usebackq delims=" %%i in (`"%VSWHERE%" -latest -products * -requires Microsoft.VisualStudio.Component.VC.Tools.x86.x64 -property installationPath`) do set "VCPATH=%%i"
)
if defined VCPATH (
echo MSVC build tools already present at !VCPATH!
) else (
echo MSVC build tools not found - installing Visual Studio Build Tools
curl -fSL -o "%TEMP%\vs_BuildTools.exe" https://aka.ms/vs/17/release/vs_BuildTools.exe || exit /b 1
"%TEMP%\vs_BuildTools.exe" --quiet --wait --norestart --nocache --add Microsoft.VisualStudio.Workload.VCTools --includeRecommended
set "VSEXIT=!ERRORLEVEL!"
del "%TEMP%\vs_BuildTools.exe" 2>nul
rem 3010 means installed, reboot pending - a success for our purposes.
if not "!VSEXIT!"=="0" if not "!VSEXIT!"=="3010" (
echo Visual Studio Build Tools installer failed with exit code !VSEXIT!
exit /b 1
)
echo Visual Studio Build Tools installed
)
endlocal
- name: Install Rust stable
run: |
where rustup >nul 2>&1 && (
@@ -351,36 +454,117 @@ jobs:
TAURI_CONFIG: "{\"build\":{\"beforeBuildCommand\":\"\"}}"
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
cargo tauri build
rem Every Tauri bundler it downloads - candle.exe, light.exe and
rem makensis.exe - is 32-bit. A runner running as SYSTEM has
rem %LOCALAPPDATA% under C:\Windows\system32\config\systemprofile, and
rem WOW64 redirection sends 32-bit processes reading System32 to
rem SysWOW64, so they cannot see their own directory: candle exits
rem 0x80131700 and makensis reports "Unable to start child process,
rem error 0x2".
rem
rem The build VM carries junctions from the SysWOW64 view of
rem systemprofile\AppData\Local\tauri and systemprofile\.cache to the
rem System32 originals, which makes the redirected view resolve. A
rem runner running as a normal user needs no such patch.
cargo tauri build --bundles msi,nsis
- name: Collect artifacts
run: |
set "PATH=%USERPROFILE%\.cargo\bin;C:\Program Files\nodejs;%PATH%"
mkdir artifacts
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ 2>nul
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ 2>nul
copy app\src-tauri\target\release\bundle\msi\*.msi artifacts\ || exit /b 1
copy app\src-tauri\target\release\bundle\nsis\*.exe artifacts\ || exit /b 1
dir artifacts\
- name: Upload to Gitea release
if: gitea.event_name == 'push'
shell: powershell
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
COMMIT_SHA: ${{ gitea.sha }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
set "TAG=v${{ steps.version.outputs.VERSION }}-win"
echo Creating release %TAG%...
curl -s -X POST -H "Authorization: token %TOKEN%" -H "Content-Type: application/json" -d "{\"tag_name\": \"%TAG%\", \"name\": \"Triple-C v${{ steps.version.outputs.VERSION }} (Windows)\", \"body\": \"Automated build from commit %COMMIT_SHA%\"}" "%GITEA_URL%/api/v1/repos/%REPO%/releases" > release.json
for /f "tokens=2 delims=:," %%a in ('findstr /c:"\"id\"" release.json') do set "RELEASE_ID=%%a" & goto :found
:found
echo Release ID: %RELEASE_ID%
for %%f in (artifacts\*) do (
echo Uploading %%~nxf...
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"
)
$ErrorActionPreference = "Stop"
$tag = "v$env:VERSION-win"
$headers = @{ Authorization = "token $env:TOKEN" }
$api = "$env:GITEA_URL/api/v1/repos/$env:REPO"
# Idempotent get-or-create. The old cmd-batch version swallowed
# curl errors and parsed the release id with findstr, so a 409 on
# a pre-existing tag yielded an empty RELEASE_ID and uploads went to
# 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:
runs-on: ubuntu-latest
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
steps:
- name: Checkout
uses: actions/checkout@v4
with:
fetch-depth: 0
- name: Create version tag
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
run: |
VERSION="${{ needs.compute-version.outputs.version }}"
TAG="v${VERSION}"
echo "Creating tag ${TAG}..."
# Create annotated tag via Gitea API
curl -s -X POST \
-H "Authorization: token ${TOKEN}" \
-H "Content-Type: application/json" \
-d "{\"tag_name\": \"${TAG}\", \"target\": \"${{ gitea.sha }}\", \"message\": \"Release ${TAG}\"}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags" || echo "Tag may already exist (created by release)"
echo "Tag ${TAG} created successfully"
sync-to-github:
runs-on: ubuntu-latest
needs: [build-linux, build-macos, build-windows]
needs: [compute-version, build-linux, build-macos, build-windows]
if: gitea.event_name == 'push'
env:
GH_PAT: ${{ secrets.GH_PAT }}
@@ -389,7 +573,7 @@ jobs:
- name: Download artifacts from Gitea releases
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
VERSION: ${{ needs.build-linux.outputs.version }}
VERSION: ${{ needs.compute-version.outputs.version }}
run: |
set -e
mkdir -p artifacts
@@ -418,7 +602,7 @@ jobs:
- name: Create GitHub release and upload artifacts
env:
VERSION: ${{ needs.build-linux.outputs.version }}
VERSION: ${{ needs.compute-version.outputs.version }}
COMMIT_SHA: ${{ gitea.sha }}
run: |
set -e
+59
View File
@@ -0,0 +1,59 @@
name: Build STT Container
on:
push:
branches: [main]
paths:
- "stt-container/**"
- ".gitea/workflows/build-stt.yml"
pull_request:
branches: [main]
paths:
- "stt-container/**"
- ".gitea/workflows/build-stt.yml"
env:
REGISTRY: repo.anhonesthost.net
IMAGE_NAME: cybercovellc/triple-c/triple-c-stt
jobs:
build-stt-container:
runs-on: ubuntu-latest
steps:
- name: Checkout
uses: actions/checkout@v4
- name: Set up QEMU
uses: docker/setup-qemu-action@v3
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v3
- name: Login to Gitea Container Registry
uses: docker/login-action@v3
with:
registry: ${{ env.REGISTRY }}
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: shadowdao
password: ${{ secrets.GH_PAT }}
- name: Build and push STT container image
uses: docker/build-push-action@v5
with:
context: ./stt-container
file: ./stt-container/Dockerfile
platforms: linux/amd64,linux/arm64
push: ${{ gitea.event_name == 'push' }}
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-stt:latest
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
+9
View File
@@ -36,6 +36,13 @@ jobs:
username: ${{ gitea.actor }}
password: ${{ secrets.REGISTRY_TOKEN }}
- name: Login to GitHub Container Registry
uses: docker/login-action@v3
with:
registry: ghcr.io
username: shadowdao
password: ${{ secrets.GH_PAT }}
- name: Build and push container image
uses: docker/build-push-action@v5
with:
@@ -46,5 +53,7 @@ jobs:
tags: |
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
ghcr.io/shadowdao/triple-c-sandbox:latest
ghcr.io/shadowdao/triple-c-sandbox:${{ gitea.sha }}
cache-from: type=gha
cache-to: type=gha,mode=max
+193
View File
@@ -0,0 +1,193 @@
name: Cleanup Old Releases
on:
workflow_dispatch:
inputs:
keep_versions:
description: "Number of recent versions to keep (each version has 3 releases: Linux, macOS, Windows)"
required: true
default: "5"
dry_run:
description: "Dry run - list what would be deleted without actually deleting"
required: true
default: "true"
type: choice
options:
- "true"
- "false"
env:
GITEA_URL: ${{ gitea.server_url }}
REPO: ${{ gitea.repository }}
jobs:
cleanup:
runs-on: ubuntu-latest
steps:
- name: Cleanup old releases
env:
TOKEN: ${{ secrets.REGISTRY_TOKEN }}
GH_PAT: ${{ secrets.GH_PAT }}
GITHUB_REPO: shadowdao/triple-c
KEEP_VERSIONS: ${{ gitea.event.inputs.keep_versions }}
DRY_RUN: ${{ gitea.event.inputs.dry_run }}
run: |
set -euo pipefail
echo "==> Configuration"
echo " Keep versions: ${KEEP_VERSIONS}"
echo " Dry run: ${DRY_RUN}"
echo ""
# ── Fetch all Gitea releases (paginated) ──
ALL_RELEASES="[]"
PAGE=1
while true; do
BATCH=$(curl -sf \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases?limit=50&page=${PAGE}")
COUNT=$(echo "$BATCH" | jq 'length')
[ "$COUNT" -eq 0 ] && break
ALL_RELEASES=$(echo "$ALL_RELEASES" "$BATCH" | jq -s '.[0] + .[1]')
PAGE=$((PAGE + 1))
done
TOTAL=$(echo "$ALL_RELEASES" | jq 'length')
echo "==> Found ${TOTAL} total Gitea releases"
# ── Extract unique version numbers and sort them ──
# Tags are like: v0.2.26, v0.2.26-mac, v0.2.26-win, build-xxx
# Extract the base version (strip -mac, -win suffixes)
VERSIONS=$(echo "$ALL_RELEASES" | jq -r '.[].tag_name' \
| sed 's/-mac$//' | sed 's/-win$//' \
| grep -E '^v[0-9]+\.[0-9]+\.[0-9]+$' \
| sort -t. -k1,1V -k2,2n -k3,3n \
| uniq)
VERSION_COUNT=$(echo "$VERSIONS" | wc -l)
echo "==> Found ${VERSION_COUNT} unique versions"
echo ""
# ── Determine which versions to keep and which to delete ──
KEEP=$(echo "$VERSIONS" | tail -n "${KEEP_VERSIONS}")
DELETE=$(echo "$VERSIONS" | head -n -"${KEEP_VERSIONS}")
DELETE_COUNT=$(echo "$DELETE" | grep -c . || true)
if [ "$DELETE_COUNT" -eq 0 ]; then
echo "==> Nothing to clean up. Only ${VERSION_COUNT} versions exist, keeping ${KEEP_VERSIONS}."
exit 0
fi
echo "==> Keeping ${KEEP_VERSIONS} most recent versions:"
echo "$KEEP" | sed 's/^/ /'
echo ""
echo "==> Will delete ${DELETE_COUNT} older versions ($(echo "$DELETE" | head -1) through $(echo "$DELETE" | tail -1)):"
echo "$DELETE" | sed 's/^/ /'
echo ""
# ── Delete releases ──
DELETED_GITEA=0
DELETED_GITHUB=0
DELETED_TAGS=0
for VERSION in $DELETE; do
# Each version can have up to 3 releases: base, -mac, -win
for SUFFIX in "" "-mac" "-win"; do
TAG="${VERSION}${SUFFIX}"
# Find the Gitea release ID for this tag
RELEASE_ID=$(echo "$ALL_RELEASES" | jq -r --arg tag "$TAG" '.[] | select(.tag_name == $tag) | .id // empty')
if [ -n "$RELEASE_ID" ]; then
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would delete Gitea release: ${TAG} (id: ${RELEASE_ID})"
else
echo " Deleting Gitea release: ${TAG} (id: ${RELEASE_ID})..."
curl -sf -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${RELEASE_ID}" || echo " Warning: failed to delete Gitea release ${TAG}"
DELETED_GITEA=$((DELETED_GITEA + 1))
fi
fi
# Delete the Gitea tag
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would delete Gitea tag: ${TAG}"
else
curl -sf -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" 2>/dev/null && DELETED_TAGS=$((DELETED_TAGS + 1)) || true
fi
done
# Delete the unified GitHub release (single tag per version, no suffix)
if [ -n "$GH_PAT" ]; then
GH_RELEASE=$(curl -sf \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/tags/${VERSION}" 2>/dev/null || echo "{}")
GH_RELEASE_ID=$(echo "$GH_RELEASE" | jq -r '.id // empty')
if [ -n "$GH_RELEASE_ID" ]; then
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would delete GitHub release: ${VERSION} (id: ${GH_RELEASE_ID})"
else
echo " Deleting GitHub release: ${VERSION} (id: ${GH_RELEASE_ID})..."
curl -sf -X DELETE \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/releases/${GH_RELEASE_ID}" || echo " Warning: failed to delete GitHub release ${VERSION}"
DELETED_GITHUB=$((DELETED_GITHUB + 1))
fi
fi
# Delete the GitHub tag
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would delete GitHub tag: ${VERSION}"
else
curl -sf -X DELETE \
-H "Authorization: Bearer ${GH_PAT}" \
-H "Accept: application/vnd.github+json" \
"https://api.github.com/repos/${GITHUB_REPO}/git/refs/tags/${VERSION}" 2>/dev/null || true
fi
fi
echo ""
done
# ── Also clean up any legacy non-semver releases (e.g., build-xxx) ──
LEGACY_RELEASES=$(echo "$ALL_RELEASES" | jq -r '.[] | select(.tag_name | test("^v[0-9]") | not) | "\(.id) \(.tag_name)"')
LEGACY_COUNT=$(echo "$LEGACY_RELEASES" | grep -c . || true)
if [ "$LEGACY_COUNT" -gt 0 ]; then
echo "==> Found ${LEGACY_COUNT} legacy (non-versioned) releases to clean up:"
echo "$LEGACY_RELEASES" | while read -r ID TAG; do
[ -z "$ID" ] && continue
if [ "$DRY_RUN" = "true" ]; then
echo " [DRY RUN] Would delete legacy release: ${TAG} (id: ${ID})"
else
echo " Deleting legacy release: ${TAG} (id: ${ID})..."
curl -sf -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/releases/${ID}" || echo " Warning: failed to delete ${TAG}"
# Delete the tag too
curl -sf -X DELETE \
-H "Authorization: token ${TOKEN}" \
"${GITEA_URL}/api/v1/repos/${REPO}/tags/${TAG}" 2>/dev/null || true
DELETED_GITEA=$((DELETED_GITEA + 1))
fi
done
echo ""
fi
# ── Summary ──
echo "==> Cleanup complete"
if [ "$DRY_RUN" = "true" ]; then
echo " Mode: DRY RUN (no changes made)"
echo " Would delete: ${DELETE_COUNT} versions (up to $((DELETE_COUNT * 3)) Gitea releases + GitHub releases)"
[ "$LEGACY_COUNT" -gt 0 ] && echo " Would also delete: ${LEGACY_COUNT} legacy releases"
else
echo " Gitea releases deleted: ${DELETED_GITEA}"
echo " GitHub releases deleted: ${DELETED_GITHUB}"
echo " Tags deleted: ${DELETED_TAGS}"
fi
+65 -11
View File
@@ -56,34 +56,78 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
### Frontend Structure (`app/src/`)
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI)
- **`store/appState.ts`** — Single Zustand store for all app state (projects, sessions, UI). The
main area is a single ordered tab strip holding two tab kinds, keyed `term:<id>` and
`home:<id>`; `activeSessionId` is *derived* from `activeTabKey` so exactly one thing is current.
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **`components/layout/`** — TopBar (tabs + status), Sidebar (project list), StatusBar
- **`components/projects/`** — ProjectCard, ProjectList, AddProjectDialog
- **`components/settings/`** — Settings panels for API keys, Docker, AWS
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
and the editors reused by Project Home
- **`components/projects/home/`** — **Project Home**, the main-area view for a project:
Overview / Sessions / Automation / Config / Files. Per-project configuration lives here, not in
modals — see "UI conventions" below.
- **`components/settings/`** — Host-level settings: Docker, AWS, Web Terminal, STT, shared auth
- **`components/ui/`** — Shared primitives. **Use these; do not hand-roll replacements.**
`Modal` (the only correct way to build a dialog — it supplies `role="dialog"`, `aria-modal`,
focus trap and restore), `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`,
`SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip`
### UI conventions
- **Project config belongs in Project Home's Config tab, not a modal.** Modals are reserved for
short, genuinely modal tasks (add project, confirm removal, token acquisition). The app
previously had ~12 hand-rolled modals; they were consolidated deliberately.
- **Never bypass the design tokens.** All colour comes from CSS custom properties in `index.css`.
Filled buttons use `--accent-emphasis` (not `--accent`, which fails WCAG AA against white).
Use `--text-disabled` rather than `disabled:opacity-50`.
- **Never write `focus:outline-none`.** A global `:focus-visible` ring is defined in `index.css`.
- **Status must not be encoded in colour alone** — `StatusIndicator` pairs a glyph with a word.
- Keyboard: `Ctrl+T` new terminal, `Ctrl+Shift+W` close tab, `Ctrl+Tab` cycle, `Ctrl+1..9` jump.
`Ctrl+W` is intentionally left alone — it is readline's `kill-word` inside the terminal.
### Backend Structure (`app/src-tauri/src/`)
- **`commands/`** — Tauri command handlers (docker, project, settings, terminal). These are the IPC entry points called by `invoke()`.
- **`commands/`** — Tauri command handlers. These are the IPC entry points called by `invoke()`.
Beyond docker/project/settings/terminal: `inspect_commands.rs` (read-only views into a
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
`auth_token_commands.rs`.
- **`auth_bridge/`** — Host-side loopback bridge so browser logins run *inside* a container can
complete against the host browser. Discovers listeners by parsing `/proc/net/tcp{,6}` (the image
has no `ss`/`netstat`/`lsof`), binds host `127.0.0.1` **only**, and tunnels in over the Docker
API via `socat`. Opt-in per project.
- **`docker/`** — Docker API layer using bollard:
- `client.rs` — Singleton Docker connection via `OnceLock`
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
- `exec.rs`PTY exec sessions with bidirectional stdin/stdout streaming
- `exec.rs`Attached exec streaming. `create_attached_exec()` is the **single** place an
attached exec is opened; terminal sessions and the auth bridge both go through it.
- `image.rs` — Image build/pull with progress streaming
- **`models/`** — Serde structs (`Project`, `AuthMode`, `BedrockConfig`, `OllamaConfig`, `LiteLlmConfig`, `ContainerInfo`, `AppSettings`). These define the IPC contract with the frontend.
- `legacy_cleanup.rs` — One-release migration shim removing leftovers from the deleted MCP
feature (containers labelled `triple-c.mcp-server`, `triple-c-net-*` networks). Deletable once
users have migrated.
- **`web_terminal/`** — Remote terminal access via axum HTTP+WebSocket server:
- `server.rs` — Axum server lifecycle (start/stop), serves embedded HTML and handles WS upgrades
- `ws_handler.rs` — Per-connection WebSocket handler with JSON protocol, session management, cleanup on disconnect
- `terminal.html` — Self-contained xterm.js web UI embedded via `include_str!()`
- **`models/`** — Serde structs (`Project`, `Backend`, `BedrockConfig`, `OllamaConfig`, `OpenAiCompatibleConfig`, `ClaudeCodeSettings`, `ContainerInfo`, `AppSettings`, `WebTerminalSettings`). These define the IPC contract with the frontend.
- **`storage/`** — Persistence: `projects_store.rs` (JSON file with atomic writes), `secure.rs` (OS keychain via `keyring` crate), `settings_store.rs`
### Container (`container/`)
- **`Dockerfile`** — Ubuntu 24.04 base with Claude Code, Node.js 22, Python 3.12, Rust, Docker CLI, git, gh, AWS CLI v2, ripgrep, pnpm, uv, ruff pre-installed
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, then `sleep infinity`
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup, git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
### Container Lifecycle
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`) so OAuth tokens survive even container resets.
Containers use a **stop/start** model (not create/destroy). Installed packages persist across stops. The `.claude` config dir uses a named Docker volume (`triple-c-claude-config-{projectId}`), nested inside the home volume (`triple-c-home-{projectId}`), so OAuth tokens and Claude Code config survive container stop/start *and* container recreation.
**Reset is the exception and it is destructive.** `rebuild_project_container` calls
`remove_project_volumes`, which deletes *both* volumes — so a Reset wipes `~/.claude`,
`~/.claude.json`, the OAuth credential, installed skills, and session transcripts. That is
intentional (Reset exists to get back to a clean base image), but do not describe Reset as
preserving credentials.
### Authentication
@@ -91,7 +135,7 @@ Per-project, independently configured:
- **Anthropic (OAuth)** — `claude login` in terminal, token persists in config volume
- **AWS Bedrock** — Static keys, profile, or bearer token injected as env vars
- **Ollama** — Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`)
- **LiteLLM** — Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers
- **OpenAI Compatible** — Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`
## Styling
@@ -104,8 +148,18 @@ Per-project, independently configured:
- Frontend types in `lib/types.ts` must stay in sync with Rust structs in `models/`
- Tauri commands are registered in `lib.rs` via `.invoke_handler(tauri::generate_handler![...])`
- Tauri v2 permissions are declared in `capabilities/default.json` — new IPC commands need permission grants there
- `capabilities/default.json` grants permissions for **plugin** commands only (`core:`, `dialog:`,
`store:`, `opener:`). Application commands registered through `generate_handler!` do **not**
need an entry there — adding one is not required and none exists for any app command.
- The `projects.json` file uses atomic writes (write to `.tmp`, then `rename()`). Corrupted files are backed up to `.bak`.
- **Adding project state that changes the container?** `container_needs_recreation()` is entirely
**label-based** — it does not diff the container's env. If a new setting affects the container's
environment or configuration, you must also write a corresponding `triple-c.*` label at creation
and compare it there, or the change will silently not take effect until some unrelated setting
forces a rebuild. Never put a secret in a label; labels are readable via `docker inspect`.
- **New model fields need an explicit serde default when the correct default isn't the zero value.**
`#[serde(default)]` on a `bool` yields `false`; follow the `default_full_permissions` pattern in
`models/project.rs` for anything that should default to true.
- Cross-platform paths: Docker socket is `/var/run/docker.sock` on Linux/macOS, `//./pipe/docker_engine` on Windows
## Testing
+337
View File
@@ -0,0 +1,337 @@
# Triple-C Design & Product Review
**Date:** 2026-08-09 · **Version reviewed:** 0.3.0 · **Reviewer:** Fable 5
Scope: `app/src/` (App, layout, projects, settings, terminal, ui, store, index.css),
README/CLAUDE.md/TODO.md, the four repo screenshots, and `triple-c-app-logov2.png`.
---
## Summary verdict
The bones are good. The floating-panel layout reads clean, the GitHub-dark palette is
inoffensive, and terminal-as-centerpiece is correct for this product.
The two real problems are structural, and they are the same problem seen from two sides:
**the project — the app's actual unit of work — has no room to live.** Everything about a
project (backend auth, mounts, git identity, env vars, ports, Claude settings, file
manager) is stuffed into a ~280px sidebar card (`ProjectCard.tsx`, 1,257 lines) that
sprays out seven modals to compensate.
`screenshot_for_fix/project_config_run_off.png` is not a bug to patch. It is the
architecture reporting that the config does not fit where it lives. Fixing that one thing
also solves the modal pile, the density problems, *and* creates the surface where newer
Claude Code concepts belong.
---
## Part A — Visual & interaction design
### A1. Tokens: coherent but thin, with one real contrast failure
`index.css` is GitHub Primer dark, verbatim (`#0d1117 / #161b22 / #21262d / #30363d /
#8b949e / #58a6ff`). Defensible — familiar, calm, terminal-adjacent — but the token layer
stops at 11 variables. Roles the code is already faking ad hoc:
- **No elevation/overlay token.** Modals reuse `--bg-secondary`, so a modal over the
sidebar is the same color as the sidebar. Add `--bg-overlay: #1c2128` and
`--shadow-overlay`.
- **No muted-accent tokens.** The code hand-rolls `bg-yellow-500/20 text-yellow-400`,
`bg-blue-500/20 text-blue-400`, `--warning/15`, `--error/10`. Add `--accent-muted`,
`--warning-muted`, `--error-muted`, `--success-muted`. Those raw Tailwind palette colors
are the only two places the token system leaks.
- **Radius drift:** `rounded` (4px), `rounded-lg` (8px), plus hardcoded 3px/6px in help
styles. Pick two: 6px controls, 8px panels.
**Contrast bug (concrete):** white text on `--accent #58a6ff` is ~**2.5:1** — fails WCAG
AA. That is the primary button ("Add Project"), the "Update" pill, and more. Primer solves
this with two accents: keep `#58a6ff` as the *foreground/link* accent and add
`--accent-emphasis: #1f6feb` for filled buttons (white on `#1f6feb` ≈ 4.7:1).
Same story for `bg-[var(--success)] text-white` ON toggles — `#3fb950` + white ≈ **2.1:1**,
the worst offender in the app.
What passes: `--text-secondary #8b949e` on `#161b22` ≈ 5.8:1, fine even at 12px.
`--warning #d29922` ≈ 7:1. But `disabled:opacity-50` on secondary text drops to ~2.4:1 —
and since the entire config form is disabled while the container runs, **the most common
state of the form is illegible.** Use a dedicated `--text-disabled: #6e7681` instead of
opacity.
### A2. Type and density: everything is 12px
Roughly 90% of the UI is `text-xs`. Hierarchy is carried almost entirely by weight plus a
single `text-lg` modal title. Forms feel cramped rather than dense — density is
information per pixel, not small type.
Proposed scale with roles: **11px** uppercase section labels (already used, keep) ·
**12px** secondary/meta · **13px** default UI/body/form values · **14px** panel headers ·
**16px** view titles.
Path strings in mono are a nice identity touch — extend mono to all machine values (model
IDs, ports, digests), which the Bedrock/Ollama forms currently render in the UI face.
The outer chrome spends generously while content starves: `App.tsx` wraps everything in
`p-6 gap-4`, then the config form gets ~180px-wide inputs for AWS secret keys. Keep the
floating-island look; `p-3 gap-3` buys content ~24px horizontally and the terminal two
more rows.
### A3. The project card is three components wearing one div
`ProjectCard` is simultaneously a list row, a command strip, and the entire settings form.
- **Selection and disclosure are conflated.** Clicking a row both selects it and expands an
accordion in place, shoving the other projects down. The 06-28 screenshot shows 18
projects — this jank is daily.
- **Actions are unstyled text links.** `ActionButton` renders `text-xs px-2 py-0.5` colored
text with no border or background, so Start/Stop/Terminal/Shell/Files/Backup/Config/Remove
read as a wrapping line of links. Worse, **Remove (destructive, red) wraps directly next
to Config** with a ~20px hit target.
- **Double-click-to-rename** is undiscoverable and keyboard/touch-inaccessible.
- **27 hover-only `<Tooltip>` markers in ProjectCard alone.** When a form needs 27 tooltips,
the form is the problem.
### A4. Modals: eight is a pattern smell, and none are real dialogs
Hanging off ProjectCard: EnvVars, PortMappings, ClaudeInstructions, ClaudeCodeSettings,
ContainerProgress, FileManager, ConfirmRemove — plus AddProject, three reused from
SettingsPanel, and Update/ImageUpdate/Help from TopBar.
Each reimplements the overlay div, Escape handler, and click-outside logic by hand. **None
has `role="dialog"`, `aria-modal`, a focus trap, or focus restore** — zero hits for
`role=`, `aria-modal`, or `tabIndex` across `components/`.
The pattern is wrong not because modals are bad, but because these are not modal *tasks*.
Env vars, ports, instructions, and Claude settings are all "edit part of the project
config" — a detail view's job.
- Legitimately modal: **ConfirmRemove**, **AddProject**.
- **FileManager** wants to be a main-area tab, not a 42rem popup.
- **ContainerProgressModal actively hurts:** starting a container blocks the entire app
behind an overlay for an operation designed to be routine. Replace with inline row state
plus an error toast.
- Whatever survives should be one shared `<Modal>` primitive with focus trap + ARIA.
### A5. Keyboard and focus: currently unsupported
For a tool whose centerpiece is a keyboard-driven terminal, the chrome is mouse-only.
- Inputs use `focus:outline-none` with only a low-contrast border swap; **buttons have no
focus style at all** — tabbing through the sidebar is invisible.
- One-line fix: add `--focus-ring: #58a6ff` and
`:focus-visible { outline: 2px solid var(--focus-ring); outline-offset: 1px; }`
- No shortcuts for constant actions: `Ctrl+T` new terminal, `Ctrl+Tab`/`Ctrl+1..9` switch,
`Ctrl+W` close, `Ctrl+P` project switcher. The only shortcut in the app is the STT mic.
- Hit targets below 24px: tab close "×" (~14px), Tooltip "?" (14px), Browse "...". The
status bar is `h-6` yet hosts two interactive controls.
### A6. Status communication
Three disconnected dot systems (TopBar Docker/Image, per-project status, StatusBar counts),
all 8px and color-only.
- **Stopped (gray) and error (red) differ only by hue**, and Docker-unavailable renders the
same gray as Docker-still-being-checked (`dockerAvailable === null` and `false` both fall
through). An outage should be loud; unknown should pulse.
- Color-only encoding fails colorblind users. Add shape or text — `● Running`, `○ Stopped`,
`⚠ Error`. The words are already in the model.
- Raw `String(e)` errors dumped into a 12px card line; bollard errors are long. Errors need
a home: toast plus expandable detail.
- The TopBar tab strip is visually disconnected from the terminal it controls. Move tabs
onto the terminal panel's top edge so the active tab connects to its content.
### A7. Empty and first-run states
`WelcomeScreen` is three lines of gray text with no affordance — "Add a project from the
sidebar" *describes* a button instead of *being* one. This is also where brand could exist:
the orange sun-gear logo appears nowhere in the UI and shares no DNA with the blue-on-
graphite chrome.
Make it an onboarding checklist reusing state already tracked:
✓ Docker detected → ✓ Image pulled → **[ Add your first project ]** → open terminal.
The same pattern fixes the "image missing" case, today just a gray dot in the corner.
### A8. Dark-only: keep it
Right call. Terminal-first developer tool, xterm content is dark, audience expects it. The
tokens make a light theme cheap later. Don't spend on it now — but keep discipline that no
color bypasses the token layer.
### A9. Iconography
Mixed: hand-inlined Feather-style SVGs in the sidebar rail, text glyphs elsewhere ("×",
"?", "...", "+", "✓", "✕"). Adopt `lucide-react` — same stroke style already being
imitated, tree-shakeable — and replace the text glyphs. It also supplies the per-concept
icons Part B needs.
---
## Part B — Information architecture & product concepts
### B1. The diagnosis
Current IA: `Projects | MCP | Settings` in a sidebar, terminal in main, project detail
crammed into the list.
Deleting the MCP tab was correct — but **the lesson matters more than the freed slot.
MCP died as a Triple-C feature because Claude Code absorbed it.** Hooks, skills, agents,
plugins, output styles, and statusline are all the same species: files under `.claude/`
that Claude Code manages natively with its own TUIs (`/agents`, `/hooks`, `/plugins`). If
Triple-C builds form editors for them, it loses the same race again and becomes exactly
what it should fear — a settings-file editor with a GUI skin.
What Claude Code *cannot* do is what Triple-C uniquely owns: **the container boundary and
what persists behind it.** The config volume, the workspace mounts, the lifecycle, the
scheduler already shipping in every image, and the fleet view across many projects.
> **Principle: Triple-C shows state and launches things. Claude Code edits its own config.**
Sessions, checkpoints, background tasks, scheduled tasks, capability inventory → surface
them, read from the volume, launch into the terminal. Hook/skill/agent *editing*
deep-link into the terminal, don't rebuild.
### B2. Proposed IA: three nouns
**Project** (a sandboxed workspace) · **Session** (a resumable conversation) ·
**Library** (reusable capabilities pushed into projects). Everything is one of these, or
Settings.
```
┌────────────────────────────────────────────────────────────────────┐
│ TopBar: ⌂ api-server │ ▣ api-server ✕ │ ▣ api (bash) ✕ │ ● ● ? │
├─────────────┬──────────────────────────────────────────────────────┤
│ ◤ Projects │ MAIN AREA — a tab strip of two tab kinds: │
│ ● api-serv │ ⌂ project-home tabs ▣ terminal tabs │
│ ○ blog │ │
│ ● data-pipe│ ⌂ api-server ● Running · 2h 14m │
│ … │ ┌─────────┬──────────┬────────────┬────────┐ │
│ ◧ Library │ │Overview │ Sessions │ Automation │ Config │ │
│ ⚙ Settings │ └─────────┴──────────┴────────────┴────────┘ │
├─────────────┴──────────────────────────────────────────────────────┤
│ StatusBar: 18 projects · 8 running · 4 terminals 🎤 ↓Jump │
└────────────────────────────────────────────────────────────────────┘
```
- **Sidebar** becomes a pure list plus nav rail. Rows carry name, path, status dot, and on
hover a play/stop and terminal button. Clicking opens (or focuses) that project's
**Project Home** tab. The freed MCP slot becomes **Library**.
- **Main area** hosts two tab kinds: terminals (as today) and project-home tabs, like VS
Code's Settings tab. The terminal stays the centerpiece; Project Home is one keystroke
away rather than a layer on top.
- **All seven config modals dissolve** into the Config tab, full-width, grouped:
*Workspace* (folders/mounts), *Model* (backend + auth), *Access* (git/SSH/env/ports),
*Runtime* (docker access, sandbox, permission mode, Mission Control). Room for visible
helper text kills most of the 27 tooltips. Save-on-blur stays but gains a visible
"Saved ✓ / Failed" indicator — today failures go only to `console.error`, which is
silent data loss.
#### Project Home — Overview tab
```
api-server ● Running · started 2h ago
[ Stop ] [ Open Claude Terminal ] [ Shell ] [ Files ] [⋯ menu]
Permission mode ( Plan ) ( Default ) ( Accept Edits ) (▮ Bypass ▮)
Sandbox ON — bubblewrap isolation Backend Anthropic
CAPABILITIES (read from container volume)
◆ Skills 7 ◆ Agents 3 ◆ Hooks 2 ◆ Plugins 1 ◆ Commands 5
└ click any → drawer listing names/descriptions,
[Manage in terminal] → opens claude with /agents etc.
RECENT SESSIONS SCHEDULED TASKS
"Refactor OAuth flow" 2h ago [Resume] nightly-review 0 3 * * *
"Fix flaky CI test" 1d ago [Resume] [2 notifications]
```
### B3. The four concepts worth building
**1. Sessions & Resume — the flagship.** The stop/start container model creates a problem
plain Claude Code doesn't have: stop a container, come back Tuesday, and "which
conversation was I in?" is buried in the volume. Read session metadata via `docker exec`
(the exec and tar plumbing already exists), list sessions with summary and age, and make
**[Resume]** open a terminal running `claude --resume <id>`. Closing a terminal tab today
silently abandons a session; it should say "Session saved — resume from Project Home."
This turns the biggest architectural quirk into the best feature.
Do **not** build a checkpoint browser. Mention rewind (`Esc Esc`) in Help and stop there.
**2. Library — the MCP tab's successor.** The pattern was already invented three times:
global MCP servers with per-project checkboxes, global Claude instructions, and Mission
Control's bundled skill install. Generalize it once: a Library of **skills, agents, and
slash commands** defined globally with per-project enable, synced into the container's
`.claude` volume by the entrypoint. Across many projects, "write a skill once, enable it in
twelve sandboxes" is genuinely differentiated. Keep the editor minimal — name plus markdown
textarea, or "import from folder." Not a structured form per frontmatter field.
**3. Permission mode as the hero control.** The whole pitch is "sandbox so you can safely
go fast," yet that pitch is expressed as a scary boolean buried in a config accordion.
Replace it with Claude Code's real vocabulary — a segmented control (**Plan / Default /
Accept Edits / Bypass**) on Overview, echoed as a badge on terminal tabs, with sandbox
state beside it. When sandbox is ON, Bypass loses its red paint ("contained by sandbox");
when sandbox is OFF *and* Bypass is on, that is when caution color earns its place. This
reframes the product's core value in the product's own UI.
**4. Automation tab.** `triple-c-scheduler` ships in every container with
add/list/logs/notifications — and its only UI is a CLAUDE.md paragraph telling Claude to
run it. Wrap it: task list (name, cron, last run, enabled), toggle/run-now/view-log, and a
notification badge on the project row. "Your nightly agent left you a note" is a reason to
open the app in the morning. Fleet-of-scheduled-agents management across projects is
something the Claude Code TUI does not offer.
**Explicitly skip:** status line builder, output-styles editor, hook *editors* (surface the
count, deep-link to the terminal), checkpoint browser, marketplace browser. Each is niche,
natively handled, or a settings-editor trap.
### B4. Coherence test
Every screen answers exactly one question:
| Screen | Question |
|---|---|
| Sidebar | What projects exist and are they up? |
| Project Home | What can this sandbox do, and where did I leave off? |
| Terminal | Do the work. |
| Library | What capabilities do I reuse? |
| Settings | How does the host behave? |
Anything that doesn't answer one of those doesn't get a nav slot.
---
## Priorities
### Tier 1 — high impact, cheap
1. `:focus-visible` ring and stop stripping outlines (one CSS rule + token). Add
`Ctrl+T` / `Ctrl+W` / `Ctrl+1..9` / `Ctrl+Tab`.
2. Contrast: `--accent-emphasis: #1f6feb` for filled buttons; kill white-on-`#3fb950`;
`--text-disabled` instead of `opacity-50`.
3. Real buttons for project actions; Remove into an overflow menu; primary action filled.
4. Inline start/stop progress and an error toast; delete `ContainerProgressModal`.
5. Status dots get labels or shapes; Docker-down turns red; null state pulses.
6. Welcome screen becomes an onboarding checklist with a real button, plus the logo.
7. One shared `<Modal>` with focus trap and ARIA for the modals that remain.
8. Permission-mode segmented control replacing the boolean.
9. `lucide-react` icons; move the tab strip onto the terminal panel.
### Tier 2 — high impact, expensive
1. **Project Home tabbed view** — the structural fix that dissolves the modal pile and the
1,257-line ProjectCard. The forms already exist; this is mostly moving and splitting.
2. **Sessions tab** with `claude --resume`.
3. **Library** — generalize global→per-project sync to skills/agents/commands.
4. **Automation tab** wrapping `triple-c-scheduler`, with notification badges.
### Tier 3 — skip
- Light theme (dark-only is right; tokens keep the door open).
- Editors for hooks, statusline, output styles; checkpoint browser; marketplace browser.
- Any new global sidebar tab beyond Library.
- Rebuilding MCP management in any form. Let the deletion be a lesson, not a vacancy.
---
**One sentence:** promote the project from a sidebar card to a first-class workspace view,
use the volume you already own to surface sessions/capabilities/automation instead of
building config editors, and spend a focused week on focus rings, contrast, and button
affordances — the visual layer needs sanding, not redesign.
+604 -194
View File
File diff suppressed because it is too large Load Diff
+106
View File
@@ -0,0 +1,106 @@
# Mission Control Setup Instructions
Reference document for adding Flight Control methodology to any project.
## How Triple-C Installs Mission Control
When Mission Control is enabled for a project in Triple-C:
1. **Bundled files install**: The Mission Control files bundled with Triple-C are copied to `/home/claude/mission-control/` (persisted in the config volume)
2. **Skills install**: All skills from the bundled `.claude/skills/` are copied to `~/.claude/skills/` so Claude Code discovers them automatically as `/slash-commands`
3. **Workspace symlink**: `/workspace/mission-control/` symlinks to the installed copy for methodology doc access
4. **Global instructions**: Mission Control usage instructions are injected into `~/.claude/CLAUDE.md`
This happens automatically on every container start, so skill updates from new Triple-C releases are picked up on restart.
## Two pieces are needed per project:
1. **Global CLAUDE.md** — Handled automatically by Triple-C when Mission Control is enabled
2. **Project CLAUDE.md** — Add the Flight Operations section to each project's `CLAUDE.md`
Then run `/init-project` to create the `.flightops/` directory.
---
## 1. Global CLAUDE.md Instructions
These are **automatically injected by Triple-C** when Mission Control is enabled. For reference, the injected content is:
```markdown
## Mission Control
The `/workspace/mission-control/` directory contains **Flight Control** — an AI-first development methodology for structured project management. Use it for all project work.
### How It Works
- **Mission Control is a tool, not a project.** It provides skills and methodology for managing other projects.
- All Flight Control skills are installed as personal skills in `~/.claude/skills/` and are automatically available as `/slash-commands`
- The methodology docs and project registry live in `/workspace/mission-control/`
### When to Use
When working on any project that has a `.flightops/` directory, follow the Flight Control methodology:
1. Read the project's `.flightops/ARTIFACTS.md` to understand artifact storage
2. Read `.flightops/FLIGHT_OPERATIONS.md` for the implementation workflow
3. Use Mission Control skills for planning and execution
### Available Skills
| Skill | When to Use |
|-------|-------------|
| `/init-project` | Setting up a new project for Flight Control |
| `/mission` | Defining new work outcomes (days-to-weeks scope) |
| `/flight` | Creating technical specs from missions (hours-to-days scope) |
| `/leg` | Generating implementation steps from flights (minutes-to-hours scope) |
| `/agentic-workflow` | Executing legs with multi-agent workflow (implement, review, commit) |
| `/flight-debrief` | Post-flight analysis after a flight lands |
| `/mission-debrief` | Post-mission retrospective after completion |
| `/daily-briefing` | Cross-project status report |
### Key Rules
- **Planning skills produce artifacts only** — never modify source code directly
- **Phase gates require human confirmation** — missions before flights, flights before legs
- **Legs are immutable once in-flight** — create new ones instead of modifying
- **`/agentic-workflow` orchestrates implementation** — it spawns separate Developer and Reviewer agents
- **Artifacts live in the target project** — not in mission-control
```
---
## 2. Project CLAUDE.md Instructions
Add this section to each project's `CLAUDE.md`:
```markdown
## Flight Operations
This project uses Flight Control (bundled with Triple-C) for structured development.
**Before any mission/flight/leg work, read these files in order:**
1. `.flightops/README.md` — What the flightops directory contains
2. `.flightops/FLIGHT_OPERATIONS.md`**The workflow you MUST follow**
3. `.flightops/ARTIFACTS.md` — Where all artifacts are stored
4. `.flightops/agent-crews/` — Project crew definitions for each phase (read the relevant crew file)
```
---
## 3. Initialize the Project
After adding the CLAUDE.md sections, run `/init-project` from mission-control to:
1. Create the `.flightops/` directory with methodology references
2. Configure the artifact system (files or Jira)
3. Set up agent crew definitions
4. Register the project in `/workspace/mission-control/projects.md`
---
## Quick Checklist for New Projects
- [ ] Enable Mission Control for the project in Triple-C (auto-installs skills to `~/.claude/skills/`)
- [ ] Add Flight Operations section to the project's `CLAUDE.md`
- [ ] Run `/init-project` from mission-control
- [ ] Add the project to `/workspace/mission-control/projects.md`
- [ ] Add `.flightops/` to the project's `.gitignore` (if artifacts should not be committed) or commit it (if they should)
+215 -64
View File
@@ -1,6 +1,6 @@
# Triple-C (Claude-Code-Container)
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. When running with `--dangerously-skip-permissions`, Claude only has access to the files and projects you explicitly provide to it.
Triple-C is a cross-platform desktop application that sandboxes Claude Code inside Docker containers. Each project chooses its own **permission mode** — from Plan (read-only) through to Bypass (`--dangerously-skip-permissions`), which gives Claude unrestricted access within the sandbox.
## Architecture
@@ -13,46 +13,165 @@ Triple-C is a cross-platform desktop application that sandboxes Claude Code insi
```
┌─────────────────────────────────────────────────────┐
│ TopBar (terminal tabs + Docker/Image status)
│ TopBar (MainTabs strip + Docker/Image status + ?)
├────────────┬────────────────────────────────────────┤
│ Sidebar │ Main Content (terminal views)
│ (25% w, │
│ responsive│
│ Sidebar │ Main Content
│ (25% w, │ · Project Home views, or
│ responsive│ · terminal views (xterm.js)
│ min/max) │ │
├────────────┴────────────────────────────────────────┤
│ StatusBar (project/terminal counts)
│ StatusBar (project/terminal counts, STT, scroll)
└─────────────────────────────────────────────────────┘
```
The main area is driven by **one ordered tab strip** (`components/layout/MainTabs.tsx`) holding
two tab kinds: `home:<projectId>` (Project Home) and `term:<sessionId>` (a terminal). There is no
separate terminal tab bar. `activeSessionId` is derived from the active tab key, so exactly one
thing is current at a time.
### Keyboard Shortcuts
Implemented in `hooks/useKeyboardShortcuts.ts` (document-level, capture phase):
| Shortcut | Action |
|---|---|
| `Ctrl+T` | New Claude terminal for the current project (no-op unless it is running) |
| `Ctrl+Shift+W` | Close the active tab |
| `Ctrl+Tab` / `Ctrl+Shift+Tab` | Cycle tabs forward / backward |
| `Ctrl+1``Ctrl+9` | Jump to the nth tab |
`Ctrl+W` is deliberately **not** bound: it is readline's `kill-word`, used constantly in the
terminal this app is built around. Terminal-scoped keys (`Ctrl+Shift+C`, `Ctrl+Shift+Alt+C`,
`Ctrl+Shift+M`) are handled in `TerminalView.tsx`.
### Project Home
Clicking a project row in the sidebar opens **Project Home** in the main area — the per-project
view, with tabs **Overview · Sessions · Automation · Config · Files**. The sidebar row itself is
select-only (plus hover controls for start/stop and opening a terminal); it holds no configuration.
Per-project configuration lives in the Config tab rather than in modals.
| Tab | Contents |
|---|---|
| **Overview** | Permission mode control, sandbox/backend/Docker-access summary, capability tiles, recent sessions, scheduled tasks |
| **Sessions** | Past Claude Code conversations read from the config volume, with **Resume** |
| **Automation** | The container's `triple-c-scheduler` tasks — enable/disable, run now, read logs, remove, and completion notifications |
| **Config** | Workspace (name, folders), Model (backend), Access (SSH, git, env vars, port mappings), Runtime (permission mode, sandbox, Docker access, Mission Control, instructions, Claude Code settings) |
| **Files** | Browse, download and upload files inside the container |
Container start/stop progress is reported inline (on the sidebar row and in the Project Home
header) via the `container-progress` event, and failures surface as toasts. There is no blocking
progress modal.
### Permission Modes
`PermissionMode` in `models/project.rs` replaces the old `full_permissions` boolean. Four states,
mapped to CLI flags by `PermissionMode::cli_args()`:
| Mode | Serialized | CLI args passed to `claude` |
|---|---|---|
| **Plan** | `plan` | `--permission-mode plan` |
| **Default** | `default` | *(none)* |
| **Accept Edits** | `acceptEdits` | `--permission-mode acceptEdits` |
| **Bypass** | `bypass` | `--dangerously-skip-permissions` |
`Project.permission_mode` is `Option<PermissionMode>`; `effective_permission_mode()` falls back to
the legacy `full_permissions` flag (`true` → Bypass) for records written before the change. Changing
the mode affects terminals opened **from then on** — a running `claude` process keeps the argv it
was launched with.
Scheduled tasks honour it too. The mode is injected as `TRIPLE_C_PERMISSION_MODE` (via
`as_env_value()`) and written as the `triple-c.permission-mode` container label; the entrypoint
snapshots it into `~/.claude/scheduler/.env`, and `container/triple-c-task-runner` translates it
back into flags for its headless `claude -p` run. Because it travels as container env, a mode change
only reaches the scheduler after the container is recreated on its next start (the label mismatch
forces that).
### Container Introspection (Capability Tiles)
`list_container_capabilities` (`commands/inspect_commands.rs`) runs a read-only `find`/`jq` script
inside a running container and returns counts plus item lists for **skills, agents, commands, hooks,
plugins and MCP servers**, at user scope (`/home/claude/.claude`) and project scope
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Overview renders these as tiles.
Triple-C does not create or edit any of them — Claude Code owns that configuration, and the tiles
link out to a terminal where `/agents`, `/hooks`, `/plugins` and `/mcp` do the real work.
### Auth Bridge
Browser-based logins run *inside* a container (`claude login`, `aws sso login`, Concourse
`fly login`) start an ephemeral HTTP listener on the container's loopback and expect the host
browser's redirect to reach it. `auth_bridge/` closes that gap:
- Listeners are discovered by parsing `/proc/net/tcp{,6}` every 2 seconds — the image ships no
`ss`, `netstat` or `lsof`. Only `TCP_LISTEN` rows bound to loopback are considered; wildcard
binds are deliberately ignored (that is the port-mappings feature's job).
- Each discovered port is bound on the host at **the same port number**, on `127.0.0.1` (required)
and `[::1]` (best effort) — never a wildcard address. Node resolves `localhost` to IPv6 first, so
`claude login` often binds `::1` alone; the bridge follows the family it actually finds.
- Traffic is carried in over the Docker API by an attached exec running `socat`, because container
IPs are not routable from the host on Docker Desktop.
- Ports already covered by the project's port mappings are skipped, and a host port that is already
in use is reported as a conflict rather than fought over.
Opt-in per project (`auth_bridge_enabled`, default `false`), purely host-side, so toggling it never
recreates the container. The poller stops on its own when the container stops.
**Security posture:** the host side binds loopback only. Everything reachable through it is an
unauthenticated service inside the container, so widening those addresses would publish container
internals to the LAN. Nothing else on the network can reach a bridged port.
### Shared Claude Authentication Token
Rather than running `claude login` in every container, `claude setup-token` can be run once
(`commands/auth_token_commands.rs`). The flow borrows a running container, runs the CLI on a PTY,
and the long-lived token it prints is stored in the OS keychain — it is never returned to the
frontend and never logged. Streamed output passes through a chunk-boundary-safe redactor that masks
anything resembling an `sk-ant-` secret.
The token is injected as `CLAUDE_CODE_OAUTH_TOKEN` into every project where the backend is
Anthropic, the project has not opted out (`use_shared_auth_token`, default `true`), and a token is
actually stored. It is a reserved env key, so it cannot be hand-set as a custom variable.
Rotation is tracked with a random id (not a hash of the token) mirrored into the
`triple-c.claude-token-version` label — a hash in a `docker inspect`-readable label would be an
offline verification oracle. Acquiring, rotating, revoking or opting out changes that label, which
forces a container recreation on the next start; that is when a container picks the token up or has
it cleared.
### Container Lifecycle
1. **Create**: New container created with bind mounts, env vars, and labels
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, sets up MCP servers
3. **Terminal**: `docker exec` launches Claude Code (or bash shell) with a PTY
4. **Stop**: Container halted (filesystem persists in named volume); MCP containers stopped
5. **Restart**: Existing container restarted; recreated if settings changed (detected via SHA-256 fingerprint)
6. **Reset**: Container removed and recreated from scratch (named volume preserved)
1. **Create**: New container created with bind mounts, named volumes, env vars, and labels
2. **Start**: Container started, entrypoint remaps UID/GID, sets up SSH, configures Docker group, injects Claude Code settings, rebuilds the scheduler crontab
3. **Terminal**: `docker exec` launches Claude Code (with the project's permission-mode flags) or a bash login shell, with a PTY
4. **Stop**: Container halted (its filesystem layer and both named volumes persist)
5. **Restart**: Existing container restarted; if any `triple-c.*` label no longer matches the project's settings, the container is committed to a snapshot image, removed, and recreated from that snapshot — so installed packages survive
6. **Reset**: Container, snapshot image **and both named volumes** all removed, then recreated from the clean base image. `remove_project_volumes` deletes `triple-c-home-{projectId}` and `triple-c-claude-config-{projectId}`, so `~/.claude`, `~/.claude.json`, the OAuth login, installed skills, session transcripts and the scheduler's tasks are all lost.
### Mounts
| Target in Container | Source | Type | Notes |
|---|---|---|---|
| `/workspace` | Project directory | Bind | Read-write |
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Persists across container recreation |
| `/workspace/<mount-name>` | Each configured project folder | Bind | Read-write; one per folder |
| `/home/claude` | `triple-c-home-{projectId}` | Named Volume | Home directory; survives stop/start and recreation |
| `/home/claude/.claude` | `triple-c-claude-config-{projectId}` | Named Volume | Nested inside the home volume; Docker gives the more specific mount precedence |
| `/tmp/.host-ssh` | SSH key directory | Bind | Read-only; entrypoint copies to `~/.ssh` |
| `/home/claude/.aws` | AWS config directory | Bind | Read-only; for Bedrock auth |
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON, or auto-enabled by stdio+Docker MCP servers |
| `/var/run/docker.sock` | Host Docker socket | Bind | If "Allow container spawning" is ON |
These two named volumes are the only ones a project owns. Both are removed by Reset and by project
removal, and by nothing else.
### Authentication Modes
Each project can independently use one of:
- **Anthropic** (OAuth): User runs `claude login` inside the terminal on first use. Token persisted in the config volume across restarts and resets.
- **Anthropic** (OAuth or shared token): either the shared `claude setup-token` token injected as `CLAUDE_CODE_OAUTH_TOKEN` (see below), or a per-container `claude login`. An interactive login's token lives in the config volume and survives container stop/start and recreation — but **not** a Reset, which deletes the volumes.
- **AWS Bedrock**: Per-project AWS credentials (static keys, profile, or bearer token). SSO sessions are validated before launching Claude for Profile auth.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Optional model override.
- **LiteLLM**: Connect through a LiteLLM proxy gateway via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN` to access 100+ model providers. API key stored securely in OS keychain.
- **Ollama**: Connect to a local or remote Ollama server via `ANTHROPIC_BASE_URL` (e.g., `http://host.docker.internal:11434`). Requires a model ID, and the model must be pulled (or used via Ollama cloud) before starting the container.
- **OpenAI Compatible**: Connect through any OpenAI API-compatible endpoint (LiteLLM, OpenRouter, vLLM, text-generation-inference, LocalAI, etc.) via `ANTHROPIC_BASE_URL` + `ANTHROPIC_AUTH_TOKEN`. API key stored securely in OS keychain.
> **Note:** Ollama and LiteLLM support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
> **Note:** Ollama and OpenAI Compatible support is best-effort. Claude Code is designed for Anthropic models, so some features (tool use, extended thinking, prompt caching, etc.) may not work as expected with non-Anthropic models behind these backends.
### Container Spawning (Sibling Containers)
@@ -60,30 +179,34 @@ When "Allow container spawning" is enabled per-project, the host Docker socket i
If the Docker access setting is toggled after a container already exists, the container is automatically recreated on next start to apply the mount change. The named config volume (keyed by project ID) is preserved across recreation.
### MCP Server Architecture
Triple-C supports [Model Context Protocol (MCP)](https://modelcontextprotocol.io/) servers as a Beta feature. MCP servers extend Claude Code with external tools and data sources.
**Modes**: Each MCP server operates in one of four modes based on transport type and whether a Docker image is specified:
| Mode | Where It Runs | How It Communicates |
|------|--------------|---------------------|
| Stdio + Manual | Inside the project container | Direct stdin/stdout (e.g., `npx -y @mcp/server`) |
| Stdio + Docker | Separate MCP container | `docker exec -i <mcp-container> <command>` from the project container |
| HTTP + Manual | External / user-provided | Connects to the URL you specify |
| HTTP + Docker | Separate MCP container | `http://<mcp-container>:<port>/mcp` via Docker DNS on a shared bridge network |
**Key behaviors**:
- **Global library**: MCP servers are defined globally in the MCP sidebar tab and stored in `mcp_servers.json`
- **Per-project toggles**: Each project enables/disables individual servers via checkboxes
- **Auto-pull**: Docker images for MCP servers are pulled automatically if not present when the project starts
- **Docker networking**: Docker-based MCP containers run on a per-project bridge network (`triple-c-net-{projectId}`), reachable by container name — not localhost
- **Auto-detection**: Config changes are detected via SHA-256 fingerprints and trigger automatic container recreation
- **Config injection**: MCP server configuration is written to `~/.claude.json` inside the container via the `MCP_SERVERS_JSON` environment variable, merged by the entrypoint using `jq`
### Mission Control Integration
Optional per-project integration with [Flight Control](https://github.com/msieurthenardier/mission-control) — an AI-first development methodology. When enabled, the repo is cloned into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
Optional per-project integration with Flight Control — an AI-first development methodology bundled with Triple-C. When enabled, the bundled files are installed into the container, skills are installed, and workflow instructions are injected into CLAUDE.md.
### Web Terminal (Remote Access)
Triple-C includes an optional web terminal server for accessing project terminals from tablets, phones, or other devices on the local network. When enabled in Settings, an axum HTTP+WebSocket server starts inside the Tauri process, serving a standalone xterm.js-based terminal UI.
- **URL**: `http://<LAN_IP>:7681?token=...` (port configurable)
- **Authentication**: Token-based (auto-generated, copyable from Settings)
- **Protocol**: JSON over WebSocket with base64-encoded terminal data
- **Features**: Project picker, multiple tabs (Claude + bash sessions), mobile-optimized input bar, scroll-to-bottom button
- **Session cleanup**: All terminal sessions are closed when the browser disconnects
The web terminal shares the existing `ExecSessionManager` via `Arc`-wrapped stores — same Docker exec sessions, different transport (WebSocket instead of Tauri IPC events).
### Speech-to-Text (Voice Mode)
Triple-C includes optional speech-to-text powered by [Faster Whisper](https://github.com/SYSTRAN/faster-whisper) running in a separate Docker container. When enabled, a microphone button appears in the StatusBar whenever a terminal session is active.
- **Hotkey**: `Ctrl+Shift+M` to toggle recording
- **Models**: `tiny`, `small`, or `medium` (configurable in Settings)
- **Port**: Default `9876` (configurable)
- **Language**: Optional language hint for transcription
- **Auto-start**: When STT is enabled in Settings, the container starts automatically with the app — no need to manually start it after each restart
- **On-demand fallback**: If not auto-started, the container starts automatically when you first click the mic button
**How it works**: Audio is captured in the browser via the Web Audio API, encoded as WAV, and sent to the Faster Whisper container's `/transcribe` endpoint. The transcribed text is inserted directly into the active terminal. The STT container uses a named Docker volume (`triple-c-stt-model-cache`) to cache Whisper models across restarts.
### Docker Socket Path
@@ -97,39 +220,67 @@ Users can override this in Settings via the global `docker_socket_path` option.
| File | Purpose |
|---|---|
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar) |
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark` |
| `app/src/components/layout/TopBar.tsx` | Terminal tabs + Docker/Image status indicators |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px) |
| `app/src/components/layout/StatusBar.tsx` | Running project/terminal counts |
| `app/src/components/projects/ProjectCard.tsx` | Project config, auth mode, action buttons |
| `app/src/App.tsx` | Root layout (TopBar + Sidebar + Main + StatusBar + ToastHost) |
| `app/src/index.css` | Global CSS variables, dark theme, `color-scheme: dark`, `:focus-visible` ring |
| `app/src/components/layout/TopBar.tsx` | Hosts MainTabs + Docker/Image status indicators + Help |
| `app/src/components/layout/MainTabs.tsx` | The single main-area tab strip (Project Home + terminal tabs) |
| `app/src/components/layout/Sidebar.tsx` | Responsive sidebar (25% width, min 224px, max 320px), collapsible to an icon rail |
| `app/src/components/layout/StatusBar.tsx` | Project/terminal counts, Jump to Current, STT mic |
| `app/src/components/projects/ProjectRow.tsx` | Select-only sidebar row; opens Project Home, with hover start/stop and terminal controls |
| `app/src/components/projects/ProjectList.tsx` | Project list in sidebar |
| `app/src/components/projects/FileManagerModal.tsx` | File browser modal (browse, download, upload) |
| `app/src/components/projects/ContainerProgressModal.tsx` | Real-time container operation progress |
| `app/src/components/mcp/McpPanel.tsx` | MCP server library (global configuration) |
| `app/src/components/mcp/McpServerCard.tsx` | Individual MCP server configuration card |
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, and global settings |
| `app/src/components/projects/PermissionModeControl.tsx` | Plan / Default / Accept Edits / Bypass segmented control |
| `app/src/components/projects/home/ProjectHome.tsx` | Project Home shell: header actions, overflow menu, tab strip |
| `app/src/components/projects/home/OverviewTab.tsx` | Permission mode, summary, capability tiles, recent sessions and tasks |
| `app/src/components/projects/home/SessionsTab.tsx` | Past Claude sessions with Resume |
| `app/src/components/projects/home/AutomationTab.tsx` | Scheduler tasks: toggle, run now, logs, remove, notifications |
| `app/src/components/projects/home/ConfigTab.tsx` | Config sections (Workspace, Model, Access, Runtime) |
| `app/src/components/projects/home/FilesTab.tsx` | File browser (browse, download, upload) |
| `app/src/components/projects/home/CapabilityTiles.tsx` | Read-only skills/agents/commands/hooks/plugins/MCP counts |
| `app/src/components/projects/ClaudeCodeSettingsEditor.tsx` | Claude Code CLI settings (TUI mode, effort, focus, caching) |
| `app/src/components/ui/` | Shared primitives: `Modal`, `Button`, `Toggle`, `Field`, `SegmentedControl`, `StatusIndicator`, `SaveIndicator`, `OverflowMenu`, `ToastHost`, `Tooltip` |
| `app/src/hooks/useKeyboardShortcuts.ts` | `Ctrl+T`, `Ctrl+Shift+W`, `Ctrl+Tab`, `Ctrl+1..9` |
| `app/src/hooks/useContainerProgress.ts` | `container-progress` event → inline progress lines |
| `app/src/components/settings/SettingsPanel.tsx` | Docker, AWS, timezone, web terminal, shared auth, and global settings |
| `app/src/components/settings/SharedAuthSettings.tsx` | Acquire / revoke the shared Claude authentication token |
| `app/src/components/settings/WebTerminalSettings.tsx` | Web terminal toggle, URL, token management |
| `app/src/components/settings/SttSettings.tsx` | STT settings panel (model, port, language, container controls) |
| `app/src/components/terminal/TerminalView.tsx` | xterm.js terminal with WebGL, URL detection, OSC 52 clipboard, image paste |
| `app/src/components/terminal/TerminalTabs.tsx` | Tab bar for multiple terminal sessions (claude + bash) |
| `app/src/components/terminal/SttButton.tsx` | Mic button with on-demand STT container start |
| `app/src/hooks/useTerminal.ts` | Terminal session management (claude and bash modes) |
| `app/src/hooks/useProjectActions.ts` | Start/stop/reset/backup and terminal-opening helpers |
| `app/src/hooks/useFileManager.ts` | File manager operations (list, download, upload) |
| `app/src/hooks/useMcpServers.ts` | MCP server CRUD operations |
| `app/src/hooks/useVoice.ts` | Voice mode audio capture (currently hidden) |
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, MCP injection, fingerprinting |
| `app/src-tauri/src/docker/exec.rs` | PTY exec sessions, file upload/download via tar |
| `app/src/hooks/useClaudeAuth.ts` | Shared-token status and acquisition |
| `app/src/hooks/useSTT.ts` | Speech-to-text recording, transcription, and container management |
| `app/src-tauri/src/docker/container.rs` | Container creation, mounts, env vars, labels, recreation checks, `remove_project_volumes` |
| `app/src-tauri/src/docker/exec.rs` | `create_attached_exec()` — the single attached-exec path; file upload/download via tar |
| `app/src-tauri/src/docker/image.rs` | Image building/pulling |
| `app/src-tauri/src/docker/network.rs` | Per-project bridge networks for MCP containers |
| `app/src-tauri/src/docker/stt.rs` | Speech-to-text container lifecycle |
| `app/src-tauri/src/docker/legacy_cleanup.rs` | One-release migration shim removing leftovers from the deleted MCP feature |
| `app/src-tauri/src/auth_bridge/` | Loopback callback bridge (`mod.rs`, `proc_net.rs`, `tunnel.rs`) |
| `app/src-tauri/src/commands/project_commands.rs` | Start/stop/rebuild Tauri command handlers |
| `app/src-tauri/src/commands/inspect_commands.rs` | Read-only container views: sessions, capabilities, scheduler tasks |
| `app/src-tauri/src/commands/auth_token_commands.rs` | `claude setup-token` flow, redaction, keychain storage |
| `app/src-tauri/src/commands/auth_bridge_commands.rs` | Auth bridge enable/status commands |
| `app/src-tauri/src/commands/file_commands.rs` | File manager Tauri commands (list, download, upload) |
| `app/src-tauri/src/commands/mcp_commands.rs` | MCP server CRUD Tauri commands |
| `app/src-tauri/src/models/project.rs` | Project struct (auth mode, Docker access, MCP servers, Mission Control) |
| `app/src-tauri/src/models/mcp_server.rs` | MCP server struct (transport, Docker image, env vars) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, microphone) |
| `app/src-tauri/src/storage/mcp_store.rs` | MCP server persistence (JSON with atomic writes) |
| `app/src-tauri/src/models/project.rs` | Project struct (backend, `PermissionMode`, Docker access, Claude Code settings, Mission Control, auth bridge, shared-token opt-out) |
| `app/src-tauri/src/models/app_settings.rs` | Global settings (image source, Docker socket, AWS, Claude Code settings, web terminal, STT) |
| `app/src-tauri/src/web_terminal/server.rs` | Axum HTTP+WS server for remote terminal access |
| `app/src-tauri/src/web_terminal/ws_handler.rs` | WebSocket connection handler and session management |
| `app/src-tauri/src/web_terminal/terminal.html` | Embedded web UI (xterm.js, project picker, tabs) |
| `app/src-tauri/src/commands/stt_commands.rs` | STT start/stop/transcribe Tauri commands |
| `app/src-tauri/src/commands/web_terminal_commands.rs` | Web terminal start/stop/status Tauri commands |
| `app/src-tauri/src/docker/stt.rs` | STT Docker container lifecycle (create, start, stop, build, pull) |
| `app/src/lib/wav.ts` | WAV audio encoding for STT transcription |
| `stt-container/Dockerfile` | Faster Whisper STT container image (Python 3.11 + FastAPI) |
| `stt-container/server.py` | STT HTTP server (POST /transcribe endpoint) |
| `container/Dockerfile` | Ubuntu 24.04 sandbox image with Claude Code + dev tools + clipboard/audio shims |
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, MCP injection, Mission Control setup |
| `container/entrypoint.sh` | UID/GID remap, SSH setup, Docker group config, Claude Code settings injection, Mission Control setup |
| `container/osc52-clipboard` | Clipboard shim (xclip/xsel/pbcopy via OSC 52) |
| `container/audio-shim` | Audio capture shim (rec/arecord via FIFO) for voice mode |
| `container/triple-c-scheduler` | Bash CLI managing scheduled task JSON and the crontab |
| `container/triple-c-task-runner` | Cron entry point; maps `TRIPLE_C_PERMISSION_MODE` to flags and runs `claude -p` |
| `container/triple-c-sso-refresh` | AWS SSO session refresh helper |
| `app/src-tauri/src/storage/secure.rs` | OS keychain access (per-project secrets, shared token, rotation id) |
## CSS / Styling Notes
+250
View File
@@ -0,0 +1,250 @@
# Triple-C Roadmap — Claude Code Feature Parity
**Date:** 2026-08-09 · **Baseline:** v0.3.0 · **Claude Code reference:** 2.1.226
Companion to [DESIGN-REVIEW.md](DESIGN-REVIEW.md), which covers visual design and
information architecture. This document covers *which Claude Code capabilities Triple-C
should surface, and why.*
---
## Guiding principle
> **Triple-C shows state and launches things. Claude Code edits its own config.**
Triple-C's built-in MCP server management was removed in this cycle because Claude Code
absorbed the capability natively (`claude mcp add/list/remove`, `.mcp.json`, `/mcp`).
Hooks, skills, agents, plugins, output styles, and statusline are the same species: files
under `.claude/` with first-class Claude Code TUIs. Building GUI form editors for them
means losing the same race again.
What Claude Code cannot do is what Triple-C uniquely owns: **the container boundary and
what persists behind it** — the config volume, workspace mounts, lifecycle, the bundled
scheduler, and the fleet view across many projects.
---
## Current coverage (v0.3.0)
Triple-C sets exactly five `settings.json` keys, plus a sandbox block:
| Key | Surfaced as |
|---|---|
| `tui` | TUI Mode select (`fullscreen`) |
| `effort` | Effort Level select (`low`/`medium`/`high`) |
| `autoScrollEnabled` | Auto-Scroll Disabled toggle |
| `focusMode` | Focus Mode toggle |
| `showThinkingSummaries` | Thinking Summaries toggle |
| `sandbox.*` | Sandbox toggle (`enabled`, `enableWeakerNestedSandbox`, `allowUnsandboxedCommands`) |
Plus four env feature flags — `CLAUDE_CODE_NO_FLICKER`, `CLAUDE_CODE_ENABLE_AWAY_SUMMARY`,
`CLAUDE_CODE_SUBPROCESS_ENV_SCRUB`, `ENABLE_PROMPT_CACHING_1H` — and arbitrary user-set
`CLAUDE_CODE_*` vars via the Env Vars modal.
Also covered: per-project auth backends (Anthropic OAuth, Bedrock incl. SSO refresh,
Ollama, OpenAI-compatible), user-level `CLAUDE.md` composition, `claude update` on every
container start, terminal ergonomics (OAuth URL detection, OSC 52 clipboard, image paste,
file drag-drop, STT), the web terminal, and workspace backup.
---
## Gap analysis
### Committed for this cycle
| # | Gap | Today | Plan |
|---|---|---|---|
| 1 | **Permission modes** | one boolean → `--dangerously-skip-permissions` | Four-state control (Plan / Default / Accept Edits / Bypass) → `--permission-mode`. Verified choices on 2.1.226: `acceptEdits`, `auto`, `bypassPermissions`, `manual`, `dontAsk`, `plan`. |
| 2 | **Session resume** | none | List sessions from the config volume; `[Resume]` opens a terminal on `claude --resume <id>`. |
| 3 | **Capability inventory** | none | Read-only counts + names for skills / agents / hooks / plugins / commands / native MCP servers. Deep-link to the terminal to manage. |
| 4 | **Automation** | `triple-c-scheduler` ships in every container with *zero* UI | Task list, cron editor, run-now, logs, notification badges. |
| 5 | **Container auth handoff** | manual code paste | See "Authentication handoff" below — design decision pending. |
### Deliberately skipped
Status line builder · output-styles editor · hook *editors* · checkpoint/rewind browser ·
plugin marketplace browser. Each is niche, natively handled by Claude Code's own TUI, or a
settings-editor trap. Surface counts and deep-link instead.
### Not yet scheduled
- Granular `permissions.allow` / `ask` / `deny` rules and `additionalDirectories`
- Sandbox detail settings (`filesystem.allowRead/allowWrite`, `allowedDomains`,
`excludedCommands`) — currently documented for hand-editing via `SANDBOX_INSTRUCTIONS`
- Project-level `.claude/settings.json` vs user-level settings hierarchy
- A model picker. **Note:** the only model strings in the app today are stale placeholders
(`anthropic.claude-sonnet-4-20250514-v1:0` in `AwsSettings.tsx` and `ProjectCard.tsx`,
`qwen3.5:27b`, `gpt-4o / gemini-pro / etc.`). These are free-text placeholders, not
dropdowns, but they should be refreshed to current model identifiers regardless.
- The container's settings.json merge is **shallow** (`jq -s '.[0] * .[1]'`), so a
user-authored nested block such as `sandbox.filesystem.allowWrite` is replaced wholesale
on every container start. Worth deepening to `*` recursive merge.
---
## Authentication handoff
**Goal:** stop making users hand-copy an auth code into every container.
**Constraint discovered during research:** `claude login`'s callback server uses an
**ephemeral port** and its redirect URI is **not configurable** for the main login flow
(`--callback-port` and `oauth.callbackPort` apply to *MCP server* OAuth only). So a design
that pre-assigns each container a fixed callback port and routes to it cannot work as
stated — there is no fixed port to route.
There is also a known container gotcha: on Linux, Node resolves `localhost` to IPv6 first,
so the callback server may bind `[::1]:PORT` only and be unreachable over IPv4
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
Two viable options:
### Option A — long-lived token injection (simple)
`claude setup-token` (verified present on 2.1.226: *"Set up a long-lived authentication
token (requires Claude subscription)"*) returns a ~1-year OAuth token. Triple-C runs it in
a running container, stores the token in the OS keychain via the existing `secure.rs`, and
injects `CLAUDE_CODE_OAUTH_TOKEN` into every container on the Anthropic backend.
**Correction to an earlier assumption in this document.** `setup-token` does *not* start a
loopback callback listener, so it does not need the Auth Bridge. Verified by running it
under a pty: its `redirect_uri` is Anthropic-hosted
(`https://platform.claude.com/oauth/code/callback`), the user copies a code off that page,
and the CLI blocks at a `Paste code here if prompted >` prompt on **stdin**. A stdin path
is therefore mandatory — the flow cannot complete without one.
- No routing, no ports, no proxy.
- One auth event covers every project.
- Cost: small. Reuses existing keychain and env-injection plumbing.
- Limits: token is subscription-scoped and expires annually; per the docs a `setup-token`
token cannot drive Remote Control sessions or claude.ai connector fetches.
Change detection uses a **random rotation id** in the `triple-c.claude-token-version`
label, not a hash of the token. Labels are readable by anything that can run
`docker inspect`, so a hash would be an offline verification oracle — given a candidate
token you could confirm it. A presence boolean would instead miss rotations and silently
leave containers on a stale token.
### Option B — the Auth Bridge (general loopback-callback bridge)
Option A only solves Claude Code. The same problem affects every CLI that authenticates by
starting a temporary loopback listener and opening a browser at a URL that redirects back
to it — Concourse `fly login` (random loopback port serving `/auth/callback`),
`aws sso login`, and many others. Inside a container the host browser cannot reach that
listener, so login stalls.
Because the ports are ephemeral and unconfigurable, nothing can be pre-assigned. The bridge
**discovers** listeners instead:
1. While enabled for a running project, poll the container for loopback TCP listeners by
reading `/proc/net/tcp` and `/proc/net/tcp6` over `docker exec` — no dependency on
`ss`/`netstat`/`lsof`, which aren't guaranteed in the image.
2. For each newly-appeared loopback listener, bind **the same port on the host's
`127.0.0.1`** (never `0.0.0.0` — that would expose container internals to the LAN).
3. Proxy each accepted connection into the container over the Docker API via
`socat - TCP:127.0.0.1:<port>` (socat already ships in the image), reusing the existing
attached-exec streaming in `docker/exec.rs`. Going through the Docker API rather than a
container IP keeps this working on Docker Desktop, where container IPs are not routable
from the host.
4. Fall back to `TCP6:[::1]:<port>` when the listener appeared only on IPv6 — on Linux,
Node resolves `localhost` to IPv6 first, so `claude login` frequently binds `::1` only
([anthropics/claude-code#44844](https://github.com/anthropics/claude-code/issues/44844)).
5. Tear down when the listener vanishes, the container stops, the bridge is disabled, or
the app exits. Ports already covered by the project's explicit port mappings are skipped;
host-side conflicts are reported rather than silently swallowed.
Opt-in per project (`auth_bridge_enabled`, default off), since it makes container-internal
loopback services reachable from the host.
**Plan:** ship **A** for Claude Code specifically — it removes the pain for the common case
at a fraction of the cost — and **B** as the general mechanism covering every other CLI.
They compose: A means most users never trigger a browser login at all; B catches AWS SSO,
Concourse, and anything else that needs a real callback.
---
## Sequencing
**Phase 0 — done.** Remove MCP (frontend, backend, entrypoint, docs) with a self-healing
migration for containers created against the old per-project Docker network.
**Phase 1 — foundations.** Permission modes end-to-end (including the scheduler bug fix
below). Read-only introspection backend: sessions, capabilities, scheduler.
**Phase 2 — Tier-1 polish.** Focus rings, contrast fixes, real buttons, inline start/stop
progress, status labels, onboarding welcome screen, shared accessible `<Modal>`.
**Phase 3 — Project Home.** Move project config out of the sidebar card into a tabbed
main-area view (Overview / Sessions / Automation / Config), dissolving the modal pile and
splitting the 1,257-line `ProjectCard`.
**Phase 4 — authentication handoff.** Option A, then evaluate B.
**Phase 5 — Library.** Global skills/agents/commands with per-project enable, synced into
the config volume by the entrypoint. Generalizes the pattern the MCP tab was reaching for.
---
## Bugs found during this review
1. **Scheduled tasks ignore the project's permission setting.**
`container/triple-c-task-runner:69` runs
`claude -p "$PROMPT" --dangerously-skip-permissions` unconditionally, regardless of the
project's Full Permissions toggle. Being fixed as part of Phase 1.
2. **Docs claim Reset preserves credentials; it does not.**
`rebuild_project_container` calls `remove_project_volumes`, which deletes both
`triple-c-home-{id}` (holding `~/.claude.json`) and `triple-c-claude-config-{id}`
(holding `~/.claude`). README.md, HOW-TO-USE.md, and CLAUDE.md all still state that
OAuth tokens survive a Reset. Pre-existing; not yet corrected.
3. **An invalid cron expression silently unscheduled every task.** Found while adding
task creation to the Automation tab, and the most serious bug in this review.
`triple-c-scheduler` never validated `--schedule`, and `rebuild_crontab` regenerates the
*entire* crontab and pipes it to `crontab`, which rejects the whole file if any single
line is malformed — with the error discarded by `2>/dev/null || true`. So one bad
schedule silently unscheduled every other task in the container, reporting success.
Reproduced directly. This mattered because the global CLAUDE.md instructs Claude to use
this CLI, so Claude itself could trigger it. Fixed at the root: `add` now validates the
expression and exits non-zero, and `rebuild_crontab` reports a rejected crontab instead
of swallowing it. The Rust `add_scheduled_task` command validates independently.
4. **Reset was destructive with no confirmation.** It deletes both volumes — the login,
installed skills, all session transcripts — from a single unconfirmed click, while the
comparably destructive Remove already confirmed. Now gated by a dialog that names each
loss. Fixed.
5. **Cancelling authentication did not cancel.** Fixed — see the handoff section above.
6. **Stale model placeholders** — see "Not yet scheduled" above.
7. **Silent save failures.** Project config saves on blur; failures went only to
`console.error`. Fixed in Phase 3 — `useProjectSave` now renders a
Saved / Saving / Save failed indicator and raises a toast.
---
## Known gaps left by Phase 23
- **Editing a scheduled task changes its id.** `triple-c-scheduler` has no `edit`
subcommand, and hand-editing its JSON behind its back would desync the crontab, so edit is
implemented as add-then-remove. The add runs first, so a rejected edit leaves the original
intact. The task gets a new id and its older logs stay under the old one; the editor says
so before saving.
- **`open_terminal_session` takes no command argument.** "Resume session" and
"Manage in terminal" therefore open a bash tab and *type* the command after a
fixed prompt delay. It works, but it is timing-dependent and will misfire on a
slow container start. The fix is a `command: Option<String>` parameter on the
Tauri command so the exec launches the process directly.
- **Uptime is observed, not reported.** `get_container_info` returns a status enum
with no start time, so Project Home records "running since" when the app *sees*
the transition. A container already running when the app launches shows
`● Running` with no elapsed time. Surfacing Docker's `State.StartedAt` would fix it.
- **`lucide-react` was not adopted** (DESIGN-REVIEW Tier-1 #9) — no package-registry
access in the build environment used for this cycle. The existing inline SVGs and
text glyphs remain.
- **The tab strip stayed in the TopBar** rather than moving onto the terminal panel's
top edge. DESIGN-REVIEW §A6 asks for the move but its own §B2 layout diagram puts
the tabs in the TopBar; the diagram won. Worth revisiting.
- **`Ctrl+Shift+W`, not `Ctrl+W`, closes a tab.** Plain `Ctrl+W` is readline's
`kill-word`, used constantly inside the terminal this app is built around;
intercepting it globally would break word-erase in every shell.
+266 -47
View File
@@ -2,7 +2,7 @@
## Overview
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that when running with `--dangerously-skip-permissions`, Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
Triple-C (Claude-Code-Container) sandboxes Claude Code inside Docker containers so that even in its most permissive mode — `--dangerously-skip-permissions` Claude only has access to files and projects you explicitly provide. The project consists of two components: a **Docker container image** pre-loaded with development tools, and a **cross-platform desktop application** for managing project containers, terminal sessions, and authentication.
---
@@ -57,6 +57,16 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
- **Web links addon**`@xterm/addon-web-links` makes URLs in terminal output clickable. Combined with `tauri-plugin-opener`, clicked URLs open in the host browser — essential for the `claude login` OAuth flow where Claude prints an authentication URL that must be opened on the host.
- **Bidirectional data flow** — xterm.js exposes `term.onData()` for user keystrokes and `term.write()` for incoming data. This maps directly to our Tauri event-based streaming architecture.
#### Terminal Layout & StatusBar Controls
Implementation gotchas for the terminal view and its global controls (merged in PR #7, `terminal-layout-statusbar`):
- **xterm padding lives on a wrapper, never the host.** FitAddon measures the same element that `term.open()` mounts into, so any padding on that host element makes the grid overhang and clip its rightmost column / bottom row. Padding must live on a **wrapper `div`**; the xterm host fills it with no padding of its own. Do not reintroduce padding on the host element in `TerminalView.tsx`.
- **STT mic and "Jump to Current" live in the global `StatusBar`, not per-terminal overlays.** There is a single `useSTT` instance in `App.tsx` bound to the active session. `Ctrl+Shift+M` routes through the Zustand store (`sttToggle`).
- **Recording is pinned to where it started.** The STT transcript targets `recordingSessionIdRef` (the session recording began in), **not** the live active session — switching tabs mid-recording must not misroute the transcript.
- **"Jump to Current" state is written only by the active terminal.** The active `TerminalView` surfaces `terminalAtBottom` and `scrollActiveToBottom` through the store; only the active terminal writes them, and they are cleared on its unmount.
- **Set store function values via object-merge, not the updater form**`set({ fn: value })`, not `set(state => ...)` — when publishing action callbacks (like `scrollActiveToBottom`) into the Zustand store.
### bollard (Docker API)
**Chosen over:** Shelling out to the `docker` CLI, dockerode (Node.js), docker-api (Python)
@@ -100,16 +110,21 @@ Tauri uses a Rust backend paired with a web-based frontend rendered by the OS-na
│ │ Project Management │◄─┤ ProjectsStore │ │
│ │ Settings UI │ │ bollard Docker Client │ │
│ │ │ │ keyring Credential Mgr │ │
│ └───────────┬───────────┘ └────────────┬─────────────┘
│ └───────────┬───────────┘ │ Web Terminal Server │
│ │ └────────────┬─────────────┘ │
│ │ Tauri IPC (invoke/emit) │ │
│ └───────────┬───────────────┘ │
│ ▲ │
│ axum HTTP+WS│(port 7681) │
│ │ │
└──────────────────────────┼───────────────────────────────┘
│ Docker Socket
┌──────────────────────────────────────────────────────────┐
│ Docker Container (per project) │
│ │
│ /workspace ←─ bind mount ─► Host project directory
│ /workspace/<name> ←─ bind mount ─► Host project folder
│ /home/claude ←── named volume (home dir) │
│ /home/claude/.claude ←── named volume (persists config) │
│ /tmp/.host-ssh ←── read-only bind mount (SSH keys) │
│ /var/run/docker.sock ←── optional (sibling containers) │
@@ -129,6 +144,8 @@ The application uses two IPC mechanisms between the React frontend and Rust back
**Request/Response** (`invoke()`): Used for discrete operations — starting containers, saving settings, listing projects. The frontend calls `invoke("command_name", { args })` and awaits a typed result.
**WebSocket Streaming** (Web Terminal): Used for remote terminal access from browsers on the local network. An axum HTTP+WebSocket server runs inside the Tauri process, sharing the same `ExecSessionManager` via `Arc`-wrapped stores. The WebSocket uses a JSON protocol with base64-encoded terminal data. Each browser connection can open multiple terminal sessions; all sessions are cleaned up when the WebSocket disconnects.
**Event Streaming** (`emit()`/`listen()`): Used for continuous data — terminal I/O. When a terminal session is opened, the Rust backend spawns two tokio tasks:
1. **Output reader** — Reads from the Docker exec stdout stream and emits `terminal-output-{sessionId}` events to the frontend.
2. **Input writer** — Listens on an `mpsc::unbounded_channel` for data sent from the frontend via `invoke("terminal_input")` and writes it to the Docker exec stdin.
@@ -144,22 +161,143 @@ Terminal resize follows the same pattern: `ResizeObserver` detects container siz
Containers follow a **stop/start** model, not create/destroy:
1. **First start**: A new container is created with bind mounts, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, then runs `sleep infinity` to keep the container alive.
2. **Terminal open**: `docker exec` launches `claude --dangerously-skip-permissions` with a PTY in the running container.
1. **First start**: A new container is created with bind mounts, named volumes, environment variables, and labels. The entrypoint remaps UID/GID, configures SSH and git, rebuilds the scheduler crontab, then runs `sleep infinity` to keep the container alive.
2. **Terminal open**: `docker exec` launches `claude` with a PTY in the running container, with the permission-mode flags from `PermissionMode::cli_args()` (or `bash -l` for a shell session).
3. **Stop**: `docker stop` halts the container but preserves its filesystem. Any packages Claude installed via `apt`, `pip`, `cargo`, etc. survive.
4. **Restart**: `docker start` resumes the existing container. All installed tools and configuration persist.
5. **Reset**: The container is removed and recreated from the image. This is a clean slate — the nuclear option when the container state is corrupted.
4. **Restart**: `docker start` resumes the existing container — unless `container_needs_recreation()` finds a `triple-c.*` label that no longer matches the project's settings, in which case the container is committed to a snapshot image (`triple-c-snapshot-{projectId}:latest`), removed, and recreated from that snapshot. Installed tools survive; the named volumes are untouched.
5. **Reset**: `rebuild_project_container` closes live exec sessions, removes the container, removes the snapshot image, calls `remove_project_volumes` to delete **both** named volumes, then starts fresh from the clean base image.
The `.claude` configuration directory uses a **named Docker volume** (`triple-c-claude-config-{projectId}`) so OAuth tokens from `claude login` persist even across container resets.
Two named volumes exist per project and they are the only ones it owns:
| Volume | Mount point | Purpose |
|---|---|---|
| `triple-c-home-{projectId}` | `/home/claude` | Home directory — `~/.claude.json`, `~/.local`, `~/.ssh`, `~/.aws` |
| `triple-c-claude-config-{projectId}` | `/home/claude/.claude` | Claude Code config: OAuth credential, settings, skills/agents/commands, session transcripts, scheduler state. Nested inside the home volume; Docker gives the more specific mount precedence. |
`remove_project_volumes` names those two volumes explicitly (no prefix sweep) and is called from
exactly two places: `remove_project` and `rebuild_project_container`. Ordinary container removal
passes `v: false`, so stop/start and recreation never touch the volumes — **only Reset and project
removal delete them.** A Reset therefore destroys the `claude login` credential, installed skills,
session transcripts and scheduled tasks; it does not touch host bind mounts, the project record, or
host keychain secrets.
### Permission Modes
`PermissionMode` (`models/project.rs`) is a four-state enum replacing the earlier `full_permissions`
boolean. It reaches Claude Code by two different routes:
| Mode | `cli_args()` — interactive terminals | `as_env_value()` — scheduler |
|---|---|---|
| `Plan` | `--permission-mode plan` | `plan` |
| `Default` | *(no flag)* | `default` |
| `AcceptEdits` | `--permission-mode acceptEdits` | `acceptEdits` |
| `Bypass` | `--dangerously-skip-permissions` | `bypass` |
`Project.permission_mode` is `Option<PermissionMode>`, and `effective_permission_mode()` resolves
`None` from the legacy `full_permissions` flag, so records written before the change keep behaving
the same way.
**Interactive path.** `build_terminal_cmd()` evaluates `cli_args()` when a session is created, so
the flags are fixed for the life of that `claude` process. Changing the mode affects terminals
opened afterwards, not running ones. The same applies to `resume_session_command`, which builds
`claude <flags> --resume <id>` server-side.
**Scheduler path.** Cron jobs run with a minimal environment, so the mode travels as
`TRIPLE_C_PERMISSION_MODE` in the container's env; the entrypoint snapshots the allowlisted
variables into `~/.claude/scheduler/.env`, and `triple-c-task-runner` sources that file and maps the
value back to flags for its `claude -p` run. Container env can only change at create time, so
`container_needs_recreation()` compares a `triple-c.permission-mode` label and forces a recreation
on the next start. A mode change therefore reaches new terminals immediately but the scheduler only
after a stop/start. `TRIPLE_C_PERMISSION_MODE` is a reserved env key so it cannot be hand-set.
### Authentication Modes
Each project independently chooses one of two authentication methods:
Each project independently chooses one backend:
| Mode | How It Works | When to Use |
| Backend | How It Works | When to Use |
|------|-------------|-------------|
| **Anthropic (OAuth)** | User runs `claude login` or `/login` inside the terminal. OAuth URL opens in host browser via URL detection. Token persists in the `.claude` config volume. | Default — personal and team use |
| **AWS Bedrock** | Per-project AWS credentials (static keys, profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only. | Enterprise environments using Bedrock |
| **Anthropic** | Either the shared `CLAUDE_CODE_OAUTH_TOKEN` injected from the OS keychain, or a per-container `claude login` whose credential persists in the `.claude` config volume. The OAuth URL opens in the host browser via URL detection. | Default — personal and team use |
| **AWS Bedrock** | Per-project AWS credentials (static keys, named profile, or bearer token) injected as env vars. `~/.aws` config optionally bind-mounted read-only; SSO sessions are validated before launching Claude for profile auth. | Enterprise environments using Bedrock |
| **Ollama** | `ANTHROPIC_BASE_URL` points at an Ollama server; `ANTHROPIC_AUTH_TOKEN` is set to a placeholder. | Local models (best-effort) |
| **OpenAI Compatible** | `ANTHROPIC_BASE_URL` plus `ANTHROPIC_AUTH_TOKEN` point at any OpenAI-compatible endpoint (LiteLLM, OpenRouter, vLLM, …). | Gateways and proxies (best-effort) |
### Shared Claude Authentication Token
`commands/auth_token_commands.rs` runs `claude setup-token` on a PTY inside a running container.
Contrary to the loopback pattern most CLI logins use, `setup-token` redirects to an Anthropic-hosted
page and then blocks on a stdin paste prompt, so the flow needs a way to feed the pasted code back
in — hence `submit_claude_token_code`. The flow is single-flight (the token is global, so two
concurrent logins would race to overwrite each other's keychain entry) and times out after 15
minutes.
- **Storage** — the OS keychain, under a dedicated service name; the token is never returned to the
frontend, never written to a log, and no command accepts or returns it.
- **Redaction** — streamed output is stripped of ANSI sequences and passed through a stateful
redactor that masks anything matching `sk-ant-` with a plausible body, withholding any tail that
could still grow into a secret across a chunk boundary.
- **Injection**`CLAUDE_CODE_OAUTH_TOKEN` is set only when the backend is Anthropic, the project
has not opted out (`use_shared_auth_token`, default `true`), and a non-blank token is stored. When
those conditions do not hold, the variable is explicitly set to empty rather than omitted, so a
value baked into a snapshot image by `docker commit` is actively cleared.
- **Rotation** — a random UUID minted on each store is mirrored into the
`triple-c.claude-token-version` label. It is deliberately *not* a hash of the token: labels are
readable by anything that can run `docker inspect`, and a hash would be an offline verification
oracle. A label mismatch forces container recreation on the next start, which is when a container
picks up or loses the token.
### Auth Bridge
CLIs that log in through a browser (`claude login`, `aws sso login`, `fly login`) start an ephemeral
HTTP listener on an unpredictable loopback port and hand the provider a `http://localhost:<port>/…`
redirect. Run inside a container, that listener is unreachable from the host browser and nothing can
be pre-published at container-creation time. `auth_bridge/` bridges it at runtime:
- **Discovery** (`proc_net.rs`) — a `docker exec` reads `/proc/net/tcp` and `/proc/net/tcp6` every
two seconds. The image ships no `ss`, `netstat` or `lsof`. Only rows in state `0A` (`TCP_LISTEN`)
bound to loopback are kept; wildcard binds are ignored on purpose, since publishing those is the
port-mappings feature's job.
- **Family handling** — a `::1`-only listener genuinely cannot be reached over `127.0.0.1`, and Node
resolves `localhost` to IPv6 first on Linux, so `claude login` frequently binds `::1` alone. The
socat target follows the family actually observed; IPv4-mapped rows in `/proc/net/tcp6` are
treated as IPv4.
- **Host bind** (`tunnel.rs`) — the same port number is bound on the host: `127.0.0.1` is required,
`[::1]` is best-effort. **The host side binds loopback only, never a wildcard address**
everything behind it is an unauthenticated in-container service that bound loopback precisely
because it expected to be unreachable.
- **Transport** — each accepted connection is proxied by an attached exec running
`socat - TCP:127.0.0.1:<port>`, because container IPs are not routable from the host under Docker
Desktop. It goes through the same `create_attached_exec()` helper as terminal sessions, with
`tty: false` so socat's stderr is demultiplexed away from the proxied byte stream.
- **Policy** — ports appearing in the project's port mappings are skipped, and a host bind failure
is recorded as a conflict and retried later rather than fought over.
- **Lifecycle** — opt-in per project (`auth_bridge_enabled`, default `false`). It is purely
host-side, so it deliberately has no container-recreation label. The poller stops itself when the
project is gone, the flag is cleared, or the container is no longer running, and `stop()` awaits
it so host ports are provably released.
### Container Introspection
`list_container_capabilities` (`commands/inspect_commands.rs`) executes a read-only shell script in
a running container and returns counts and item lists for skills, agents, commands, hooks, plugins
and MCP servers, across user scope (`/home/claude/.claude`) and project scope
(`/workspace/*/.claude`, `/workspace/*/.mcp.json`). Everything is computed in-container with
`find`/`awk`/`jq`; only the JSON summary crosses the wire, and a stopped container yields zeros
rather than an error.
The script writes nothing. Claude Code owns this configuration and has its own tooling for it
(`/agents`, `/hooks`, `/plugins`, `/mcp`); Triple-C surfaces counts and opens a terminal rather than
rebuilding those editors as forms. `list_claude_sessions` and the scheduler commands
(`list_scheduled_tasks`, `get_scheduled_task_log`, `set_scheduled_task_enabled`,
`run_scheduled_task_now`, `remove_scheduled_task`, `clear_scheduler_notifications`) live in the same
module; the mutating ones shell out to `triple-c-scheduler` rather than editing its state files.
### Main-Area Tab Model
The frontend keeps a single ordered `tabOrder` array in the Zustand store holding two tab kinds,
`home:<projectId>` and `term:<sessionId>`, rendered by `components/layout/MainTabs.tsx`.
`activeSessionId` is *derived* from `activeTabKey`, so exactly one thing is current and a Project
Home tab and a terminal cannot both claim focus. Project configuration is a main-area view
(`components/projects/home/`), not a modal; the sidebar row is select-only.
### UID/GID Remapping
@@ -189,10 +327,12 @@ This avoids the common Docker problem where bind-mount permissions can't be chan
| Data | Storage | Location |
|------|---------|----------|
| Project configurations | JSON file (atomic writes) | `~/.local/share/triple-c/projects.json` |
| API keys | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service |
| API keys and per-project secrets | OS keychain | macOS Keychain / Windows Credential Manager / Linux Secret Service |
| Shared Claude token + rotation id | OS keychain | Separate service entries; never on disk, never in a label |
| App settings | Tauri plugin-store | App data directory |
| Claude config/tokens | Named Docker volume | `triple-c-claude-config-{projectId}` |
| Container filesystem | Docker container layer | Preserved across stop/start, cleared on reset |
| Claude config, sessions, scheduler state | Named Docker volume | `triple-c-claude-config-{projectId}` |
| Container home directory | Named Docker volume | `triple-c-home-{projectId}` |
| Container filesystem | Docker container layer, preserved into `triple-c-snapshot-{projectId}:latest` on recreation | Survives stop/start and recreation; destroyed by Reset |
The projects store uses **atomic writes** (write to `.json.tmp`, then `rename()`) to prevent data corruption if the app crashes mid-write. Corrupted files are backed up to `.json.bak` before being replaced.
@@ -214,92 +354,159 @@ The `TerminalView` component works around this with a **URL accumulator**:
triple-c/
├── README.md # Architecture overview
├── TECHNICAL.md # This document
├── HOW-TO-USE.md # User guide
├── HOW-TO-USE.md # User guide (also served by the in-app Help dialog)
├── BUILDING.md # Build instructions
├── CLAUDE.md # Claude Code instructions
├── DESIGN-REVIEW.md # UI/UX review notes
├── ROADMAP.md # Planned work
├── container/
├── container/ # Sandbox image
│ ├── Dockerfile # Ubuntu 24.04 + all dev tools + Claude Code
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, MCP injection
│ ├── entrypoint.sh # UID/GID remap, SSH setup, git config, settings injection,
│ │ # scheduler env snapshot + crontab rebuild
│ ├── osc52-clipboard # Clipboard shim (xclip/xsel/pbcopy via OSC 52)
│ ├── audio-shim # Audio capture shim (rec/arecord via FIFO)
│ ├── triple-c-scheduler # Bash-based cron task system
── triple-c-task-runner # Task execution runner for scheduler
── triple-c-task-runner # Cron entry point; permission mode → flags → `claude -p`
│ ├── triple-c-sso-refresh # AWS SSO session refresh helper
│ └── mission-control/ # Bundled Flight Control methodology (skills, docs, templates)
├── stt-container/ # Speech-to-text image
│ ├── Dockerfile # Faster Whisper (Python 3.11 + FastAPI)
│ └── server.py # POST /transcribe endpoint
├── .gitea/
│ └── workflows/
│ ├── build-app.yml # Build Tauri app (Linux/macOS/Windows)
│ ├── build-app-preview.yml # Preview builds
│ ├── build.yml # Build container image (multi-arch)
│ ├── build-stt.yml # Build the STT image
│ ├── sync-release.yml # Mirror releases to GitHub
── backfill-releases.yml # Bulk copy releases to GitHub
── backfill-releases.yml # Bulk copy releases to GitHub
│ └── cleanup-releases.yml # Prune old releases
└── app/ # Tauri v2 desktop application
├── package.json # React, xterm.js, zustand, tailwindcss
├── vite.config.ts # Vite bundler config
├── vitest.config.ts # Vitest (jsdom) config
├── index.html # HTML entry point
├── src/ # React frontend
│ ├── main.tsx # React DOM root
│ ├── App.tsx # Top-level layout
│ ├── index.css # CSS variables, dark theme, scrollbars
│ ├── App.tsx # Top-level layout + welcome screen
│ ├── index.css # CSS variables, dark theme, focus ring, scrollbars
│ ├── store/
│ │ └── appState.ts # Zustand store (projects, sessions, MCP, UI)
│ │ └── appState.ts # Zustand store (projects, sessions, tab strip, toasts)
│ ├── hooks/
│ │ ├── useClaudeAuth.ts # Shared token status + acquisition
│ │ ├── useContainerProgress.ts # container-progress events → inline progress
│ │ ├── useDocker.ts # Docker status, image build/pull
│ │ ├── useFileManager.ts # File manager operations
│ │ ├── useMcpServers.ts # MCP server CRUD
│ │ ├── useFileManager.ts # File browser operations
│ │ ├── useInstallHelper.ts # Guided Docker installation
│ │ ├── useKeyboardShortcuts.ts # Ctrl+T / Ctrl+Shift+W / Ctrl+Tab / Ctrl+1..9
│ │ ├── useProjectActions.ts # Start/stop/reset/backup, open terminals
│ │ ├── useProjects.ts # Project CRUD operations
│ │ ├── useSaveState.ts # Saved / Saving / Failed indicator state
│ │ ├── useSettings.ts # App settings
│ │ ├── useSTT.ts # Speech-to-text recording and container control
│ │ ├── useTerminal.ts # Terminal I/O, resize, session events
│ │ ├── useUpdates.ts # App update checking
│ │ └── useVoice.ts # Voice mode audio capture
│ ├── lib/
│ │ ├── types.ts # TypeScript interfaces matching Rust models
│ │ ├── tauri-commands.ts # Typed invoke() wrappers
│ │ ├── urlDetector.ts # Long-URL reassembly for OAuth flows
│ │ ├── wav.ts # WAV encoding for STT
│ │ └── constants.ts # App-wide constants
│ └── components/
│ ├── layout/ # Sidebar, TopBar, StatusBar
│ ├── mcp/ # McpPanel, McpServerCard
├── projects/ # ProjectCard, ProjectList, AddProjectDialog,
│ # FileManagerModal, ContainerProgressModal, modals
│ ├── DockerInstallDialog.tsx # First-run Docker setup
│ ├── layout/ # TopBar, MainTabs (the unified tab strip),
# Sidebar, StatusBar, HelpDialog
├── projects/
│ │ ├── home/ # Project Home — the main-area project view
│ │ │ ├── ProjectHome.tsx # Header, actions, overflow menu, tab strip
│ │ │ ├── OverviewTab.tsx # Permission mode, summary, recent activity
│ │ │ ├── SessionsTab.tsx # Past Claude sessions + Resume
│ │ │ ├── AutomationTab.tsx # Scheduler tasks + notifications
│ │ │ ├── ConfigTab.tsx # Config section host
│ │ │ ├── FilesTab.tsx # In-container file browser
│ │ │ ├── CapabilityTiles.tsx # Read-only capability counts
│ │ │ ├── format.ts # Age / size / uptime formatting
│ │ │ └── config/ # WorkspaceSection, ModelSection,
│ │ │ # AccessSection, RuntimeSection
│ │ ├── ProjectRow.tsx # Select-only sidebar row
│ │ ├── ProjectList.tsx # Sidebar project list
│ │ ├── AddProjectDialog.tsx # New-project dialog
│ │ ├── PermissionModeControl.tsx # Plan/Default/Accept Edits/Bypass
│ │ ├── ConfirmRemoveModal.tsx # Project removal confirmation
│ │ └── *Editor.tsx / *Modal.tsx # EnvVars, PortMappings,
│ │ # ClaudeInstructions, ClaudeCodeSettings —
│ │ # editors reused by Project Home
│ ├── settings/ # SettingsPanel, DockerSettings, AwsSettings,
│ │ # UpdateDialog
└── terminal/ # TerminalView (xterm.js), TerminalTabs, UrlToast
│ │ # OllamaSettings, OpenAiCompatibleSettings,
│ # SharedAuthSettings, ClaudeAuthModal,
│ │ # WebTerminalSettings, SttSettings,
│ │ # MicrophoneSettings, UpdateDialog, ImageUpdateDialog
│ ├── terminal/ # TerminalView (xterm.js), TerminalContextMenu,
│ │ # SttButton, UrlToast, trimSelection
│ └── ui/ # Shared primitives: Modal, Button, Toggle, Field,
│ # SegmentedControl, StatusIndicator, SaveIndicator,
│ # OverflowMenu, ToastHost, Tooltip, AccordionSection
└── src-tauri/ # Rust backend
├── Cargo.toml # Rust dependencies
├── tauri.conf.json # Tauri app configuration
├── build.rs # Tauri build script
├── capabilities/
│ └── default.json # Tauri v2 permission grants
│ └── default.json # Tauri v2 plugin permission grants
└── src/
├── lib.rs # App builder, plugin + command registration
├── main.rs # Entry point
├── logging.rs # Log configuration
├── commands/ # Tauri command handlers
│ ├── docker_commands.rs # Docker status, image ops
│ ├── file_commands.rs # File manager (list/download/upload)
│ ├── mcp_commands.rs # MCP server CRUD
│ ├── project_commands.rs # Start/stop/rebuild containers
│ ├── settings_commands.rs # Settings CRUD
│ ├── terminal_commands.rs # Terminal I/O, resize
── update_commands.rs # App update checking
│ ├── auth_bridge_commands.rs # Enable/status for the loopback bridge
│ ├── auth_token_commands.rs # claude setup-token flow, redaction, keychain
│ ├── aws_commands.rs # AWS profile/region discovery
│ ├── docker_commands.rs # Docker status, image ops
│ ├── file_commands.rs # File browser (list/download/upload)
│ ├── help_commands.rs # Serves HOW-TO-USE.md to the Help dialog
── inspect_commands.rs # Sessions, capabilities, scheduler tasks
│ ├── install_helper_commands.rs # Guided Docker installation
│ ├── project_commands.rs # Start/stop/rebuild/backup containers
│ ├── settings_commands.rs # Settings CRUD
│ ├── stt_commands.rs # STT start/stop/transcribe
│ ├── terminal_commands.rs # Terminal I/O, resize
│ ├── update_commands.rs # App update checking
│ └── web_terminal_commands.rs # Web terminal start/stop/status
├── auth_bridge/ # Host-side loopback callback bridge
│ ├── mod.rs # Per-project poller, status, lifecycle
│ ├── proc_net.rs # /proc/net/tcp{,6} parsing, loopback filtering
│ └── tunnel.rs # Host loopback bind + socat tunnel over the Docker API
├── web_terminal/ # Remote terminal access
│ ├── mod.rs # Module root
│ ├── server.rs # Axum HTTP+WS server lifecycle
│ ├── ws_handler.rs # WebSocket connection handler
│ └── terminal.html # Embedded xterm.js web UI
├── install_helper/ # Docker installation assistance
│ ├── mod.rs # Install orchestration
│ └── platform.rs # Per-OS install strategies
├── docker/ # Docker API layer
│ ├── client.rs # bollard singleton connection
│ ├── container.rs # Create, start, stop, remove, fingerprinting
├── exec.rs # PTY exec sessions with bidirectional streaming
│ ├── container.rs # Create/start/stop/remove, labels, recreation checks,
│ # remove_project_volumes, snapshot commit
│ ├── exec.rs # create_attached_exec() — the single attached-exec path
│ ├── image.rs # Build from Dockerfile, pull from registry
── network.rs # Per-project bridge networks for MCP
── stt.rs # Speech-to-text container lifecycle
│ └── legacy_cleanup.rs # Migration shim for the removed MCP feature
├── models/ # Data structures
│ ├── project.rs # Project, AuthMode, BedrockConfig
│ ├── mcp_server.rs # MCP server configuration
│ ├── app_settings.rs # Global settings (image source, AWS, etc.)
│ ├── project.rs # Project, Backend, PermissionMode, BedrockConfig, …
│ ├── app_settings.rs # Global settings (image source, AWS, STT, web terminal)
│ ├── container_config.rs # Image name resolution
│ └── update_info.rs # Update metadata
└── storage/ # Persistence
├── projects_store.rs # JSON file with atomic writes
├── mcp_store.rs # MCP server persistence
├── settings_store.rs # App settings (Tauri plugin-store)
└── secure.rs # OS keychain via keyring
└── secure.rs # OS keychain via keyring (secrets, shared token)
```
---
@@ -323,6 +530,16 @@ triple-c/
| `tar` | 0.4 | In-memory tar archives for Docker build context |
| `dirs` | 6.x | Cross-platform app data directory paths |
| `serde` / `serde_json` | 1.x | Serialization for IPC and persistence |
| `log` / `fern` | 0.4 / 0.7 | Date-based file logging |
| `include_dir` | 0.7 | Embeds the container build context in the binary |
| `reqwest` | 0.12 | HTTPS (rustls) for update checks, help content, STT uploads |
| `iana-time-zone` | 0.1 | Host timezone detection for container `TZ` |
| `sha2` | 0.10 | Settings fingerprints |
| `axum` | 0.8 | HTTP+WebSocket server for web terminal |
| `tower-http` | 0.6 | CORS middleware for web terminal |
| `base64` | 0.22 | Terminal data encoding over WebSocket |
| `rand` | 0.9 | Access token generation |
| `local-ip-address` | 0.6 | LAN IP detection for web terminal URL |
### JavaScript (Frontend)
@@ -340,6 +557,8 @@ triple-c/
| `zustand` | 5.x | Lightweight state management |
| `tailwindcss` | 4.x | Utility-first CSS framework |
| `vite` | 6.x | Frontend build tool and dev server |
| `vitest` | 4.x | Test runner (jsdom environment) |
| `@testing-library/react` | 16.x | Component tests |
### Container Image
+1
View File
@@ -0,0 +1 @@
0.3
+57 -57
View File
@@ -1,15 +1,15 @@
{
"name": "triple-c",
"version": "0.2.0",
"version": "0.3.0",
"lockfileVersion": 3,
"requires": true,
"packages": {
"": {
"name": "triple-c",
"version": "0.2.0",
"version": "0.3.0",
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-store": "^2",
"@xterm/addon-fit": "^0.10",
@@ -1757,9 +1757,9 @@
}
},
"node_modules/@tauri-apps/api": {
"version": "2.10.1",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.10.1.tgz",
"integrity": "sha512-hKL/jWf293UDSUN09rR69hrToyIXBb8CjGaWC7gfinvnQrBVvnLr08FeFi38gxtugAVyVcTa5/FD/Xnkb1siBw==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/api/-/api-2.11.0.tgz",
"integrity": "sha512-7CinYODhky9lmO23xHnUFv0Xt43fbtWMyxZcLcRBlFkcgXKuEirBvHpmtJ89YMhyeGcq20Wuc47Fa4XjyniywA==",
"license": "Apache-2.0 OR MIT",
"funding": {
"type": "opencollective",
@@ -1767,9 +1767,9 @@
}
},
"node_modules/@tauri-apps/cli": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.10.0.tgz",
"integrity": "sha512-ZwT0T+7bw4+DPCSWzmviwq5XbXlM0cNoleDKOYPFYqcZqeKY31KlpoMW/MOON/tOFBPgi31a2v3w9gliqwL2+Q==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli/-/cli-2.11.0.tgz",
"integrity": "sha512-W5Wbuqsb2pHFPTj4TaRNKTj5rwXhDShPiLSY9T18y4ouSR/NNCptAEFxFsBtyNRgL6Vs1a/q9LzfqqYzEwC+Jw==",
"dev": true,
"license": "Apache-2.0 OR MIT",
"bin": {
@@ -1783,23 +1783,23 @@
"url": "https://opencollective.com/tauri"
},
"optionalDependencies": {
"@tauri-apps/cli-darwin-arm64": "2.10.0",
"@tauri-apps/cli-darwin-x64": "2.10.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.10.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.10.0",
"@tauri-apps/cli-linux-arm64-musl": "2.10.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-gnu": "2.10.0",
"@tauri-apps/cli-linux-x64-musl": "2.10.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.10.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.10.0",
"@tauri-apps/cli-win32-x64-msvc": "2.10.0"
"@tauri-apps/cli-darwin-arm64": "2.11.0",
"@tauri-apps/cli-darwin-x64": "2.11.0",
"@tauri-apps/cli-linux-arm-gnueabihf": "2.11.0",
"@tauri-apps/cli-linux-arm64-gnu": "2.11.0",
"@tauri-apps/cli-linux-arm64-musl": "2.11.0",
"@tauri-apps/cli-linux-riscv64-gnu": "2.11.0",
"@tauri-apps/cli-linux-x64-gnu": "2.11.0",
"@tauri-apps/cli-linux-x64-musl": "2.11.0",
"@tauri-apps/cli-win32-arm64-msvc": "2.11.0",
"@tauri-apps/cli-win32-ia32-msvc": "2.11.0",
"@tauri-apps/cli-win32-x64-msvc": "2.11.0"
}
},
"node_modules/@tauri-apps/cli-darwin-arm64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.10.0.tgz",
"integrity": "sha512-avqHD4HRjrMamE/7R/kzJPcAJnZs0IIS+1nkDP5b+TNBn3py7N2aIo9LIpy+VQq0AkN8G5dDpZtOOBkmWt/zjA==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-arm64/-/cli-darwin-arm64-2.11.0.tgz",
"integrity": "sha512-UfMeDNlgIP252rm/KSTuu8yHatPua5TjtUEUf+jyIzVwBNcIl7Ywkdpfj+e5jVVg3EfCTp+4gwuL1dNpgF8clg==",
"cpu": [
"arm64"
],
@@ -1814,9 +1814,9 @@
}
},
"node_modules/@tauri-apps/cli-darwin-x64": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.10.0.tgz",
"integrity": "sha512-keDmlvJRStzVFjZTd0xYkBONLtgBC9eMTpmXnBXzsHuawV2q9PvDo2x6D5mhuoMVrJ9QWjgaPKBBCFks4dK71Q==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-darwin-x64/-/cli-darwin-x64-2.11.0.tgz",
"integrity": "sha512-lY1+aPlgyMN7vgjtCdQ3+WODfZkebAcxnrCrO0HjqDpKSXieDkrJbimqeaoM4RwhTSrCLRHfVYiYrfE5E131tg==",
"cpu": [
"x64"
],
@@ -1831,9 +1831,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm-gnueabihf": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.10.0.tgz",
"integrity": "sha512-e5u0VfLZsMAC9iHaOEANumgl6lfnJx0Dtjkd8IJpysZ8jp0tJ6wrIkto2OzQgzcYyRCKgX72aKE0PFgZputA8g==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm-gnueabihf/-/cli-linux-arm-gnueabihf-2.11.0.tgz",
"integrity": "sha512-5uCP0AusgN3NrKC8EpkuJwjek1k8pEffBdugJSpXPey/QGbPEb8vZ542n/giJ2mZPjMSllDkdhG2QIDpBY4PpQ==",
"cpu": [
"arm"
],
@@ -1848,9 +1848,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.10.0.tgz",
"integrity": "sha512-YrYYk2dfmBs5m+OIMCrb+JH/oo+4FtlpcrTCgiFYc7vcs6m3QDd1TTyWu0u01ewsCtK2kOdluhr/zKku+KP7HA==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-gnu/-/cli-linux-arm64-gnu-2.11.0.tgz",
"integrity": "sha512-loDPqtRHMSbIcrH2VBd4GgHoQlF7jJnrZj7MxA2lj1cixS/jEgMAPFqj83U6Wvjete4HfYplbE/gCpSFifA9jw==",
"cpu": [
"arm64"
],
@@ -1865,9 +1865,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-arm64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.10.0.tgz",
"integrity": "sha512-GUoPdVJmrJRIXFfW3Rkt+eGK9ygOdyISACZfC/bCSfOnGt8kNdQIQr5WRH9QUaTVFIwxMlQyV3m+yXYP+xhSVA==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-arm64-musl/-/cli-linux-arm64-musl-2.11.0.tgz",
"integrity": "sha512-DtSE8ZBlB9H+L+eHkfZ3myt00EVEyAB3e41juEHoE2qT88fgVlJvyrwa9SZYc/xTwCS9TnmK+R84tpg+ZsAg7Q==",
"cpu": [
"arm64"
],
@@ -1882,9 +1882,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-riscv64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.10.0.tgz",
"integrity": "sha512-JO7s3TlSxshwsoKNCDkyvsx5gw2QAs/Y2GbR5UE2d5kkU138ATKoPOtxn8G1fFT1aDW4LH0rYAAfBpGkDyJJnw==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-riscv64-gnu/-/cli-linux-riscv64-gnu-2.11.0.tgz",
"integrity": "sha512-5QdgS4LD+kntClI1aj2JmwjW38LosNXxwCe8viIHEwqYIWuMPdNEIau6/cLogI38Yzx9DnfCPRfEWLyI+5li8Q==",
"cpu": [
"riscv64"
],
@@ -1899,9 +1899,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-gnu": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.10.0.tgz",
"integrity": "sha512-Uvh4SUUp4A6DVRSMWjelww0GnZI3PlVy7VS+DRF5napKuIehVjGl9XD0uKoCoxwAQBLctvipyEK+pDXpJeoHng==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-gnu/-/cli-linux-x64-gnu-2.11.0.tgz",
"integrity": "sha512-5UynPXo3Zq9khjVdAbD+YogeLltdVUeOah2ioSIM3tu6H7wY9vMy6rgGJhv9r5R8ZXmk9GttMippdqYJWrnLnA==",
"cpu": [
"x64"
],
@@ -1916,9 +1916,9 @@
}
},
"node_modules/@tauri-apps/cli-linux-x64-musl": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.10.0.tgz",
"integrity": "sha512-AP0KRK6bJuTpQ8kMNWvhIpKUkQJfcPFeba7QshOQZjJ8wOS6emwTN4K5g/d3AbCMo0RRdnZWwu67MlmtJyxC1Q==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-linux-x64-musl/-/cli-linux-x64-musl-2.11.0.tgz",
"integrity": "sha512-CNz7fHbApz1Zyhhq73jtGn9JqgNEV/lIWnTnUo6h6ujw+mHsTmkLszvJSM8W6JBaDjNpTTFr/RSNoVL5FMwcTg==",
"cpu": [
"x64"
],
@@ -1933,9 +1933,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-arm64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.10.0.tgz",
"integrity": "sha512-97DXVU3dJystrq7W41IX+82JEorLNY+3+ECYxvXWqkq7DBN6FsA08x/EFGE8N/b0LTOui9X2dvpGGoeZKKV08g==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-arm64-msvc/-/cli-win32-arm64-msvc-2.11.0.tgz",
"integrity": "sha512-K+br+VXZ+Xx0n/9FdWohpW5Ugq+2FQUpJScqcPl1hTxXfh3fgjYgt4qA2NgrjlJo+zZPNrmUMl+NLvm0ufEqBQ==",
"cpu": [
"arm64"
],
@@ -1950,9 +1950,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-ia32-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.10.0.tgz",
"integrity": "sha512-EHyQ1iwrWy1CwMalEm9z2a6L5isQ121pe7FcA2xe4VWMJp+GHSDDGvbTv/OPdkt2Lyr7DAZBpZHM6nvlHXEc4A==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-ia32-msvc/-/cli-win32-ia32-msvc-2.11.0.tgz",
"integrity": "sha512-OFV+s3MLZnd75zl0ZAFU5riMpGK4waUEA8ZDuijDsnkU0btz/gHhqh5jVlOn8thyvgdtT3Xyoxqo099MMifH3g==",
"cpu": [
"ia32"
],
@@ -1967,9 +1967,9 @@
}
},
"node_modules/@tauri-apps/cli-win32-x64-msvc": {
"version": "2.10.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.10.0.tgz",
"integrity": "sha512-NTpyQxkpzGmU6ceWBTY2xRIEaS0ZLbVx1HE1zTA3TY/pV3+cPoPPOs+7YScr4IMzXMtOw7tLw5LEXo5oIG3qaQ==",
"version": "2.11.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/cli-win32-x64-msvc/-/cli-win32-x64-msvc-2.11.0.tgz",
"integrity": "sha512-AeDTWBd2cOZ6TX133BWsoo+LutG9o0JRcgjMsIfLE13ZugpgCMv/2dJbUiBGeRvbPOGin5A3aYmsArPVV6ZSHQ==",
"cpu": [
"x64"
],
@@ -1984,12 +1984,12 @@
}
},
"node_modules/@tauri-apps/plugin-dialog": {
"version": "2.6.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.6.0.tgz",
"integrity": "sha512-q4Uq3eY87TdcYzXACiYSPhmpBA76shgmQswGkSVio4C82Sz2W4iehe9TnKYwbq7weHiL88Yw19XZm7v28+Micg==",
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/@tauri-apps/plugin-dialog/-/plugin-dialog-2.7.0.tgz",
"integrity": "sha512-4nS/hfGMGCXiAS3LtVjH9AgsSAPJeG/7R+q8agTFqytjnMa4Zq95Bq8WzVDkckpanX+yyRHXnRtrKXkANKDHvw==",
"license": "MIT OR Apache-2.0",
"dependencies": {
"@tauri-apps/api": "^2.8.0"
"@tauri-apps/api": "^2.10.1"
}
},
"node_modules/@tauri-apps/plugin-opener": {
+2 -2
View File
@@ -1,7 +1,7 @@
{
"name": "triple-c",
"private": true,
"version": "0.2.0",
"version": "0.3.0",
"type": "module",
"scripts": {
"dev": "vite",
@@ -13,7 +13,7 @@
},
"dependencies": {
"@tauri-apps/api": "^2",
"@tauri-apps/plugin-dialog": "^2",
"@tauri-apps/plugin-dialog": "^2.7.0",
"@tauri-apps/plugin-opener": "^2.5.3",
"@tauri-apps/plugin-store": "^2",
"@xterm/addon-fit": "^0.10",
+664 -129
View File
File diff suppressed because it is too large Load Diff
+8 -2
View File
@@ -1,6 +1,6 @@
[package]
name = "triple-c"
version = "0.2.0"
version = "0.3.0"
edition = "2021"
[lib]
@@ -28,9 +28,15 @@ dirs = "6"
log = "0.4"
fern = { version = "0.7", features = ["date-based"] }
tar = "0.4"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls"] }
include_dir = "0.7"
reqwest = { version = "0.12", default-features = false, features = ["json", "rustls-tls", "multipart"] }
iana-time-zone = "0.1"
sha2 = "0.10"
axum = { version = "0.8", features = ["ws"] }
tower-http = { version = "0.6", features = ["cors"] }
base64 = "0.22"
rand = "0.9"
local-ip-address = "0.6"
[build-dependencies]
tauri-build = { version = "2", features = [] }
File diff suppressed because one or more lines are too long
+64 -16
View File
@@ -351,10 +351,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
"type": "string",
"const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
},
{
"description": "Enables the app_hide command without any pre-configured scope.",
@@ -428,6 +428,12 @@
"const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Enables the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-supports-multiple-windows",
"markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
},
{
"description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -512,6 +518,12 @@
"const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Denies the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-supports-multiple-windows",
"markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
},
{
"description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -1035,10 +1047,10 @@
"markdownDescription": "Denies the close command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`",
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
"type": "string",
"const": "core:tray:default",
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`"
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
},
{
"description": "Enables the get_by_id command without any pre-configured scope.",
@@ -1070,6 +1082,12 @@
"const": "core:tray:allow-set-icon-as-template",
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
},
{
"description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:allow-set-icon-with-as-template",
"markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
},
{
"description": "Enables the set_menu command without any pre-configured scope.",
"type": "string",
@@ -1136,6 +1154,12 @@
"const": "core:tray:deny-set-icon-as-template",
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
},
{
"description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:deny-set-icon-with-as-template",
"markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
},
{
"description": "Denies the set_menu command without any pre-configured scope.",
"type": "string",
@@ -1395,10 +1419,16 @@
"markdownDescription": "Denies the webview_size command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
"type": "string",
"const": "core:window:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
},
{
"description": "Enables the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-activity-name",
"markdownDescription": "Enables the activity_name command without any pre-configured scope."
},
{
"description": "Enables the available_monitors command without any pre-configured scope.",
@@ -1592,6 +1622,12 @@
"const": "core:window:allow-scale-factor",
"markdownDescription": "Enables the scale_factor command without any pre-configured scope."
},
{
"description": "Enables the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-scene-identifier",
"markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
},
{
"description": "Enables the set_always_on_bottom command without any pre-configured scope.",
"type": "string",
@@ -1856,6 +1892,12 @@
"const": "core:window:allow-unminimize",
"markdownDescription": "Enables the unminimize command without any pre-configured scope."
},
{
"description": "Denies the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-activity-name",
"markdownDescription": "Denies the activity_name command without any pre-configured scope."
},
{
"description": "Denies the available_monitors command without any pre-configured scope.",
"type": "string",
@@ -2048,6 +2090,12 @@
"const": "core:window:deny-scale-factor",
"markdownDescription": "Denies the scale_factor command without any pre-configured scope."
},
{
"description": "Denies the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-scene-identifier",
"markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
},
{
"description": "Denies the set_always_on_bottom command without any pre-configured scope.",
"type": "string",
@@ -2313,22 +2361,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2397,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
+64 -16
View File
@@ -351,10 +351,10 @@
"markdownDescription": "Default core plugins set.\n#### This default permission set includes:\n\n- `core:path:default`\n- `core:event:default`\n- `core:window:default`\n- `core:webview:default`\n- `core:app:default`\n- `core:image:default`\n- `core:resources:default`\n- `core:menu:default`\n- `core:tray:default`"
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`",
"type": "string",
"const": "core:app:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-version`\n- `allow-name`\n- `allow-tauri-version`\n- `allow-identifier`\n- `allow-bundle-type`\n- `allow-register-listener`\n- `allow-remove-listener`\n- `allow-supports-multiple-windows`"
},
{
"description": "Enables the app_hide command without any pre-configured scope.",
@@ -428,6 +428,12 @@
"const": "core:app:allow-set-dock-visibility",
"markdownDescription": "Enables the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Enables the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:allow-supports-multiple-windows",
"markdownDescription": "Enables the supports_multiple_windows command without any pre-configured scope."
},
{
"description": "Enables the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -512,6 +518,12 @@
"const": "core:app:deny-set-dock-visibility",
"markdownDescription": "Denies the set_dock_visibility command without any pre-configured scope."
},
{
"description": "Denies the supports_multiple_windows command without any pre-configured scope.",
"type": "string",
"const": "core:app:deny-supports-multiple-windows",
"markdownDescription": "Denies the supports_multiple_windows command without any pre-configured scope."
},
{
"description": "Denies the tauri_version command without any pre-configured scope.",
"type": "string",
@@ -1035,10 +1047,10 @@
"markdownDescription": "Denies the close command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`",
"description": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`",
"type": "string",
"const": "core:tray:default",
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-show-menu-on-left-click`"
"markdownDescription": "Default permissions for the plugin, which enables all commands.\n#### This default permission set includes:\n\n- `allow-new`\n- `allow-get-by-id`\n- `allow-remove-by-id`\n- `allow-set-icon`\n- `allow-set-menu`\n- `allow-set-tooltip`\n- `allow-set-title`\n- `allow-set-visible`\n- `allow-set-temp-dir-path`\n- `allow-set-icon-as-template`\n- `allow-set-icon-with-as-template`\n- `allow-set-show-menu-on-left-click`"
},
{
"description": "Enables the get_by_id command without any pre-configured scope.",
@@ -1070,6 +1082,12 @@
"const": "core:tray:allow-set-icon-as-template",
"markdownDescription": "Enables the set_icon_as_template command without any pre-configured scope."
},
{
"description": "Enables the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:allow-set-icon-with-as-template",
"markdownDescription": "Enables the set_icon_with_as_template command without any pre-configured scope."
},
{
"description": "Enables the set_menu command without any pre-configured scope.",
"type": "string",
@@ -1136,6 +1154,12 @@
"const": "core:tray:deny-set-icon-as-template",
"markdownDescription": "Denies the set_icon_as_template command without any pre-configured scope."
},
{
"description": "Denies the set_icon_with_as_template command without any pre-configured scope.",
"type": "string",
"const": "core:tray:deny-set-icon-with-as-template",
"markdownDescription": "Denies the set_icon_with_as_template command without any pre-configured scope."
},
{
"description": "Denies the set_menu command without any pre-configured scope.",
"type": "string",
@@ -1395,10 +1419,16 @@
"markdownDescription": "Denies the webview_size command without any pre-configured scope."
},
{
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`",
"description": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`",
"type": "string",
"const": "core:window:default",
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-internal-toggle-maximize`"
"markdownDescription": "Default permissions for the plugin.\n#### This default permission set includes:\n\n- `allow-get-all-windows`\n- `allow-scale-factor`\n- `allow-inner-position`\n- `allow-outer-position`\n- `allow-inner-size`\n- `allow-outer-size`\n- `allow-is-fullscreen`\n- `allow-is-minimized`\n- `allow-is-maximized`\n- `allow-is-focused`\n- `allow-is-decorated`\n- `allow-is-resizable`\n- `allow-is-maximizable`\n- `allow-is-minimizable`\n- `allow-is-closable`\n- `allow-is-visible`\n- `allow-is-enabled`\n- `allow-title`\n- `allow-current-monitor`\n- `allow-primary-monitor`\n- `allow-monitor-from-point`\n- `allow-available-monitors`\n- `allow-cursor-position`\n- `allow-theme`\n- `allow-is-always-on-top`\n- `allow-activity-name`\n- `allow-scene-identifier`\n- `allow-internal-toggle-maximize`"
},
{
"description": "Enables the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-activity-name",
"markdownDescription": "Enables the activity_name command without any pre-configured scope."
},
{
"description": "Enables the available_monitors command without any pre-configured scope.",
@@ -1592,6 +1622,12 @@
"const": "core:window:allow-scale-factor",
"markdownDescription": "Enables the scale_factor command without any pre-configured scope."
},
{
"description": "Enables the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:allow-scene-identifier",
"markdownDescription": "Enables the scene_identifier command without any pre-configured scope."
},
{
"description": "Enables the set_always_on_bottom command without any pre-configured scope.",
"type": "string",
@@ -1856,6 +1892,12 @@
"const": "core:window:allow-unminimize",
"markdownDescription": "Enables the unminimize command without any pre-configured scope."
},
{
"description": "Denies the activity_name command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-activity-name",
"markdownDescription": "Denies the activity_name command without any pre-configured scope."
},
{
"description": "Denies the available_monitors command without any pre-configured scope.",
"type": "string",
@@ -2048,6 +2090,12 @@
"const": "core:window:deny-scale-factor",
"markdownDescription": "Denies the scale_factor command without any pre-configured scope."
},
{
"description": "Denies the scene_identifier command without any pre-configured scope.",
"type": "string",
"const": "core:window:deny-scene-identifier",
"markdownDescription": "Denies the scene_identifier command without any pre-configured scope."
},
{
"description": "Denies the set_always_on_bottom command without any pre-configured scope.",
"type": "string",
@@ -2313,22 +2361,22 @@
"markdownDescription": "Denies the unminimize command without any pre-configured scope."
},
{
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"description": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`",
"type": "string",
"const": "dialog:default",
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-ask`\n- `allow-confirm`\n- `allow-message`\n- `allow-save`\n- `allow-open`"
"markdownDescription": "This permission set configures the types of dialogs\navailable from the dialog plugin.\n\n#### Granted Permissions\n\nAll dialog types are enabled.\n\n\n\n#### This default permission set includes:\n\n- `allow-message`\n- `allow-save`\n- `allow-open`"
},
{
"description": "Enables the ask command without any pre-configured scope.",
"description": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-ask",
"markdownDescription": "Enables the ask command without any pre-configured scope."
"markdownDescription": "Enables the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the confirm command without any pre-configured scope.",
"description": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)",
"type": "string",
"const": "dialog:allow-confirm",
"markdownDescription": "Enables the confirm command without any pre-configured scope."
"markdownDescription": "Enables the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `allow-message` and will be removed in v3)"
},
{
"description": "Enables the message command without any pre-configured scope.",
@@ -2349,16 +2397,16 @@
"markdownDescription": "Enables the save command without any pre-configured scope."
},
{
"description": "Denies the ask command without any pre-configured scope.",
"description": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-ask",
"markdownDescription": "Denies the ask command without any pre-configured scope."
"markdownDescription": "Denies the ask command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the confirm command without any pre-configured scope.",
"description": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)",
"type": "string",
"const": "dialog:deny-confirm",
"markdownDescription": "Denies the confirm command without any pre-configured scope."
"markdownDescription": "Denies the confirm command without any pre-configured scope. (**DEPRECATED**: This is now an alias to `deny-message` and will be removed in v3)"
},
{
"description": "Denies the message command without any pre-configured scope.",
+525
View File
@@ -0,0 +1,525 @@
//! Auth Bridge — lets browser-based OAuth logins run by CLIs *inside* a
//! container complete against the browser on the *host*.
//!
//! ## The problem
//!
//! `claude login`, Concourse's `fly login`, `aws sso login` and friends all use
//! the same pattern: start a throwaway HTTP listener on a random loopback port,
//! then open a browser at a provider URL whose redirect points back to
//! `http://localhost:<that port>/callback`. Run inside a container, the listener
//! is on the *container's* loopback, the browser is on the *host's*, and the
//! callback goes nowhere — the login just hangs. The ports are ephemeral and not
//! configurable, so nothing can be pre-published at container creation time.
//!
//! ## The mechanism
//!
//! While the bridge is enabled for a running project, poll the container every
//! [`POLL_INTERVAL`] for loopback TCP listeners (see [`proc_net`]). For each one
//! that appears, bind the *same* port on the host's loopback and proxy each
//! accepted connection into the container over `docker exec … socat` (see
//! [`tunnel`]). When the in-container listener goes away, drop the host
//! listener. The host and container therefore agree on the port number, which is
//! the whole trick: the redirect URL the provider was given resolves correctly
//! on both sides.
//!
//! ## Lifecycle and teardown
//!
//! One poller task per project. It is the only thing that owns
//! [`PortForward`]s, and it always tears them down on its way out, so every way
//! the bridge can end funnels through the same code:
//!
//! | Trigger | Path |
//! |---|---|
//! | Bridge disabled | `set_auth_bridge_enabled(false)` → [`AuthBridgeManager::stop`] |
//! | Container stopped via UI | `stop_project_container` → [`AuthBridgeManager::stop`] |
//! | Container stopped/died another way | poller's own `is_container_running` check → loop exits |
//! | Project deleted | `remove_project` → [`AuthBridgeManager::stop`]; also the poller's `store.get()` check |
//! | Container rebuilt | `rebuild_project_container` → stop, then start re-arms it |
//! | App exit | window `CloseRequested` → [`AuthBridgeManager::stop_all`] |
//!
//! [`AuthBridgeManager::stop`] awaits the poller, so host ports are provably
//! released before it returns. As a backstop for any path that skips all of the
//! above (a panicking poller, an aborted task), `PortForward`'s [`Drop`] aborts
//! the accept loop, which drops the socket.
pub mod proc_net;
pub mod tunnel;
use std::collections::{BTreeMap, HashMap, HashSet};
use std::sync::atomic::{AtomicU64, Ordering};
use std::sync::Arc;
use std::time::Duration;
use serde::Serialize;
use tauri::{AppHandle, Emitter};
use tokio::sync::{watch, Mutex};
use tokio::task::JoinHandle;
use crate::docker::container::is_container_running;
use crate::docker::exec::exec_oneshot;
use crate::storage::projects_store::ProjectsStore;
use proc_net::PortFamily;
use tunnel::PortForward;
/// How often the container is polled for new/vanished loopback listeners.
/// Short enough that a login redirect isn't left waiting, cheap enough to run
/// continuously (one `cat` of two procfs files per tick).
const POLL_INTERVAL: Duration = Duration::from_secs(2);
/// Emitted whenever the bridged-port set (or the conflict set) changes.
/// Payload: `{ project_id, status: AuthBridgeStatus }`.
const AUTH_BRIDGE_EVENT: &str = "auth-bridge-changed";
// ─────────────────────────────────────────────────────────────────────────────
// IPC response models
// ─────────────────────────────────────────────────────────────────────────────
/// A port currently bound on the host loopback and forwarded into the container.
#[derive(Debug, Clone, Serialize)]
pub struct BridgedPort {
pub port: u16,
pub family: PortFamily,
/// RFC 3339 timestamp of when the host listener was bound.
pub bridged_at: String,
}
/// A loopback listener that was discovered but could not be bridged.
#[derive(Debug, Clone, Serialize)]
pub struct PortConflict {
pub port: u16,
pub reason: String,
}
#[derive(Debug, Clone, Serialize)]
pub struct AuthBridgeStatus {
pub enabled: bool,
pub active_ports: Vec<BridgedPort>,
pub conflicts: Vec<PortConflict>,
}
impl AuthBridgeStatus {
fn disabled() -> Self {
Self {
enabled: false,
active_ports: Vec::new(),
conflicts: Vec::new(),
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Manager
// ─────────────────────────────────────────────────────────────────────────────
/// Everything the poller owns for one project. Live ports and conflicts sit
/// behind an `Arc<Mutex<…>>` so `get_auth_bridge_status` can read them without
/// disturbing the poller.
#[derive(Default)]
struct BridgeState {
forwards: BTreeMap<u16, PortForward>,
conflicts: BTreeMap<u16, String>,
}
impl BridgeState {
fn snapshot(&self, enabled: bool) -> AuthBridgeStatus {
AuthBridgeStatus {
enabled,
active_ports: self
.forwards
.values()
.map(|f| BridgedPort {
port: f.port,
family: f.family,
bridged_at: f.bridged_at.clone(),
})
.collect(),
conflicts: self
.conflicts
.iter()
.map(|(port, reason)| PortConflict {
port: *port,
reason: reason.clone(),
})
.collect(),
}
}
}
struct ProjectBridge {
/// Distinguishes this poller from a later one for the same project, so a
/// poller that exits late can't remove its replacement's map entry.
epoch: u64,
cancel: watch::Sender<bool>,
state: Arc<Mutex<BridgeState>>,
poller: JoinHandle<()>,
}
type BridgeMap = Arc<Mutex<HashMap<String, ProjectBridge>>>;
#[derive(Default)]
pub struct AuthBridgeManager {
bridges: BridgeMap,
next_epoch: AtomicU64,
}
impl AuthBridgeManager {
pub fn new() -> Self {
Self::default()
}
/// Start polling for `project_id`. Idempotent: a call while a live poller
/// already exists for the project is a no-op.
pub async fn start(
&self,
project_id: String,
container_id: String,
app: AppHandle,
store: Arc<ProjectsStore>,
) {
let mut map = self.bridges.lock().await;
// A finished poller has already torn its ports down, so its entry is
// just a husk and can be replaced. A live one means we're already on.
if map
.get(&project_id)
.is_some_and(|b| !b.poller.is_finished())
{
return;
}
let epoch = self.next_epoch.fetch_add(1, Ordering::Relaxed);
let state = Arc::new(Mutex::new(BridgeState::default()));
let (cancel_tx, cancel_rx) = watch::channel(false);
log::info!(
"Auth bridge: starting for project {} (container {})",
project_id,
&container_id[..container_id.len().min(12)]
);
let poller = tokio::spawn(poll_loop(
project_id.clone(),
container_id,
epoch,
app,
store,
state.clone(),
self.bridges.clone(),
cancel_rx,
));
map.insert(
project_id,
ProjectBridge {
epoch,
cancel: cancel_tx,
state,
poller,
},
);
}
/// Stop the bridge for one project and wait until every host port it held
/// has been released.
pub async fn stop(&self, project_id: &str) {
// Remove under the lock, then release it before awaiting: the poller
// takes the same lock to deregister itself on exit.
let bridge = self.bridges.lock().await.remove(project_id);
if let Some(bridge) = bridge {
let _ = bridge.cancel.send(true);
let _ = bridge.poller.await;
log::info!("Auth bridge: stopped for project {}", project_id);
}
}
/// Stop every bridge. Used on app exit.
pub async fn stop_all(&self) {
let bridges: Vec<(String, ProjectBridge)> =
self.bridges.lock().await.drain().collect();
for (project_id, bridge) in bridges {
let _ = bridge.cancel.send(true);
let _ = bridge.poller.await;
log::info!("Auth bridge: stopped for project {}", project_id);
}
}
/// Current status. `enabled` comes from the persisted project record, so a
/// project whose bridge is on but whose container is stopped still reports
/// `enabled: true` with no active ports.
pub async fn status(&self, project_id: &str, enabled: bool) -> AuthBridgeStatus {
let map = self.bridges.lock().await;
match map.get(project_id) {
Some(bridge) => bridge.state.lock().await.snapshot(enabled),
None => AuthBridgeStatus {
enabled,
..AuthBridgeStatus::disabled()
},
}
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Poller
// ─────────────────────────────────────────────────────────────────────────────
#[allow(clippy::too_many_arguments)]
async fn poll_loop(
project_id: String,
container_id: String,
epoch: u64,
app: AppHandle,
store: Arc<ProjectsStore>,
state: Arc<Mutex<BridgeState>>,
bridges: BridgeMap,
mut cancel: watch::Receiver<bool>,
) {
let mut exec_failures: u32 = 0;
loop {
// Stop conditions checked every tick, so the bridge winds itself down
// even when nothing calls `stop()` (container died, project deleted
// out from under us, flag flipped off by another path).
let project = match store.get(&project_id) {
Some(p) => p,
None => {
log::info!("Auth bridge: project {} is gone — tearing down", project_id);
break;
}
};
if !project.auth_bridge_enabled {
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
break;
}
if !is_container_running(&container_id).await.unwrap_or(false) {
log::info!(
"Auth bridge: container for project {} is no longer running — tearing down",
project_id
);
break;
}
// One exec per tick reads both procfs files.
let cmd = vec![
"cat".to_string(),
"/proc/net/tcp".to_string(),
"/proc/net/tcp6".to_string(),
];
// Cancellation races the exec, not just the sleep, so disabling the
// bridge or stopping the container doesn't wait out an in-flight poll.
let discovery = tokio::select! {
_ = cancel.changed() => break,
res = exec_oneshot(&container_id, cmd) => res,
};
match discovery {
Ok(text) => {
exec_failures = 0;
let discovered = proc_net::parse_loopback_listeners(&text);
let skip = skipped_ports(&project);
if reconcile(&container_id, &discovered, &skip, &state).await {
emit_status(&app, &project_id, &state, true).await;
}
}
Err(e) => {
exec_failures += 1;
// Transient failures happen (container restarting, engine busy);
// only complain once per streak.
if exec_failures == 1 {
log::warn!(
"Auth bridge: failed to read /proc/net/tcp in container for project {}: {}",
project_id,
e
);
}
}
}
tokio::select! {
_ = cancel.changed() => break,
_ = tokio::time::sleep(POLL_INTERVAL) => {}
}
}
teardown(&project_id, &state).await;
emit_status(
&app,
&project_id,
&state,
store
.get(&project_id)
.is_some_and(|p| p.auth_bridge_enabled),
)
.await;
// Deregister, unless a newer poller has already taken this project's slot.
let mut map = bridges.lock().await;
if map.get(&project_id).is_some_and(|b| b.epoch == epoch) {
map.remove(&project_id);
}
}
/// Ports Docker already handles for this project. A container port that is
/// explicitly published has a host-side path already, and the mapping's host
/// port is a binding we must not fight over.
fn skipped_ports(project: &crate::models::Project) -> HashSet<u16> {
project
.port_mappings
.iter()
.flat_map(|m| [m.container_port, m.host_port])
.collect()
}
/// Bring the set of host listeners in line with what the container is currently
/// listening on. Returns whether anything the UI cares about changed.
async fn reconcile(
container_id: &str,
discovered: &BTreeMap<u16, PortFamily>,
skip: &HashSet<u16>,
state: &Arc<Mutex<BridgeState>>,
) -> bool {
let mut changed = false;
let mut st = state.lock().await;
// Drop host listeners whose container-side counterpart vanished, became
// covered by an explicit port mapping, or changed address family (a family
// change alters the socat target, so it has to be rebound below).
let stale: Vec<u16> = st
.forwards
.iter()
.filter(|(port, forward)| match discovered.get(port) {
None => true,
Some(_) if skip.contains(port) => true,
Some(family) => *family != forward.family,
})
.map(|(port, _)| *port)
.collect();
for port in stale {
if let Some(mut forward) = st.forwards.remove(&port) {
forward.shutdown().await;
log::info!("Auth bridge: released host port {}", port);
changed = true;
}
}
// Forget conflicts for ports that are no longer relevant.
let before = st.conflicts.len();
st.conflicts
.retain(|port, _| discovered.contains_key(port) && !skip.contains(port));
changed |= st.conflicts.len() != before;
for (&port, &family) in discovered {
if skip.contains(&port) || st.forwards.contains_key(&port) {
continue;
}
match PortForward::bind(container_id.to_string(), port, family).await {
Ok(forward) => {
if st.conflicts.remove(&port).is_some() {
log::info!("Auth bridge: host port {} became available", port);
}
log::info!(
"Auth bridge: bridging 127.0.0.1:{} → container {} ({:?})",
port,
family.socat_target(port),
family
);
st.forwards.insert(port, forward);
changed = true;
}
Err(e) => {
// Conflict policy: never fight for a port. Something else on the
// host owns it — another project's bridge, or an unrelated
// process. Skip it, record why so the UI can say so, and retry
// on later ticks in case the owner releases it. Warn only on
// the transition so a long-lived conflict doesn't spam the log.
let reason = format!(
"Host port {} is already in use ({}); not bridged.",
port, e
);
if st.conflicts.get(&port) != Some(&reason) {
log::warn!("Auth bridge: {}", reason);
st.conflicts.insert(port, reason);
changed = true;
}
}
}
}
changed
}
/// Release every host port held for this project. Awaits each shutdown, so on
/// return nothing is bound.
async fn teardown(project_id: &str, state: &Arc<Mutex<BridgeState>>) {
let mut st = state.lock().await;
let forwards = std::mem::take(&mut st.forwards);
st.conflicts.clear();
let count = forwards.len();
for (_, mut forward) in forwards {
forward.shutdown().await;
}
if count > 0 {
log::info!(
"Auth bridge: released {} host port(s) for project {}",
count,
project_id
);
}
}
async fn emit_status(
app: &AppHandle,
project_id: &str,
state: &Arc<Mutex<BridgeState>>,
enabled: bool,
) {
let status = state.lock().await.snapshot(enabled);
let _ = app.emit(
AUTH_BRIDGE_EVENT,
serde_json::json!({
"project_id": project_id,
"status": status,
}),
);
}
#[cfg(test)]
mod tests {
use super::*;
use crate::models::{PortMapping, Project, ProjectPath};
fn project_with_mappings(mappings: Vec<(u16, u16)>) -> Project {
let mut p = Project::new(
"test".to_string(),
vec![ProjectPath {
host_path: "/tmp".to_string(),
mount_name: "tmp".to_string(),
}],
);
p.port_mappings = mappings
.into_iter()
.map(|(host_port, container_port)| PortMapping {
host_port,
container_port,
protocol: "tcp".to_string(),
})
.collect();
p
}
#[test]
fn ports_already_published_by_docker_are_skipped() {
let skip = skipped_ports(&project_with_mappings(vec![(3000, 3000), (8081, 8080)]));
assert!(skip.contains(&3000));
// Both ends of an asymmetric mapping are off limits: the container port
// is already reachable, and the host port is Docker's binding.
assert!(skip.contains(&8080));
assert!(skip.contains(&8081));
assert!(!skip.contains(&34567));
}
#[test]
fn no_mappings_means_nothing_is_skipped() {
assert!(skipped_ports(&project_with_mappings(vec![])).is_empty());
}
}
+302
View File
@@ -0,0 +1,302 @@
//! Discovery of loopback TCP listeners by parsing `/proc/net/tcp` and
//! `/proc/net/tcp6` from inside the container.
//!
//! ## Why /proc and not `ss`
//!
//! The container image (`container/Dockerfile`) ships neither `iproute2` (`ss`)
//! nor `net-tools` (`netstat`) nor `lsof`. `/proc/net/tcp{,6}` is part of procfs
//! and needs no package at all, so discovery works in the stock image and in any
//! snapshot derived from it.
//!
//! ## Wire format
//!
//! Both files are fixed-column text with a header line:
//!
//! ```text
//! sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode
//! 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 ...
//! ```
//!
//! Only two columns matter: `local_address` (index 1) and `st` (index 3).
//! `st == 0A` is `TCP_LISTEN`; every other state is a connection, not a listener.
//!
//! ## Hex and endianness
//!
//! `local_address` is `<address>:<port>`, both hex, but they are *not* encoded
//! the same way:
//!
//! * The **port** is a plain big-endian `%04X` — `8707` is 34567.
//! * The **address** is printed as one `%08X` per 32-bit word *in host byte
//! order*, which is little-endian on every platform this app targets. So each
//! 8-hex-digit group must be parsed as a `u32` and then expanded with
//! [`u32::to_le_bytes`] to recover the address bytes in network order:
//! `0100007F` → `0x0100007F` → `[7F, 00, 00, 01]` → `127.0.0.1`.
//!
//! IPv4 rows have one such group (8 hex digits); IPv6 rows have four (32 hex
//! digits), each converted independently, in order, to fill the 16 address
//! bytes. `::1` is therefore `00000000000000000000000001000000`, and the
//! IPv4-mapped `::ffff:127.0.0.1` is `0000000000000000FFFF00000100007F`.
//!
//! ## What counts as loopback
//!
//! Only `127.0.0.0/8` and `::1` (plus IPv4-mapped loopback, reported as v4).
//! A `0.0.0.0` or `::` listener is a service deliberately published to the
//! outside world — that is the port-mappings feature's job, not the auth
//! bridge's — so those rows are dropped.
use std::collections::BTreeMap;
use std::net::{Ipv4Addr, Ipv6Addr};
use serde::{Deserialize, Serialize};
/// The `st` column value for `TCP_LISTEN`.
const TCP_LISTEN: &str = "0A";
/// Which loopback address family (or families) a container-side listener was
/// found on. Determines the `socat` target address used to reach it.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum PortFamily {
/// Only `127.0.0.0/8`.
V4,
/// Only `::1`. Common in practice: Node resolves `localhost` to IPv6 first
/// on Linux, so `claude login` frequently binds `::1` and nothing else
/// (anthropics/claude-code#44844).
V6,
/// Both — reachable either way; we use IPv4.
Dual,
}
impl PortFamily {
fn merge(self, other: PortFamily) -> PortFamily {
if self == other {
self
} else {
PortFamily::Dual
}
}
/// The `socat` address that reaches this listener from inside the container.
/// A `::1`-only listener genuinely cannot be reached via `127.0.0.1`
/// (verified: connect gets ECONNREFUSED), hence the split.
pub fn socat_target(&self, port: u16) -> String {
match self {
PortFamily::V4 | PortFamily::Dual => format!("TCP:127.0.0.1:{}", port),
PortFamily::V6 => format!("TCP6:[::1]:{}", port),
}
}
}
/// One parsed LISTEN row that survived the loopback filter.
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord)]
pub struct LoopbackListener {
pub port: u16,
pub family: PortFamily,
}
/// Parse the concatenated contents of `/proc/net/tcp` and `/proc/net/tcp6` into
/// the set of loopback ports being listened on, keyed by port with the families
/// merged (a port bound on both `127.0.0.1` and `::1` yields
/// [`PortFamily::Dual`]).
///
/// Unparseable lines — the two header lines, `cat`'s "No such file" complaint
/// when IPv6 is disabled, anything else that ends up interleaved in the exec's
/// combined output — are silently ignored rather than failing the whole poll.
pub fn parse_loopback_listeners(text: &str) -> BTreeMap<u16, PortFamily> {
let mut ports: BTreeMap<u16, PortFamily> = BTreeMap::new();
for listener in parse_listener_rows(text) {
ports
.entry(listener.port)
.and_modify(|f| *f = f.merge(listener.family))
.or_insert(listener.family);
}
ports
}
/// Row-level parse, before per-port family merging. Split out so tests can
/// assert on the individual rows.
pub fn parse_listener_rows(text: &str) -> Vec<LoopbackListener> {
text.lines().filter_map(parse_listener_row).collect()
}
fn parse_listener_row(line: &str) -> Option<LoopbackListener> {
let mut fields = line.split_whitespace();
let _sl = fields.next()?;
let local_address = fields.next()?;
let _rem_address = fields.next()?;
let state = fields.next()?;
if state != TCP_LISTEN {
return None;
}
let (addr_hex, port_hex) = local_address.split_once(':')?;
// The port is a straightforward big-endian hex u16 — no byte swapping.
let port = u16::from_str_radix(port_hex, 16).ok()?;
if port == 0 {
return None;
}
let family = match addr_hex.len() {
8 => {
let addr = Ipv4Addr::from(parse_le_word(addr_hex)?);
addr.is_loopback().then_some(PortFamily::V4)
}
32 => {
let mut octets = [0u8; 16];
for (i, group) in addr_hex.as_bytes().chunks(8).enumerate() {
let group = std::str::from_utf8(group).ok()?;
octets[i * 4..i * 4 + 4].copy_from_slice(&parse_le_word(group)?);
}
let addr = Ipv6Addr::from(octets);
// An IPv4-mapped row describes a v4 socket, so it is reachable at
// 127.0.0.1 and must be classified as v4, not v6.
match addr.to_ipv4_mapped() {
Some(v4) => v4.is_loopback().then_some(PortFamily::V4),
None => addr.is_loopback().then_some(PortFamily::V6),
}
}
_ => None,
}?;
Some(LoopbackListener { port, family })
}
/// Parse one `%08X` procfs address word into its four address bytes in network
/// order. The kernel prints the word in host byte order, so the recovered bytes
/// are the little-endian expansion of the parsed integer.
fn parse_le_word(hex: &str) -> Option<[u8; 4]> {
Some(u32::from_str_radix(hex, 16).ok()?.to_le_bytes())
}
#[cfg(test)]
mod tests {
use super::*;
/// Verbatim `cat /proc/net/tcp` from a running `triple-c:latest` container
/// with three listeners deliberately started:
/// * `socat TCP4-LISTEN:34567,bind=127.0.0.1` → row 0 (`0100007F:8707`)
/// * `socat TCP4-LISTEN:34569,bind=0.0.0.0` → row 1 (`00000000:8709`)
/// * `node ... .listen(34568, "::1")` → appears in TCP6 only
const REAL_PROC_NET_TCP: &str = concat!(
" sl local_address rem_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode \n",
" 0: 0100007F:8707 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27764798 1 0000000000000000 100 0 0 10 0 \n",
" 1: 00000000:8709 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27758875 1 0000000000000000 100 0 0 10 0 \n",
);
/// Verbatim `cat /proc/net/tcp6` from the same container. The single row is
/// the Node listener bound to `::1` only — the case that motivates the
/// TCP6 socat target.
const REAL_PROC_NET_TCP6: &str = concat!(
" sl local_address remote_address st tx_queue rx_queue tr tm->when retrnsmt uid timeout inode\n",
" 0: 00000000000000000000000001000000:8708 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 27747129 1 0000000000000000 100 0 0 10 0\n",
);
fn both_files() -> String {
format!("{}{}", REAL_PROC_NET_TCP, REAL_PROC_NET_TCP6)
}
#[test]
fn parses_ipv4_loopback_row_with_little_endian_address() {
let rows = parse_listener_rows(REAL_PROC_NET_TCP);
// 0100007F → 127.0.0.1 (kept), 00000000 → 0.0.0.0 (dropped).
assert_eq!(
rows,
vec![LoopbackListener {
port: 0x8707,
family: PortFamily::V4
}]
);
assert_eq!(rows[0].port, 34567);
}
#[test]
fn parses_ipv6_loopback_row() {
let rows = parse_listener_rows(REAL_PROC_NET_TCP6);
assert_eq!(
rows,
vec![LoopbackListener {
port: 34568,
family: PortFamily::V6
}]
);
}
#[test]
fn ignores_wildcard_bind_addresses() {
// 0.0.0.0:34569 is in the fixture and must never be bridged — that is
// the port-mappings feature's territory.
let ports = parse_loopback_listeners(&both_files());
assert!(!ports.contains_key(&34569));
// Same for the IPv6 wildcard and a non-loopback unicast address.
let wildcard_v6 = " 0: 00000000000000000000000000000000:1F90 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
let lan_v4 = " 0: 0245A8C0:1F90 00000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
assert!(parse_listener_rows(wildcard_v6).is_empty());
assert!(parse_listener_rows(lan_v4).is_empty());
}
#[test]
fn parses_both_files_concatenated_as_one_exec_output() {
let ports = parse_loopback_listeners(&both_files());
assert_eq!(ports.len(), 2);
assert_eq!(ports.get(&34567), Some(&PortFamily::V4));
assert_eq!(ports.get(&34568), Some(&PortFamily::V6));
}
#[test]
fn merges_families_for_a_dual_stack_port() {
let dual = format!(
"{} 1: 00000000000000000000000001000000:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 2 1 0 100 0 0 10 0\n",
both_files()
);
let ports = parse_loopback_listeners(&dual);
assert_eq!(ports.get(&34567), Some(&PortFamily::Dual));
}
#[test]
fn ipv4_mapped_loopback_is_reported_as_v4() {
// ::ffff:127.0.0.1 — a v4 socket surfacing in /proc/net/tcp6.
let row = " 0: 0000000000000000FFFF00000100007F:8707 00000000000000000000000000000000:0000 0A 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
assert_eq!(
parse_listener_rows(row),
vec![LoopbackListener {
port: 34567,
family: PortFamily::V4
}]
);
}
#[test]
fn ignores_non_listen_states() {
// Same loopback address, state 01 (ESTABLISHED) instead of 0A.
let established = " 0: 0100007F:8707 0100007F:C350 01 00000000:00000000 00:00000000 00000000 0 0 1 1 0 100 0 0 10 0";
assert!(parse_listener_rows(established).is_empty());
}
#[test]
fn ignores_headers_and_garbage() {
assert!(parse_listener_rows("").is_empty());
assert!(parse_listener_rows(
"cat: /proc/net/tcp6: No such file or directory\n\n sl local_address rem_address st\n"
)
.is_empty());
// Truncated / malformed rows must not panic or be accepted.
assert!(parse_listener_rows(" 0: 0100007F 00000000:0000 0A").is_empty());
assert!(parse_listener_rows(" 0: ZZZZZZZZ:8707 00000000:0000 0A x").is_empty());
assert!(parse_listener_rows(" 0: 0100007F:0000 00000000:0000 0A x").is_empty());
}
#[test]
fn socat_target_matches_family() {
assert_eq!(
PortFamily::V4.socat_target(34567),
"TCP:127.0.0.1:34567"
);
assert_eq!(
PortFamily::Dual.socat_target(34567),
"TCP:127.0.0.1:34567"
);
assert_eq!(PortFamily::V6.socat_target(34568), "TCP6:[::1]:34568");
}
}
+245
View File
@@ -0,0 +1,245 @@
//! Host-side loopback listener for one bridged port, and the per-connection
//! tunnel that carries its bytes into the container.
//!
//! ## Why not connect to the container's IP
//!
//! Container IPs are not routable from the host on Docker Desktop (macOS and
//! Windows run the engine in a VM), so a host→`172.17.x.x` dial cannot be the
//! transport. The Docker API is the only channel guaranteed to reach the
//! container from the host, so each accepted connection is carried by a
//! `docker exec` running `socat - TCP:127.0.0.1:<port>`, with the exec's stdin
//! and stdout wired to the TCP socket. `socat` ships in the container image.
//!
//! The exec plumbing itself is *not* reimplemented here: it comes from
//! [`crate::docker::exec::create_attached_exec`], the same helper the
//! interactive terminal sessions are built on.
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
use bollard::container::LogOutput;
use futures_util::StreamExt;
use tokio::io::{AsyncReadExt, AsyncWriteExt};
use tokio::net::{TcpListener, TcpStream};
use tokio::task::{JoinHandle, JoinSet};
use crate::docker::exec::{create_attached_exec, AttachedExec};
use super::proc_net::PortFamily;
/// Buffer size for the host→container direction. OAuth callbacks are tiny; this
/// only needs to not be pathological.
const PUMP_BUF: usize = 16 * 1024;
/// Aborts a task when dropped, so a cancelled parent can never leave a detached
/// child running.
struct AbortOnDrop(JoinHandle<()>);
impl Drop for AbortOnDrop {
fn drop(&mut self) {
self.0.abort();
}
}
/// One host loopback port bound and proxied into the container.
///
/// The accept loop owns the [`TcpListener`](tokio::net::TcpListener)s and the
/// [`JoinSet`] of live connection tasks, so aborting the single task handle
/// releases the port *and* tears down every connection under it. [`Drop`] does
/// that as a backstop; [`PortForward::shutdown`] does it deterministically by
/// also awaiting the aborted task, which guarantees the socket is closed before
/// the caller proceeds (important when a port is rebound right after).
pub struct PortForward {
pub port: u16,
pub family: PortFamily,
pub bridged_at: String,
task: JoinHandle<()>,
}
impl Drop for PortForward {
fn drop(&mut self) {
self.task.abort();
}
}
impl PortForward {
/// Bind `port` on the host loopback and start proxying into `container_id`.
///
/// The bind happens before the task is spawned, so an already-taken port is
/// reported to the caller as an error rather than disappearing into a
/// background task.
pub async fn bind(
container_id: String,
port: u16,
family: PortFamily,
) -> Result<Self, std::io::Error> {
// SECURITY BOUNDARY: the host side binds loopback ONLY — 127.0.0.1 and
// ::1, never 0.0.0.0 / ::. Everything reachable through this socket is
// an unauthenticated service inside the container that deliberately
// bound loopback because it expected to be reachable from nowhere else.
// Binding a wildcard address here would publish container internals to
// every host on the LAN. Do not "fix" a connectivity problem by
// widening these addresses.
let v4 = TcpListener::bind(SocketAddr::from((Ipv4Addr::LOCALHOST, port))).await?;
// Also take ::1 when it is available. Browsers and CLIs resolve
// `localhost` to either family, and the IPv6 answer is often tried
// first, so a v4-only host listener would miss those callbacks. This is
// best-effort: if ::1 is unavailable (no IPv6, or that half is taken)
// the v4 listener alone still works, so it is not treated as a conflict.
let v6 = match TcpListener::bind(SocketAddr::from((Ipv6Addr::LOCALHOST, port))).await {
Ok(l) => Some(l),
Err(e) => {
log::debug!(
"Auth bridge: bound 127.0.0.1:{} but not [::1]:{} ({}) — continuing with IPv4 only",
port,
port,
e
);
None
}
};
let target = family.socat_target(port);
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
Ok(Self {
port,
family,
bridged_at: chrono::Utc::now().to_rfc3339(),
task,
})
}
/// Stop accepting, drop the host socket, and abort every in-flight
/// connection. Awaits the aborted task so the port is provably released
/// when this returns.
pub async fn shutdown(&mut self) {
self.task.abort();
let _ = (&mut self.task).await;
}
}
/// Accept on both loopback listeners until aborted. Dropping this future drops
/// the listeners (freeing the port) and the `JoinSet` (aborting live tunnels).
async fn accept_loop(
container_id: String,
port: u16,
target: String,
v4: TcpListener,
v6: Option<TcpListener>,
) {
let mut conns: JoinSet<()> = JoinSet::new();
loop {
let accepted = tokio::select! {
r = v4.accept() => r,
r = accept_optional(v6.as_ref()) => r,
// Reap finished tunnels so the JoinSet doesn't grow without bound.
// When the set is empty `join_next()` yields None, the pattern fails
// to match, and the branch simply drops out of the select.
Some(_) = conns.join_next() => continue,
};
match accepted {
Ok((stream, peer)) => {
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
let _ = stream.set_nodelay(true);
conns.spawn(tunnel_connection(
container_id.clone(),
target.clone(),
stream,
port,
));
}
Err(e) => {
log::warn!("Auth bridge: accept failed on port {}: {} — stopping listener", port, e);
return;
}
}
}
}
/// `accept()` on an optional listener; never completes when there is none, so it
/// can sit in a `select!` arm unconditionally.
async fn accept_optional(
listener: Option<&TcpListener>,
) -> std::io::Result<(TcpStream, SocketAddr)> {
match listener {
Some(l) => l.accept().await,
None => std::future::pending().await,
}
}
/// Carry one accepted host connection into the container over `socat`.
async fn tunnel_connection(container_id: String, target: String, stream: TcpStream, port: u16) {
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
let AttachedExec {
mut output,
mut input,
..
} = match create_attached_exec(&container_id, cmd, false).await {
Ok(e) => e,
Err(e) => {
log::warn!(
"Auth bridge: failed to open tunnel exec for port {} ({}): {}",
port,
target,
e
);
return;
}
};
let (mut host_rx, mut host_tx) = stream.into_split();
// Host → container. Runs as its own task so the container→host direction is
// never blocked behind a client that has stopped sending. Finishing this
// direction drops `input`, which closes the exec's stdin and lets socat see
// a clean EOF (a half-close, not a teardown of the whole connection).
let upstream = AbortOnDrop(tokio::spawn(async move {
let mut buf = vec![0u8; PUMP_BUF];
loop {
match host_rx.read(&mut buf).await {
Ok(0) => break,
Ok(n) => {
if input.write_all(&buf[..n]).await.is_err() || input.flush().await.is_err() {
break;
}
}
Err(_) => break,
}
}
}));
// Container → host. This direction is authoritative: when the exec's output
// stream ends, socat has exited and the connection is over.
while let Some(chunk) = output.next().await {
match chunk {
// Only stdout is payload. The exec is created with tty = false
// precisely so Docker demultiplexes these, keeping socat's stderr
// diagnostics out of the proxied byte stream.
Ok(LogOutput::StdOut { message }) => {
if host_tx.write_all(&message).await.is_err() {
break;
}
}
Ok(LogOutput::StdErr { message }) => {
log::debug!(
"Auth bridge: socat stderr for port {}: {}",
port,
String::from_utf8_lossy(&message).trim()
);
}
Ok(_) => {}
Err(e) => {
log::debug!("Auth bridge: tunnel stream error on port {}: {}", port, e);
break;
}
}
}
let _ = host_tx.shutdown().await;
// Explicit: stop reading from the host now that the container side is gone.
drop(upstream);
}
@@ -0,0 +1,67 @@
//! IPC surface for the auth bridge. The mechanism lives in
//! [`crate::auth_bridge`]; this file only translates between it and the
//! frontend, and keeps the persisted per-project flag in step.
use tauri::{AppHandle, State};
use crate::auth_bridge::AuthBridgeStatus;
use crate::AppState;
/// Turn the bridge on or off for a project and return the resulting status.
///
/// Enabling starts polling immediately when the container is already running;
/// otherwise the flag is simply persisted and `start_project_container` arms the
/// bridge on the next start. This is a host-side feature, so no container
/// recreation is involved either way.
#[tauri::command]
pub async fn set_auth_bridge_enabled(
project_id: String,
enabled: bool,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<AuthBridgeStatus, String> {
state
.projects_store
.set_auth_bridge_enabled(&project_id, enabled)?;
if enabled {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
if let Some(container_id) = project.container_id {
if crate::docker::container::is_container_running(&container_id)
.await
.unwrap_or(false)
{
state
.auth_bridge
.start(
project_id.clone(),
container_id,
app_handle,
state.projects_store.clone(),
)
.await;
}
}
} else {
// Awaits the poller, so every host port is released before we return.
state.auth_bridge.stop(&project_id).await;
}
Ok(state.auth_bridge.status(&project_id, enabled).await)
}
#[tauri::command]
pub async fn get_auth_bridge_status(
project_id: String,
state: State<'_, AppState>,
) -> Result<AuthBridgeStatus, String> {
let enabled = state
.projects_store
.get(&project_id)
.map(|p| p.auth_bridge_enabled)
.unwrap_or(false);
Ok(state.auth_bridge.status(&project_id, enabled).await)
}
@@ -0,0 +1,942 @@
//! Shared Claude Code authentication — one long-lived token for every project.
//!
//! ## Why
//!
//! Without this, every container is its own authentication island: each one
//! needs `claude login`, each one opens a browser flow, each one stores its own
//! credential in its own config volume. `claude setup-token` mints a single
//! ~1-year OAuth token that Claude Code accepts via `CLAUDE_CODE_OAUTH_TOKEN`,
//! so one authentication event can cover the whole fleet.
//!
//! ## How the token is obtained
//!
//! Observed directly against Claude Code 2.1.226, because the flow is not what
//! the design assumed. `claude setup-token` prints an authorization URL whose
//! `redirect_uri` is **Anthropic-hosted**
//! (`https://platform.claude.com/oauth/code/callback`) — it does *not* start a
//! loopback listener. After signing in, the user copies a code off that page
//! and the CLI waits at a `Paste code here if prompted >` prompt on **stdin**.
//! It then prints the token.
//!
//! Two consequences:
//!
//! * The flow needs a way to deliver the pasted code, hence
//! [`submit_claude_token_code`] and the stdin channel below. Without it the
//! command would simply sit at the prompt until it timed out.
//! * [`crate::auth_bridge`] is *not* required for this particular command,
//! since there is no container-local callback to reach. It is still enabled
//! for the duration (and restored afterwards) as designed: it costs nothing
//! here and keeps the flow working if a future CLI version, or the plain
//! `claude login` path, goes back to a loopback redirect.
//!
//! ## Handling of the token itself
//!
//! The token never reaches the frontend. It is parsed out of the command's
//! output, written straight to the OS keychain, and from then on only
//! [`crate::docker::container`] reads it, to inject the env var. Everything
//! streamed to the UI passes through [`SecretRedactor`] first, and no command
//! here returns the token or accepts it as an argument.
use std::sync::OnceLock;
use std::time::Duration;
use futures_util::StreamExt;
use tauri::{AppHandle, Emitter, State};
use tokio::io::AsyncWriteExt;
use tokio::sync::{mpsc, oneshot, Mutex};
use crate::docker::container::is_container_running;
use crate::docker::exec::{create_attached_exec, wait_for_exec_exit, AttachedExec};
use crate::storage::secure;
use crate::AppState;
/// Milestones in the acquisition flow. Payload `{ project_id, message }`,
/// matching the `container-progress` convention.
const PROGRESS_EVENT: &str = "claude-token-progress";
/// Redacted output from `claude setup-token`, so the UI can show the user the
/// URL to visit. Payload `{ project_id, chunk }`.
const OUTPUT_EVENT: &str = "claude-token-output";
/// How long to wait for the whole flow. Generous: the user has to switch to a
/// browser, sign in, and approve. Bounded so a wedged exec can't leak a task.
const SETUP_TIMEOUT: Duration = Duration::from_secs(15 * 60);
/// Documented shape of a `setup-token` credential.
const TOKEN_PREFIX: &str = "sk-ant-oat01-";
/// Minimum number of body characters after [`TOKEN_PREFIX`] for a match to be
/// believed. Real tokens run to ~90 characters; this is set well below that but
/// far above anything prose would produce, so documentation-style decoys like
/// `sk-ant-oat01-...` or `sk-ant-oat01-<your-token>` are rejected.
const MIN_TOKEN_BODY: usize = 32;
/// Redaction is deliberately broader than extraction: anything shaped like an
/// Anthropic credential is masked on its way to the UI, not just `oat01` ones.
const SECRET_MARKER: &str = "sk-ant-";
const SECRET_PLACEHOLDER: &str = "sk-ant-<redacted>";
const MIN_SECRET_BODY: usize = 8;
/// Cap on how much text [`SecretRedactor`] will withhold waiting for a
/// candidate secret to end. Past this, it is not a token — release it (still
/// redacted) rather than swallow the UI's output.
const MAX_HOLDBACK: usize = 4096;
/// Cap on the retained transcript used for parsing. The token is printed at the
/// end, and a re-rendering TUI can repaint many times, so keeping the tail is
/// both sufficient and bounded.
const MAX_TRANSCRIPT: usize = 256 * 1024;
/// Characters that can appear in the body of an Anthropic credential.
fn is_token_byte(b: u8) -> bool {
b.is_ascii_alphanumeric() || b == b'-' || b == b'_'
}
// ─────────────────────────────────────────────────────────────────────────────
// Token extraction
// ─────────────────────────────────────────────────────────────────────────────
/// Pull the long-lived token out of `claude setup-token`'s output.
///
/// Strict by construction, because the alternative to failing is storing
/// garbage that silently breaks every container:
/// * the value must carry the documented `sk-ant-oat01-` prefix;
/// * the prefix must not be glued to the tail of a longer word;
/// * at least [`MIN_TOKEN_BODY`] token characters must follow it.
///
/// The **last** match wins. The command narrates before it succeeds, and a TUI
/// may repaint the same frame repeatedly, so earlier matches are either prose
/// or superseded repaints of the same value.
pub fn parse_setup_token(output: &str) -> Option<String> {
let bytes = output.as_bytes();
let mut found = None;
let mut cursor = 0usize;
while let Some(offset) = output[cursor..].find(TOKEN_PREFIX) {
let start = cursor + offset;
cursor = start + TOKEN_PREFIX.len();
// `xsk-ant-oat01-…` is not a token, it is a substring of something else.
if start > 0 && is_token_byte(bytes[start - 1]) {
continue;
}
let body_start = start + TOKEN_PREFIX.len();
let mut end = body_start;
while end < bytes.len() && is_token_byte(bytes[end]) {
end += 1;
}
if end - body_start < MIN_TOKEN_BODY {
continue;
}
found = Some(output[start..end].to_string());
}
found
}
// ─────────────────────────────────────────────────────────────────────────────
// Redaction
// ─────────────────────────────────────────────────────────────────────────────
/// Mask every *complete* credential in `text`.
fn redact_complete(text: &str) -> String {
let bytes = text.as_bytes();
let mut out = String::with_capacity(text.len());
let mut copied = 0usize;
let mut cursor = 0usize;
while let Some(offset) = text[cursor..].find(SECRET_MARKER) {
let start = cursor + offset;
cursor = start + SECRET_MARKER.len();
if start > 0 && is_token_byte(bytes[start - 1]) {
continue;
}
let body_start = start + SECRET_MARKER.len();
let mut end = body_start;
while end < bytes.len() && is_token_byte(bytes[end]) {
end += 1;
}
if end - body_start < MIN_SECRET_BODY {
continue;
}
out.push_str(&text[copied..start]);
out.push_str(SECRET_PLACEHOLDER);
copied = end;
cursor = end;
}
out.push_str(&text[copied..]);
out
}
/// Where the tail that might still grow into a credential begins. Everything
/// before this index is safe to emit; everything from it must be withheld until
/// more input arrives. Returns `text.len()` when nothing needs withholding.
fn holdback_index(text: &str) -> usize {
let bytes = text.as_bytes();
// A credential already under way: the last marker with nothing but token
// characters after it. If the *last* marker fails that test, no earlier one
// can pass it either — the disqualifying character lies after them all.
if let Some(start) = text.rfind(SECRET_MARKER) {
let clean_start = start == 0 || !is_token_byte(bytes[start - 1]);
let body_all_token = bytes[start + SECRET_MARKER.len()..]
.iter()
.all(|b| is_token_byte(*b));
if clean_start && body_all_token {
return start;
}
}
// Otherwise: a marker truncated mid-way by the chunk boundary.
for len in (1..SECRET_MARKER.len()).rev() {
if text.len() >= len && text.is_char_boundary(text.len() - len)
&& &text[text.len() - len..] == &SECRET_MARKER[..len]
{
return text.len() - len;
}
}
text.len()
}
/// Masks credentials out of a stream, tolerating a secret split across chunk
/// boundaries by withholding any tail that could still turn into one.
#[derive(Default)]
struct SecretRedactor {
pending: String,
}
impl SecretRedactor {
/// Absorb `chunk` and return the text that is now safe to show.
fn push(&mut self, chunk: &str) -> String {
self.pending.push_str(chunk);
let mut split = holdback_index(&self.pending);
if self.pending.len() - split > MAX_HOLDBACK {
split = self.pending.len();
}
let emit = redact_complete(&self.pending[..split]);
self.pending.drain(..split);
emit
}
/// Release whatever is still withheld. The stream is over, so a partial
/// credential can no longer grow — but it is still redacted on the way out.
fn flush(&mut self) -> String {
let out = redact_complete(&self.pending);
self.pending.clear();
out
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Terminal control-sequence stripping
// ─────────────────────────────────────────────────────────────────────────────
/// Length in bytes of the UTF-8 character starting with `b`.
fn utf8_len(b: u8) -> usize {
if b < 0x80 {
1
} else if b >> 5 == 0b110 {
2
} else if b >> 4 == 0b1110 {
3
} else if b >> 3 == 0b11110 {
4
} else {
1
}
}
/// CSI final bytes that move the cursor. Claude Code's TUI lays text out by
/// jumping to a column (`ESC [ 9 G`) instead of emitting spaces, so deleting
/// these outright would weld neighbouring words together — which at best
/// garbles the URL the user has to read, and at worst welds a preceding word
/// onto the token and makes the parser reject it. They become a space instead:
/// a separator can never fabricate or destroy a match.
const CURSOR_MOVE_FINALS: &[u8] = b"ABCDEFGHd";
/// Strip terminal control sequences from the front of `bytes`, stopping at the
/// first incomplete sequence or truncated character. Returns the clean text and
/// how many bytes were consumed.
fn strip_ansi_prefix(bytes: &[u8]) -> (String, usize) {
let mut out = String::with_capacity(bytes.len());
let mut i = 0usize;
while i < bytes.len() {
match bytes[i] {
0x1b => {
if i + 1 >= bytes.len() {
return (out, i);
}
match bytes[i + 1] {
// CSI: parameter/intermediate bytes, then a final 0x40..=0x7e.
b'[' => {
let mut j = i + 2;
while j < bytes.len() && !(0x40..=0x7e).contains(&bytes[j]) {
j += 1;
}
if j >= bytes.len() {
return (out, i);
}
if CURSOR_MOVE_FINALS.contains(&bytes[j]) {
out.push(' ');
}
i = j + 1;
}
// OSC: runs until BEL or ST (ESC \).
b']' => {
let mut j = i + 2;
loop {
if j >= bytes.len() {
return (out, i);
}
if bytes[j] == 0x07 {
j += 1;
break;
}
if bytes[j] == 0x1b {
if j + 1 >= bytes.len() {
return (out, i);
}
if bytes[j + 1] == b'\\' {
j += 2;
break;
}
}
j += 1;
}
i = j;
}
// Two-byte escapes (charset selection, keypad mode, …).
_ => i += 2,
}
}
// A repaint returns to column 0. Turn that into a line break so the
// old frame's trailing text cannot be glued onto the new frame's
// leading text — which could otherwise fabricate a "token". A run of
// CRs immediately before a LF is just the pty's ONLCR translation,
// so it collapses into that single LF rather than blank lines.
b'\r' => {
let mut j = i;
while j < bytes.len() && bytes[j] == b'\r' {
j += 1;
}
if j >= bytes.len() {
return (out, i);
}
if bytes[j] != b'\n' {
out.push('\n');
}
i = j;
}
b'\n' => {
out.push('\n');
i += 1;
}
b'\t' => {
out.push('\t');
i += 1;
}
0x00..=0x1f | 0x7f => i += 1,
b => {
let len = utf8_len(b);
if i + len > bytes.len() {
return (out, i);
}
if let Ok(s) = std::str::from_utf8(&bytes[i..i + len]) {
out.push_str(s);
}
i += len;
}
}
}
(out, i)
}
/// Stateful wrapper around [`strip_ansi_prefix`] that carries an incomplete
/// trailing sequence over to the next chunk.
#[derive(Default)]
struct AnsiStripper {
carry: Vec<u8>,
}
impl AnsiStripper {
fn push(&mut self, chunk: &[u8]) -> String {
self.carry.extend_from_slice(chunk);
let (out, consumed) = strip_ansi_prefix(&self.carry);
self.carry.drain(..consumed);
out
}
}
// ─────────────────────────────────────────────────────────────────────────────
// Commands
// ─────────────────────────────────────────────────────────────────────────────
/// Stdin of the acquisition currently in flight, so [`submit_claude_token_code`]
/// can answer the CLI's `Paste code here` prompt.
///
/// `Some` exactly while a flow is running, which doubles as the single-flight
/// guard: the token is global, so two concurrent logins would race to overwrite
/// each other's keychain entry and neither could tell which prompt it was
/// feeding.
static PENDING_INPUT: OnceLock<Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>>> = OnceLock::new();
fn pending_input() -> &'static Mutex<Option<mpsc::UnboundedSender<Vec<u8>>>> {
PENDING_INPUT.get_or_init(|| Mutex::new(None))
}
/// Abort channel for the in-flight flow, claimed and released in lockstep with
/// [`PENDING_INPUT`].
///
/// Without this the only exits are "finished" and "timed out", so a user who
/// closes the dialog would be locked out by the single-flight guard until
/// `SETUP_TIMEOUT` elapsed.
static CANCEL_TX: OnceLock<Mutex<Option<oneshot::Sender<()>>>> = OnceLock::new();
fn cancel_slot() -> &'static Mutex<Option<oneshot::Sender<()>>> {
CANCEL_TX.get_or_init(|| Mutex::new(None))
}
fn emit_progress(app: &AppHandle, project_id: &str, message: &str) {
let _ = app.emit(
PROGRESS_EVENT,
serde_json::json!({ "project_id": project_id, "message": message }),
);
}
fn emit_output(app: &AppHandle, project_id: &str, chunk: &str) {
let _ = app.emit(
OUTPUT_EVENT,
serde_json::json!({ "project_id": project_id, "chunk": chunk }),
);
}
/// Shell run inside the container.
///
/// * `stty` widens the pty before Claude Code starts, so its layout engine does
/// not wrap the token or the sign-in URL across lines. Docker's default exec
/// pty is 80 columns; both are longer than that. Setting it here rather than
/// via a post-start resize avoids racing the process's startup.
/// * The `unset` line strips inherited auth so `setup-token` runs against a
/// clean claude.ai login instead of warning about, or deferring to, whatever
/// credential the container is already configured with — including a shared
/// token from a previous run, which is likely the very thing being replaced.
const SETUP_TOKEN_SCRIPT: &str = r#"stty cols 200 rows 50 2>/dev/null || true
unset CLAUDE_CODE_OAUTH_TOKEN ANTHROPIC_API_KEY ANTHROPIC_AUTH_TOKEN ANTHROPIC_BASE_URL \
ANTHROPIC_MODEL CLAUDE_CODE_USE_BEDROCK AWS_BEARER_TOKEN_BEDROCK
exec claude setup-token"#;
/// Run `claude setup-token` in the container and return the token it printed.
/// Streams redacted output as it arrives and forwards anything arriving on
/// `input_rx` (the user's pasted code) to the command's stdin.
async fn run_setup_token(
app: &AppHandle,
project_id: &str,
container_id: &str,
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
mut cancel_rx: oneshot::Receiver<()>,
) -> Result<String, String> {
// A pty (`tty = true`) because `setup-token` renders an interactive TUI and
// reads the pasted code in raw mode, which a plain pipe cannot provide.
let AttachedExec {
exec_id,
mut output,
mut input,
} = create_attached_exec(
container_id,
vec![
"sh".to_string(),
"-c".to_string(),
SETUP_TOKEN_SCRIPT.to_string(),
],
true,
)
.await?;
let mut stripper = AnsiStripper::default();
let mut redactor = SecretRedactor::default();
let mut transcript = String::new();
let deadline = tokio::time::Instant::now() + SETUP_TIMEOUT;
loop {
// Writing stdin and reading stdout are driven from the same loop: with
// a hijacked exec both halves ride one socket, and `input` must stay
// alive for the whole session anyway — dropping it early would tear the
// output stream down with it.
let next = tokio::select! {
// Cancellation wins the race so a user who gives up isn't held by
// the single-flight guard until the timeout. Dropping `input` and
// `output` on return tears the exec down with them.
_ = &mut cancel_rx => {
return Err(
"Authentication cancelled. No token was stored.".to_string()
);
}
Some(data) = input_rx.recv() => {
if let Err(e) = input.write_all(&data).await {
return Err(format!(
"Could not send the code to `claude setup-token`: {}. No token was stored.",
e
));
}
let _ = input.flush().await;
continue;
}
next = tokio::time::timeout_at(deadline, output.next()) => match next {
Ok(next) => next,
Err(_) => {
return Err(format!(
"Timed out after {} minutes waiting for `claude setup-token` to finish. \
No token was stored.",
SETUP_TIMEOUT.as_secs() / 60
))
}
},
};
let frame = match next {
Some(Ok(frame)) => frame,
Some(Err(e)) => {
return Err(format!(
"Lost the connection to `claude setup-token`: {}. No token was stored.",
e
))
}
None => break,
};
let visible = stripper.push(&frame.into_bytes());
if visible.is_empty() {
continue;
}
transcript.push_str(&visible);
if transcript.len() > MAX_TRANSCRIPT {
// Keep the tail: that is where the token lands.
let cut = transcript.len() - MAX_TRANSCRIPT / 2;
let cut = (cut..transcript.len())
.find(|i| transcript.is_char_boundary(*i))
.unwrap_or(transcript.len());
transcript.drain(..cut);
}
let safe = redactor.push(&visible);
if !safe.is_empty() {
emit_output(app, project_id, &safe);
}
}
let tail = redactor.flush();
if !tail.is_empty() {
emit_output(app, project_id, &tail);
}
let exit_code = wait_for_exec_exit(&exec_id).await.unwrap_or(0);
if exit_code != 0 {
return Err(format!(
"`claude setup-token` exited with status {}. No token was stored — \
see the command output above for what went wrong.",
exit_code
));
}
parse_setup_token(&transcript).ok_or_else(|| {
"`claude setup-token` finished but printed no recognisable token. \
Nothing was stored. This usually means the login was cancelled, or the \
account has no Claude subscription (long-lived tokens require one)."
.to_string()
})
}
/// Mint a shared, long-lived Claude Code token by running `claude setup-token`
/// inside `project_id`'s container, and store it in the OS keychain.
///
/// The project only lends its container — a place to run the CLI that already
/// has Claude Code installed. The resulting token is global, and is used by
/// every Anthropic-backend project that has not opted out.
#[tauri::command]
pub async fn acquire_claude_token(
project_id: String,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state
.projects_store
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let container_id = project.container_id.clone().ok_or_else(|| {
format!(
"Project '{}' has no container yet. Start it, then run authentication again.",
project.name
)
})?;
if !is_container_running(&container_id).await.unwrap_or(false) {
return Err(format!(
"The container for '{}' is not running. Start it, then run authentication again.",
project.name
));
}
// Claim the flow before touching anything else, so a second caller bounces
// off the guard rather than half-configuring the same project.
let (input_tx, input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (cancel_tx, cancel_rx) = oneshot::channel::<()>();
{
// Both slots are claimed under the input lock held first, and released
// in the same order below, so the guard and its abort channel can never
// disagree about whether a flow is live.
let mut slot = pending_input().lock().await;
if slot.is_some() {
return Err(
"A Claude authentication flow is already running. Finish or cancel it first."
.to_string(),
);
}
*slot = Some(input_tx);
*cancel_slot().lock().await = Some(cancel_tx);
}
let bridge_was_enabled = project.auth_bridge_enabled;
let result = async {
// See the module docs: 2.1.226's `setup-token` redirects to an
// Anthropic-hosted callback, so no container-local listener needs
// bridging. Enabled anyway, per design, to cover CLI versions and login
// paths that do use a loopback redirect. Temporary elevation — the
// prior setting is restored below whatever happens.
if !bridge_was_enabled {
state
.projects_store
.set_auth_bridge_enabled(&project_id, true)?;
emit_progress(
&app_handle,
&project_id,
"Auth bridge enabled for the duration of login.",
);
}
// Called unconditionally, and idempotent: the flag may already have
// been on while the poller was not running (e.g. enabled before start).
state
.auth_bridge
.start(
project_id.clone(),
container_id.clone(),
app_handle.clone(),
state.projects_store.clone(),
)
.await;
emit_progress(
&app_handle,
&project_id,
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
);
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await
}
.await;
// Release the flow, then restore the bridge — both unconditionally, so a
// failed or cancelled login leaves nothing latched on.
*pending_input().lock().await = None;
*cancel_slot().lock().await = None;
if !bridge_was_enabled {
// Stop the poller first: it awaits teardown, so host ports are provably
// released before the flag goes back.
state.auth_bridge.stop(&project_id).await;
if let Err(e) = state
.projects_store
.set_auth_bridge_enabled(&project_id, false)
{
log::warn!(
"Failed to restore the auth bridge setting for project {}: {}",
project_id,
e
);
}
}
let token = result?;
secure::store_claude_oauth_token(&token)?;
log::info!(
"Stored a shared Claude authentication token (acquired via project {})",
project_id
);
emit_progress(
&app_handle,
&project_id,
"Token stored in the OS keychain. Restart your Anthropic-backend containers to use it.",
);
Ok(())
}
/// Answer the `Paste code here if prompted >` prompt of a running
/// [`acquire_claude_token`] with the code shown after signing in.
///
/// Takes no project id: the flow is single-flight and the token is global, so
/// there is only ever one prompt waiting.
#[tauri::command]
pub async fn submit_claude_token_code(code: String) -> Result<(), String> {
let code = code.trim();
if code.is_empty() {
return Err("Enter the code shown after signing in.".to_string());
}
// The code goes to a TUI text input. A newline or escape embedded in it
// would submit early or drive the widget, so reject control characters
// outright rather than trying to sanitise them.
if code.chars().any(char::is_control) {
return Err("That code contains invalid characters. Copy it again and retry.".to_string());
}
let slot = pending_input().lock().await;
let sender = slot.as_ref().ok_or_else(|| {
"No Claude authentication flow is waiting for a code. Start authentication first."
.to_string()
})?;
let mut keystrokes = code.as_bytes().to_vec();
keystrokes.push(b'\r');
sender
.send(keystrokes)
.map_err(|_| "The authentication flow has already ended.".to_string())
}
/// Abort an in-flight [`acquire_claude_token`].
///
/// Tears the `setup-token` exec down and releases the single-flight guard, so
/// the user can immediately try again rather than waiting out `SETUP_TIMEOUT`.
/// A no-op when nothing is running, so closing the dialog twice is harmless.
#[tauri::command]
pub async fn cancel_claude_token() -> Result<(), String> {
let Some(sender) = cancel_slot().lock().await.take() else {
return Ok(());
};
// `Err` only means the flow finished between the take and the send, which
// is exactly the outcome cancelling wanted.
let _ = sender.send(());
Ok(())
}
/// Whether a shared Claude token exists. Deliberately a boolean — no command
/// here ever hands the token itself to the frontend.
#[tauri::command]
pub async fn has_claude_token() -> Result<bool, String> {
Ok(secure::has_claude_oauth_token())
}
/// Forget the shared Claude token. Containers keep the injected value until
/// each is next started, at which point the rotation-id label mismatch forces a
/// recreation that blanks the env var.
#[tauri::command]
pub async fn clear_claude_token() -> Result<(), String> {
secure::delete_claude_oauth_token()?;
log::info!("Cleared the shared Claude authentication token");
Ok(())
}
#[cfg(test)]
mod tests {
use super::*;
/// A token-shaped value of realistic length.
fn token(seed: char) -> String {
format!("{}{}", TOKEN_PREFIX, std::iter::repeat(seed).take(90).collect::<String>())
}
#[test]
fn extracts_the_token_from_realistic_output() {
let tok = token('A');
let output = format!(
"Claude Code long-lived token setup\n\
Opening browser to https://claude.ai/oauth/authorize?code=true\n\
Login successful!\n\n\
Your token:\n{}\n\n\
Set CLAUDE_CODE_OAUTH_TOKEN to this value.\n",
tok
);
assert_eq!(parse_setup_token(&output), Some(tok));
}
#[test]
fn ignores_prose_decoys_and_still_finds_the_real_token() {
let tok = token('B');
let output = format!(
"Set the env var like so:\n export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n\
or CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-<your-token-here>\n\
Your token: {}\n",
tok
);
assert_eq!(parse_setup_token(&output), Some(tok));
}
#[test]
fn a_decoy_on_its_own_yields_nothing() {
let output = "Usage: export CLAUDE_CODE_OAUTH_TOKEN=sk-ant-oat01-...\n";
assert_eq!(parse_setup_token(output), None);
}
#[test]
fn no_match_returns_none_rather_than_guessing() {
assert_eq!(parse_setup_token(""), None);
assert_eq!(
parse_setup_token("error: authentication cancelled by the user\n"),
None
);
// Right length, wrong product prefix.
assert_eq!(
parse_setup_token(&format!("sk-ant-api03-{}\n", "C".repeat(90))),
None
);
}
#[test]
fn multiple_matches_take_the_last() {
let old = token('D');
let new = token('E');
let output = format!(
"Replacing existing token {}\n...\nYour new token: {}\n",
old, new
);
assert_eq!(parse_setup_token(&output), Some(new));
}
#[test]
fn a_repainted_tui_frame_yields_the_same_token_once() {
let tok = token('F');
// Same frame drawn three times, as a TUI would.
let output = format!("Your token: {}\n", tok).repeat(3);
assert_eq!(parse_setup_token(&output), Some(tok));
}
#[test]
fn a_prefix_glued_to_a_longer_word_is_not_a_token() {
let output = format!("notasecretsk-ant-oat01-{}\n", "G".repeat(90));
assert_eq!(parse_setup_token(&output), None);
}
#[test]
fn the_token_stops_at_the_first_non_token_character() {
let tok = token('H');
let output = format!("token=\"{}\", expires=2027-08-09\n", tok);
assert_eq!(parse_setup_token(&output), Some(tok));
}
#[test]
fn redaction_masks_a_token_in_one_piece() {
let mut r = SecretRedactor::default();
let mut seen = r.push(&format!("Your token: {}\n", token('I')));
seen.push_str(&r.flush());
assert!(!seen.contains(TOKEN_PREFIX));
assert!(seen.contains(SECRET_PLACEHOLDER));
assert!(seen.contains("Your token: "));
}
#[test]
fn redaction_survives_a_token_split_across_chunks() {
let tok = token('J');
let mut r = SecretRedactor::default();
let mut seen = String::new();
// Split mid-prefix and again mid-body — the worst case for a naive
// per-chunk regex.
seen.push_str(&r.push("Your token: sk-a"));
seen.push_str(&r.push(&tok[4..40]));
seen.push_str(&r.push(&tok[40..]));
seen.push_str(&r.push("\ndone\n"));
seen.push_str(&r.flush());
assert!(!seen.contains(TOKEN_PREFIX), "leaked: {}", seen);
assert!(seen.contains(SECRET_PLACEHOLDER));
assert!(seen.ends_with("\ndone\n"));
}
#[test]
fn redaction_leaves_ordinary_text_alone() {
let mut r = SecretRedactor::default();
let mut seen = r.push("Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n");
seen.push_str(&r.flush());
assert_eq!(
seen,
"Visit https://claude.ai/oauth/authorize?code=abc-def to continue\n"
);
}
#[test]
fn ansi_stripping_recovers_the_token_from_a_styled_frame() {
let tok = token('K');
let framed = format!(
"\x1b[2J\x1b[H\x1b[1;36mYour token:\x1b[0m\r\n\x1b[32m{}\x1b[0m\r\n",
tok
);
let mut s = AnsiStripper::default();
let visible = s.push(framed.as_bytes());
assert!(!visible.contains('\x1b'));
assert_eq!(parse_setup_token(&visible), Some(tok));
}
/// Claude Code's TUI positions words with `ESC [ n G` instead of spaces
/// (verified against 2.1.226). Deleting those would weld words together.
#[test]
fn ansi_stripping_turns_column_jumps_into_separators() {
let mut s = AnsiStripper::default();
let visible = s.push(b"\x1b[38;2;215;119;87mWelcome\x1b[9Gto\x1b[12GClaude\x1b[19GCode\x1b[39m");
assert_eq!(visible, "Welcome to Claude Code");
}
/// The failure this protects against: a column jump immediately before the
/// token would, if simply deleted, glue the preceding word onto the prefix
/// and make `parse_setup_token` reject a perfectly good token.
#[test]
fn a_column_jump_before_the_token_does_not_hide_it() {
let tok = token('L');
let framed = format!("\x1b[2GToken\x1b[8G{}\r\n", tok);
let mut s = AnsiStripper::default();
let visible = s.push(framed.as_bytes());
// The leading jump is an indent, so it becomes a space too.
assert_eq!(visible, format!(" Token {}\n", tok));
assert_eq!(parse_setup_token(&visible), Some(tok));
}
/// A pty with ONLCR emits `\r\r\n` at end of line; that is one break.
#[test]
fn carriage_return_runs_before_a_newline_collapse() {
let mut s = AnsiStripper::default();
let visible = s.push(b"one\r\r\ntwo\r\r\n");
assert_eq!(visible, "one\ntwo\n");
}
/// A bare CR is a repaint, and must still break the line so the old frame's
/// tail cannot be welded onto the new frame's head.
#[test]
fn a_bare_carriage_return_breaks_the_line() {
let mut s = AnsiStripper::default();
let visible = s.push(b"sk-ant-oat01-old\rsk-ant-oat01-new");
assert_eq!(visible, "sk-ant-oat01-old\nsk-ant-oat01-new");
}
#[test]
fn ansi_stripping_removes_osc8_hyperlink_wrappers() {
let mut s = AnsiStripper::default();
let visible = s.push(b"\x1b]8;id=1;https://claude.com/x\x07https://claude.com/x\x1b]8;;\x07");
assert_eq!(visible, "https://claude.com/x");
}
#[test]
fn ansi_stripping_handles_a_sequence_split_across_chunks() {
let mut s = AnsiStripper::default();
let mut visible = s.push(b"a\x1b[3");
visible.push_str(&s.push(b"1mb"));
assert_eq!(visible, "ab");
}
}
+63 -12
View File
@@ -1,23 +1,58 @@
use tauri::State;
use crate::models::Project;
use crate::AppState;
#[tauri::command]
pub async fn aws_sso_refresh(
project_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state.projects_store.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let profile = project.bedrock_config.as_ref()
/// Resolve AWS profile: project-level → global settings → "default".
pub fn resolve_profile_for_project(project: &Project, global_profile: Option<&str>) -> String {
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());
.or_else(|| global_profile.map(|s| s.to_string()))
.unwrap_or_else(|| "default".to_string())
}
/// Check if the AWS session is valid for the given profile on the host.
/// Returns `Ok(true)` if valid, `Ok(false)` if expired/invalid.
pub async fn check_sso_session(profile: &str) -> Result<bool, String> {
let output = tokio::process::Command::new("aws")
.args(["sts", "get-caller-identity", "--profile", profile])
.output()
.await
.map_err(|e| format!("Failed to run aws sts get-caller-identity: {}", e))?;
Ok(output.status.success())
}
/// Check if the given AWS profile uses SSO (has sso_start_url or sso_session configured).
pub async fn is_sso_profile(profile: &str) -> Result<bool, String> {
let check_start_url = tokio::process::Command::new("aws")
.args(["configure", "get", "sso_start_url", "--profile", profile])
.output()
.await;
if let Ok(out) = check_start_url {
if out.status.success() {
return Ok(true);
}
}
let check_session = tokio::process::Command::new("aws")
.args(["configure", "get", "sso_session", "--profile", profile])
.output()
.await;
if let Ok(out) = check_session {
if out.status.success() {
return Ok(true);
}
}
Ok(false)
}
/// Run `aws sso login --profile X` on the host. This is interactive (opens a browser).
pub async fn run_sso_login(profile: &str) -> Result<(), String> {
log::info!("Running host-side AWS SSO login for profile '{}'", profile);
let status = tokio::process::Command::new("aws")
.args(["sso", "login", "--profile", &profile])
.args(["sso", "login", "--profile", profile])
.status()
.await
.map_err(|e| format!("Failed to run aws sso login: {}", e))?;
@@ -28,3 +63,19 @@ pub async fn aws_sso_refresh(
Ok(())
}
#[tauri::command]
pub async fn aws_sso_refresh(
project_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
let project = state.projects_store.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
let profile = resolve_profile_for_project(
&project,
state.settings_store.get().global_aws.aws_profile.as_deref(),
);
run_sso_login(&profile).await
}
+195 -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 serde::Serialize;
use tauri::State;
@@ -151,6 +152,199 @@ pub async fn download_container_file(
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` — Claude Code's own native
/// MCP config — and `settings` are kept) and ~/.claude/ minus the OAuth
/// `.credentials.json`, so 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]
pub async fn upload_file_to_container(
project_id: String,
@@ -0,0 +1,60 @@
use std::sync::OnceLock;
use tokio::sync::Mutex;
const HELP_URL: &str =
"https://raw.githubusercontent.com/shadowdao/triple-c/main/HOW-TO-USE.md";
const EMBEDDED_HELP: &str = include_str!("../../../../HOW-TO-USE.md");
/// Cached help content fetched from the remote repo (or `None` if not yet fetched).
static CACHED_HELP: OnceLock<Mutex<Option<String>>> = OnceLock::new();
/// Return the help markdown content.
///
/// On the first call, tries to fetch the latest version from the gitea repo.
/// If that fails (network error, timeout, etc.), falls back to the version
/// embedded at compile time. The result is cached for the rest of the session.
#[tauri::command]
pub async fn get_help_content() -> Result<String, String> {
let mutex = CACHED_HELP.get_or_init(|| Mutex::new(None));
let mut guard = mutex.lock().await;
if let Some(ref cached) = *guard {
return Ok(cached.clone());
}
let content = match fetch_remote_help().await {
Ok(md) => {
log::info!("Loaded help content from remote repo");
md
}
Err(e) => {
log::info!("Using embedded help content (remote fetch failed: {})", e);
EMBEDDED_HELP.to_string()
}
};
*guard = Some(content.clone());
Ok(content)
}
async fn fetch_remote_help() -> Result<String, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(10))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let resp = client
.get(HELP_URL)
.send()
.await
.map_err(|e| format!("Failed to fetch help content: {}", e))?;
if !resp.status().is_success() {
return Err(format!("Remote returned status {}", resp.status()));
}
resp.text()
.await
.map_err(|e| format!("Failed to read response body: {}", e))
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,11 @@
use crate::install_helper::{self, InstallOptions};
#[tauri::command]
pub async fn detect_install_options() -> Result<InstallOptions, String> {
Ok(install_helper::detect_install_options())
}
#[tauri::command]
pub async fn run_docker_install(app_handle: tauri::AppHandle) -> Result<(), String> {
install_helper::platform::run_install(&app_handle).await
}
@@ -1,38 +0,0 @@
use tauri::State;
use crate::models::McpServer;
use crate::AppState;
#[tauri::command]
pub async fn list_mcp_servers(state: State<'_, AppState>) -> Result<Vec<McpServer>, String> {
Ok(state.mcp_store.list())
}
#[tauri::command]
pub async fn add_mcp_server(
name: String,
state: State<'_, AppState>,
) -> Result<McpServer, String> {
let name = name.trim().to_string();
if name.is_empty() {
return Err("MCP server name cannot be empty.".to_string());
}
let server = McpServer::new(name);
state.mcp_store.add(server)
}
#[tauri::command]
pub async fn update_mcp_server(
server: McpServer,
state: State<'_, AppState>,
) -> Result<McpServer, String> {
state.mcp_store.update(server)
}
#[tauri::command]
pub async fn remove_mcp_server(
server_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
state.mcp_store.remove(&server_id)
}
+7 -1
View File
@@ -1,8 +1,14 @@
pub mod auth_bridge_commands;
pub mod auth_token_commands;
pub mod aws_commands;
pub mod docker_commands;
pub mod file_commands;
pub mod mcp_commands;
pub mod help_commands;
pub mod inspect_commands;
pub mod install_helper_commands;
pub mod project_commands;
pub mod settings_commands;
pub mod stt_commands;
pub mod terminal_commands;
pub mod update_commands;
pub mod web_terminal_commands;
+198 -101
View File
@@ -1,7 +1,8 @@
use tauri::{Emitter, State};
use crate::commands::aws_commands;
use crate::docker;
use crate::models::{container_config, AuthMode, McpServer, Project, ProjectPath, ProjectStatus};
use crate::models::{container_config, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectStatus};
use crate::storage::secure;
use crate::AppState;
@@ -34,9 +35,9 @@ fn store_secrets_for_project(project: &Project) -> Result<(), String> {
secure::store_project_secret(&project.id, "aws-bearer-token", v)?;
}
}
if let Some(ref litellm) = project.litellm_config {
if let Some(ref v) = litellm.api_key {
secure::store_project_secret(&project.id, "litellm-api-key", v)?;
if let Some(ref oai_config) = project.openai_compatible_config {
if let Some(ref v) = oai_config.api_key {
secure::store_project_secret(&project.id, "openai-compatible-api-key", v)?;
}
}
Ok(())
@@ -56,25 +57,12 @@ fn load_secrets_for_project(project: &mut Project) {
bedrock.aws_bearer_token = secure::get_project_secret(&project.id, "aws-bearer-token")
.unwrap_or(None);
}
if let Some(ref mut litellm) = project.litellm_config {
litellm.api_key = secure::get_project_secret(&project.id, "litellm-api-key")
if let Some(ref mut oai_config) = project.openai_compatible_config {
oai_config.api_key = secure::get_project_secret(&project.id, "openai-compatible-api-key")
.unwrap_or(None);
}
}
/// Resolve enabled MCP servers and filter to Docker-only ones.
fn resolve_mcp_servers(project: &Project, state: &AppState) -> (Vec<McpServer>, Vec<McpServer>) {
let all_mcp_servers = state.mcp_store.list();
let enabled_mcp: Vec<McpServer> = project.enabled_mcp_servers.iter()
.filter_map(|id| all_mcp_servers.iter().find(|s| &s.id == id).cloned())
.collect();
let docker_mcp: Vec<McpServer> = enabled_mcp.iter()
.filter(|s| s.is_docker())
.cloned()
.collect();
(enabled_mcp, docker_mcp)
}
#[tauri::command]
pub async fn list_projects(state: State<'_, AppState>) -> Result<Vec<Project>, String> {
Ok(state.projects_store.list())
@@ -112,6 +100,10 @@ pub async fn remove_project(
project_id: String,
state: State<'_, AppState>,
) -> Result<(), String> {
// Release any host loopback ports the auth bridge holds for this project
// before the container (and the project record) go away.
state.auth_bridge.stop(&project_id).await;
// Stop and remove container if it exists
if let Some(ref project) = state.projects_store.get(&project_id) {
if let Some(ref container_id) = project.container_id {
@@ -120,16 +112,10 @@ pub async fn remove_project(
let _ = docker::remove_container(container_id).await;
}
// Remove MCP containers and network
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(project, &state);
if !docker_mcp.is_empty() {
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await {
log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e);
}
}
if let Err(e) = docker::remove_project_network(&project.id).await {
log::warn!("Failed to remove project network for project {}: {}", project_id, e);
}
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
// containers first, then the per-project network they were attached to.
docker::remove_legacy_mcp_containers(&project.id).await;
docker::remove_legacy_project_network(&project.id).await;
// Clean up the snapshot image + volumes
if let Err(e) = docker::remove_snapshot_image(project).await {
@@ -151,10 +137,35 @@ pub async fn remove_project(
#[tauri::command]
pub async fn update_project(
project: Project,
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Project, String> {
store_secrets_for_project(&project)?;
state.projects_store.update(project)
let updated = state.projects_store.update(project)?;
// `auth_bridge_enabled` can arrive through this generic save as well as
// through `set_auth_bridge_enabled`, so reconcile the running bridge with
// whatever was just persisted. `start` is idempotent and `stop` is a no-op
// when nothing is running, so this is safe on every project save.
if updated.auth_bridge_enabled {
if let Some(ref container_id) = updated.container_id {
if docker::is_container_running(container_id).await.unwrap_or(false) {
state
.auth_bridge
.start(
updated.id.clone(),
container_id.clone(),
app_handle,
state.projects_store.clone(),
)
.await;
}
}
} else {
state.auth_bridge.stop(&updated.id).await;
}
Ok(updated)
}
#[tauri::command]
@@ -176,38 +187,109 @@ pub async fn start_project_container(
let settings = state.settings_store.get();
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
// Resolve enabled MCP servers for this project
let (enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
// Validate auth mode requirements
if project.auth_mode == AuthMode::Bedrock {
// Validate backend requirements
if project.backend == Backend::Bedrock {
let bedrock = project.bedrock_config.as_ref()
.ok_or_else(|| "Bedrock auth mode selected but no Bedrock configuration found.".to_string())?;
.ok_or_else(|| "Bedrock backend selected but no Bedrock configuration found.".to_string())?;
// Region can come from per-project or global
if bedrock.aws_region.is_empty() && settings.global_aws.aws_region.is_none() {
return Err("AWS region is required for Bedrock auth mode. Set it per-project or in global AWS settings.".to_string());
return Err("AWS region is required for Bedrock backend. Set it per-project or in global AWS settings.".to_string());
}
}
if project.auth_mode == AuthMode::Ollama {
if project.backend == Backend::Ollama {
let ollama = project.ollama_config.as_ref()
.ok_or_else(|| "Ollama auth mode selected but no Ollama configuration found.".to_string())?;
if ollama.base_url.is_empty() {
return Err("Ollama base URL is required.".to_string());
.ok_or_else(|| "Ollama backend selected but no Ollama configuration found.".to_string())?;
if ollama.base_url.is_empty()
&& settings.global_ollama.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("Ollama base URL is required. Set it per-project or in global Ollama settings.".to_string());
}
}
if project.auth_mode == AuthMode::LiteLlm {
let litellm = project.litellm_config.as_ref()
.ok_or_else(|| "LiteLLM auth mode selected but no LiteLLM configuration found.".to_string())?;
if litellm.base_url.is_empty() {
return Err("LiteLLM base URL is required.".to_string());
if project.backend == Backend::OpenAiCompatible {
let oai_config = project.openai_compatible_config.as_ref()
.ok_or_else(|| "OpenAI Compatible backend selected but no configuration found.".to_string())?;
if oai_config.base_url.is_empty()
&& settings.global_openai_compatible.base_url.as_deref().map(str::trim).unwrap_or("").is_empty()
{
return Err("OpenAI Compatible base URL is required. Set it per-project or in global settings.".to_string());
}
}
// Update status to starting
state.projects_store.update_status(&project_id, ProjectStatus::Starting)?;
// Pre-validate AWS SSO session on the host for Bedrock Profile projects.
// If the session is expired, trigger `aws sso login` before starting the container
// so the entrypoint copies already-fresh credentials from the host mount.
if project.backend == Backend::Bedrock {
if let Some(ref bedrock) = project.bedrock_config {
if bedrock.auth_method == BedrockAuthMethod::Profile {
let profile = aws_commands::resolve_profile_for_project(
&project,
settings.global_aws.aws_profile.as_deref(),
);
emit_progress(&app_handle, &project_id, "Validating AWS session...");
let session_valid = tokio::time::timeout(
std::time::Duration::from_secs(10),
aws_commands::check_sso_session(&profile),
)
.await;
match session_valid {
Ok(Ok(true)) => {
emit_progress(&app_handle, &project_id, "AWS session valid.");
}
Ok(Ok(false)) => {
// Session expired — check if this is an SSO profile
if aws_commands::is_sso_profile(&profile).await.unwrap_or(false) {
emit_progress(
&app_handle,
&project_id,
"AWS session expired. Starting SSO login (check your browser)...",
);
match aws_commands::run_sso_login(&profile).await {
Ok(()) => {
emit_progress(
&app_handle,
&project_id,
"SSO login successful.",
);
}
Err(e) => {
log::warn!(
"SSO login failed for profile '{}': {} — continuing anyway",
profile,
e
);
emit_progress(
&app_handle,
&project_id,
"SSO login failed or cancelled. Continuing...",
);
}
}
} else {
log::warn!(
"AWS session invalid for profile '{}' (not SSO). Continuing...",
profile
);
}
}
Ok(Err(e)) => {
log::warn!("Failed to check AWS session: {} — continuing anyway", e);
}
Err(_) => {
log::warn!("AWS session check timed out — continuing anyway");
}
}
}
}
}
// Wrap container operations so that any failure resets status to Stopped.
let result: Result<String, String> = async {
// Ensure image exists
@@ -225,48 +307,21 @@ pub async fn start_project_container(
// AWS config path from global settings
let aws_config_path = settings.global_aws.aws_config_path.clone();
// Set up Docker network and MCP containers if needed
let network_name = if !docker_mcp.is_empty() {
// Pull any missing MCP Docker images before starting containers
for server in &docker_mcp {
if let Some(ref image) = server.docker_image {
if !docker::image_exists(image).await.unwrap_or(false) {
emit_progress(
&app_handle,
&project_id,
&format!("Pulling MCP image for '{}'...", server.name),
);
let image_clone = image.clone();
let app_clone = app_handle.clone();
let pid_clone = project_id.clone();
let sname = server.name.clone();
docker::pull_image(&image_clone, move |msg| {
emit_progress(&app_clone, &pid_clone, &format!("[{}] {}", sname, msg));
}).await.map_err(|e| {
format!("Failed to pull MCP image '{}' for '{}': {}", image, server.name, e)
})?;
}
}
}
emit_progress(&app_handle, &project_id, "Setting up MCP network...");
let net = docker::ensure_project_network(&project.id).await?;
emit_progress(&app_handle, &project_id, "Starting MCP containers...");
docker::start_mcp_containers(&docker_mcp, &net).await?;
Some(net)
} else {
None
};
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
// Check if config changed — if so, snapshot + recreate
let needs_recreate = docker::container_needs_recreation(
&existing_id,
&project,
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
&enabled_mcp,
settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(),
).await.unwrap_or(false);
if needs_recreate {
@@ -280,6 +335,12 @@ pub async fn start_project_container(
let _ = docker::stop_container(&existing_id).await;
docker::remove_container(&existing_id).await?;
// Legacy MCP cleanup: the old container may have been attached to
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
// that network now, before the replacement is created without it.
docker::remove_legacy_mcp_containers(&project.id).await;
docker::remove_legacy_project_network(&project.id).await;
// Create from snapshot image (preserves system-level changes)
let snapshot_image = docker::get_snapshot_image_name(&project);
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
@@ -294,11 +355,15 @@ pub async fn start_project_container(
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
&enabled_mcp,
network_name.as_deref(),
settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(),
).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?;
@@ -327,17 +392,28 @@ pub async fn start_project_container(
&create_image,
aws_config_path.as_deref(),
&settings.global_aws,
&settings.global_ollama,
&settings.global_openai_compatible,
settings.global_claude_instructions.as_deref(),
&settings.global_custom_env_vars,
settings.timezone.as_deref(),
&enabled_mcp,
network_name.as_deref(),
settings.global_claude_code_settings.as_ref(),
settings.default_ssh_key_path.as_deref(),
settings.default_git_user_name.as_deref(),
settings.default_git_user_email.as_deref(),
).await?;
emit_progress(&app_handle, &project_id, "Starting container...");
docker::start_container(&new_id).await?;
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)
}.await;
@@ -353,6 +429,20 @@ pub async fn start_project_container(
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
state.projects_store.update_status(&project_id, ProjectStatus::Running)?;
// Arm the auth bridge if this project opted in. Purely host-side, so it
// happens after the container is up and never affects the start itself.
if project.auth_bridge_enabled {
state
.auth_bridge
.start(
project_id.clone(),
container_id.clone(),
app_handle.clone(),
state.projects_store.clone(),
)
.await;
}
project.container_id = Some(container_id);
project.status = ProjectStatus::Running;
Ok(project)
@@ -371,6 +461,9 @@ pub async fn stop_project_container(
state.projects_store.update_status(&project_id, ProjectStatus::Stopping)?;
// Drop host listeners first: they only make sense while the container runs.
state.auth_bridge.stop(&project_id).await;
if let Some(ref container_id) = project.container_id {
// Close exec sessions for this project
emit_progress(&app_handle, &project_id, "Stopping container...");
@@ -381,15 +474,6 @@ pub async fn stop_project_container(
}
}
// Stop MCP containers (best-effort)
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
if !docker_mcp.is_empty() {
emit_progress(&app_handle, &project_id, "Stopping MCP containers...");
if let Err(e) = docker::stop_mcp_containers(&docker_mcp).await {
log::warn!("Failed to stop MCP containers for project {}: {}", project_id, e);
}
}
state.projects_store.update_status(&project_id, ProjectStatus::Stopped)?;
Ok(())
}
@@ -405,6 +489,10 @@ pub async fn rebuild_project_container(
.get(&project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
// The bridge is bound to the container that is about to be destroyed;
// `start_project_container` below re-arms it against the new one.
state.auth_bridge.stop(&project_id).await;
// Remove existing container
if let Some(ref container_id) = project.container_id {
state.exec_manager.close_sessions_for_container(container_id).await;
@@ -413,14 +501,6 @@ pub async fn rebuild_project_container(
state.projects_store.set_container_id(&project_id, None)?;
}
// Remove MCP containers before rebuild
let (_enabled_mcp, docker_mcp) = resolve_mcp_servers(&project, &state);
if !docker_mcp.is_empty() {
if let Err(e) = docker::remove_mcp_containers(&docker_mcp).await {
log::warn!("Failed to remove MCP containers for project {}: {}", project_id, e);
}
}
// Remove snapshot image + volumes so Reset creates from the clean base image
if let Err(e) = docker::remove_snapshot_image(&project).await {
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
@@ -439,6 +519,7 @@ pub async fn rebuild_project_container(
/// to Stopped.
#[tauri::command]
pub async fn reconcile_project_statuses(
app_handle: tauri::AppHandle,
state: State<'_, AppState>,
) -> Result<Vec<Project>, String> {
let projects = state.projects_store.list();
@@ -460,6 +541,22 @@ pub async fn reconcile_project_statuses(
project.name,
project.id
);
// The app may have restarted while the container kept running; the
// bridge lives in this process, so re-arm it here. `start` is
// idempotent, so a bridge that is already polling is untouched.
if project.auth_bridge_enabled {
if let Some(ref container_id) = project.container_id {
state
.auth_bridge
.start(
project.id.clone(),
container_id.clone(),
app_handle.clone(),
state.projects_store.clone(),
)
.await;
}
}
} else {
log::info!(
"Project '{}' ({}) container is not running — setting to Stopped",
@@ -0,0 +1,92 @@
use tauri::{AppHandle, Emitter, State};
use crate::docker::stt;
use crate::models::app_settings::SttStatus;
use crate::AppState;
#[tauri::command]
pub async fn get_stt_status(state: State<'_, AppState>) -> Result<SttStatus, String> {
let settings = state.settings_store.get();
stt::get_stt_status(&settings.stt).await
}
#[tauri::command]
pub async fn start_stt(state: State<'_, AppState>) -> Result<SttStatus, String> {
let settings = state.settings_store.get();
stt::ensure_stt_running(&settings.stt).await
}
#[tauri::command]
pub async fn stop_stt() -> Result<(), String> {
stt::stop_stt_container().await
}
#[tauri::command]
pub async fn build_stt_image(app_handle: AppHandle) -> Result<(), String> {
stt::build_stt_image(move |msg| {
let _ = app_handle.emit("stt-build-progress", &msg);
})
.await
}
#[tauri::command]
pub async fn pull_stt_image(app_handle: AppHandle) -> Result<(), String> {
stt::pull_stt_image(move |msg| {
let _ = app_handle.emit("stt-pull-progress", &msg);
})
.await
}
#[tauri::command]
pub async fn transcribe_audio(
audio_data: Vec<u8>,
state: State<'_, AppState>,
) -> Result<String, String> {
let settings = state.settings_store.get();
if !settings.stt.enabled {
return Err("STT is not enabled".to_string());
}
let url = format!("http://127.0.0.1:{}/transcribe", settings.stt.port);
let file_part = reqwest::multipart::Part::bytes(audio_data)
.file_name("recording.wav")
.mime_str("audio/wav")
.map_err(|e| format!("Failed to create multipart: {}", e))?;
let mut form = reqwest::multipart::Form::new().part("file", file_part);
if let Some(ref lang) = settings.stt.language {
form = form.text("language", lang.clone());
}
let client = reqwest::Client::new();
let response = client
.post(&url)
.multipart(form)
.send()
.await
.map_err(|e| {
if e.is_connect() {
"STT container is not running. Start it from Settings.".to_string()
} else {
format!("Transcription request failed: {}", e)
}
})?;
if !response.status().is_success() {
let status = response.status();
let body = response.text().await.unwrap_or_default();
return Err(format!("Transcription failed ({}): {}", status, body));
}
let result: serde_json::Value = response
.json()
.await
.map_err(|e| format!("Failed to parse transcription response: {}", e))?;
result["text"]
.as_str()
.map(|s| s.to_string())
.ok_or_else(|| "No text in transcription response".to_string())
}
+85 -17
View File
@@ -1,6 +1,7 @@
use tauri::{AppHandle, Emitter, State};
use crate::models::{AuthMode, BedrockAuthMethod, Project};
use crate::commands::aws_commands;
use crate::models::{Backend, BedrockAuthMethod, Project};
use crate::AppState;
/// Build the command to run in the container terminal.
@@ -8,31 +9,47 @@ use crate::AppState;
/// 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
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
.as_ref()
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
.unwrap_or(false);
let permission_args = project.effective_permission_mode().cli_args();
if !is_bedrock_profile {
return vec![
"claude".to_string(),
"--dangerously-skip-permissions".to_string(),
];
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
if let Some(name) = session_name {
if !name.is_empty() {
cmd.push("-n".to_string());
cmd.push(name.to_string());
}
}
return cmd;
}
// 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());
let profile = aws_commands::resolve_profile_for_project(
project,
state.settings_store.get().global_aws.aws_profile.as_deref(),
);
// Build a bash wrapper that validates credentials, re-auths if needed,
// then exec's into claude.
let name_flag = session_name
.filter(|n| !n.is_empty())
.map(|n| format!(" -n '{}'", n.replace('\'', "'\\''")))
.unwrap_or_default();
// The args are interpolated into a shell script string, so single-quote
// each one (same escaping style as name_flag above).
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
let script = format!(
r#"
echo "Validating AWS session for profile '{profile}'..."
@@ -58,9 +75,10 @@ else
echo ""
fi
fi
exec claude --dangerously-skip-permissions
{claude_cmd}
"#,
profile = profile
profile = profile,
claude_cmd = claude_cmd
);
vec![
@@ -75,6 +93,7 @@ pub async fn open_terminal_session(
project_id: String,
session_id: String,
session_type: Option<String>,
session_name: Option<String>,
app_handle: AppHandle,
state: State<'_, AppState>,
) -> Result<(), String> {
@@ -90,7 +109,7 @@ pub async fn open_terminal_session(
let cmd = match session_type.as_deref() {
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state),
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
};
let output_event = format!("terminal-output-{}", session_id);
@@ -166,6 +185,55 @@ pub async fn paste_image_to_terminal(
.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]
pub async fn start_audio_bridge(
session_id: String,
+177 -38
View File
@@ -1,7 +1,20 @@
use crate::models::{GiteaRelease, ReleaseAsset, UpdateInfo};
use serde::Deserialize;
use tauri::State;
use crate::docker;
use crate::models::{container_config, GitHubRelease, ImageUpdateInfo, ReleaseAsset, UpdateInfo};
use crate::AppState;
const RELEASES_URL: &str =
"https://repo.anhonesthost.net/api/v1/repos/cybercovellc/triple-c/releases";
"https://api.github.com/repos/shadowdao/triple-c/releases";
/// GHCR container-registry API base (OCI distribution spec).
const REGISTRY_API_BASE: &str =
"https://ghcr.io/v2/shadowdao/triple-c-sandbox";
/// GHCR token endpoint for anonymous pull access.
const GHCR_TOKEN_URL: &str =
"https://ghcr.io/token?scope=repository:shadowdao/triple-c-sandbox:pull";
#[tauri::command]
pub fn get_app_version() -> String {
@@ -15,9 +28,10 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
let releases: Vec<GiteaRelease> = client
let releases: Vec<GitHubRelease> = client
.get(RELEASES_URL)
.header("Accept", "application/json")
.header("User-Agent", "triple-c-updater")
.send()
.await
.map_err(|e| format!("Failed to fetch releases: {}", e))?
@@ -26,30 +40,34 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
.map_err(|e| format!("Failed to parse releases: {}", e))?;
let current_version = env!("CARGO_PKG_VERSION");
let is_windows = cfg!(target_os = "windows");
let current_semver = parse_semver(current_version).unwrap_or((0, 0, 0));
// Filter releases by platform tag suffix
let platform_releases: Vec<&GiteaRelease> = releases
// Determine platform-specific asset extensions
let platform_extensions: &[&str] = if cfg!(target_os = "windows") {
&[".msi", ".exe"]
} else if cfg!(target_os = "macos") {
&[".dmg", ".app.tar.gz"]
} else {
&[".AppImage", ".deb", ".rpm"]
};
// Filter releases that have at least one asset matching the current platform
let platform_releases: Vec<&GitHubRelease> = releases
.iter()
.filter(|r| {
if is_windows {
r.tag_name.ends_with("-win")
} else {
!r.tag_name.ends_with("-win")
}
r.assets.iter().any(|a| {
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
})
})
.collect();
// Find the latest release with a higher patch version
// Version format: 0.1.X or v0.1.X (tag may have prefix/suffix)
let current_patch = parse_patch_version(current_version).unwrap_or(0);
let mut best: Option<(&GiteaRelease, u32)> = None;
// Find the latest release with a higher semver version
let mut best: Option<(&GitHubRelease, (u32, u32, u32))> = None;
for release in &platform_releases {
if let Some(patch) = parse_patch_from_tag(&release.tag_name) {
if patch > current_patch {
if best.is_none() || patch > best.unwrap().1 {
best = Some((release, patch));
if let Some(ver) = parse_semver_from_tag(&release.tag_name) {
if ver > current_semver {
if best.is_none() || ver > best.unwrap().1 {
best = Some((release, ver));
}
}
}
@@ -57,9 +75,13 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
match best {
Some((release, _)) => {
// Only include assets matching the current platform
let assets = release
.assets
.iter()
.filter(|a| {
platform_extensions.iter().any(|ext| a.name.ends_with(ext))
})
.map(|a| ReleaseAsset {
name: a.name.clone(),
browser_download_url: a.browser_download_url.clone(),
@@ -67,7 +89,6 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
})
.collect();
// Reconstruct version string from tag
let version = extract_version_from_tag(&release.tag_name)
.unwrap_or_else(|| release.tag_name.clone());
@@ -84,34 +105,152 @@ pub async fn check_for_updates() -> Result<Option<UpdateInfo>, String> {
}
}
/// Parse patch version from a semver string like "0.1.5" -> 5
fn parse_patch_version(version: &str) -> Option<u32> {
/// Parse a semver string like "0.2.5" -> (0, 2, 5)
fn parse_semver(version: &str) -> Option<(u32, u32, u32)> {
let clean = version.trim_start_matches('v');
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 {
parts[2].parse().ok()
let major = parts[0].parse().ok()?;
let minor = parts[1].parse().ok()?;
let patch = parts[2].parse().ok()?;
Some((major, minor, patch))
} else {
None
}
}
/// Parse patch version from a tag like "v0.1.5", "v0.1.5-win", "0.1.5" -> 5
fn parse_patch_from_tag(tag: &str) -> Option<u32> {
/// Parse semver from a tag like "v0.2.5" -> (0, 2, 5)
fn parse_semver_from_tag(tag: &str) -> Option<(u32, u32, u32)> {
let clean = tag.trim_start_matches('v');
// Remove platform suffix
let clean = clean.strip_suffix("-win").unwrap_or(clean);
parse_patch_version(clean)
parse_semver(clean)
}
/// Extract a clean version string from a tag like "v0.1.5-win" -> "0.1.5"
/// Extract a clean version string from a tag like "v0.2.5" -> "0.2.5"
fn extract_version_from_tag(tag: &str) -> Option<String> {
let clean = tag.trim_start_matches('v');
let clean = clean.strip_suffix("-win").unwrap_or(clean);
// Validate it looks like a version
let parts: Vec<&str> = clean.split('.').collect();
if parts.len() >= 3 && parts.iter().all(|p| p.parse::<u32>().is_ok()) {
Some(clean.to_string())
} else {
None
let (major, minor, patch) = parse_semver_from_tag(tag)?;
Some(format!("{}.{}.{}", major, minor, patch))
}
/// Check whether a newer container image is available in the registry.
///
/// Compares the local image digest with the remote registry digest using the
/// Docker Registry HTTP API v2. Only applies when the image source is
/// "registry" (the default); for local builds or custom images we cannot
/// meaningfully check for remote updates.
#[tauri::command]
pub async fn check_image_update(
state: State<'_, AppState>,
) -> Result<Option<ImageUpdateInfo>, String> {
let settings = state.settings_store.get();
// Only check for registry images
if settings.image_source != crate::models::app_settings::ImageSource::Registry {
return Ok(None);
}
let image_name =
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
// 1. Get local image digest via Docker
let local_digest = docker::get_local_image_digest(&image_name).await.ok().flatten();
// 2. Get remote digest from the GHCR container registry (OCI distribution spec)
let remote_digest = fetch_remote_digest("latest").await?;
// No remote digest available — nothing to compare
let remote_digest = match remote_digest {
Some(d) => d,
None => return Ok(None),
};
// If local digest matches remote, no update
if let Some(ref local) = local_digest {
if *local == remote_digest {
return Ok(None);
}
}
// There's a difference (or no local image at all)
Ok(Some(ImageUpdateInfo {
remote_digest,
local_digest,
remote_updated_at: None,
}))
}
/// Fetch the digest of a tag from GHCR using the OCI / Docker Registry HTTP API v2.
///
/// GHCR requires authentication even for public images, so we first obtain an
/// anonymous token, then issue a HEAD request to /v2/<repo>/manifests/<tag>
/// and read the `Docker-Content-Digest` header.
async fn fetch_remote_digest(tag: &str) -> Result<Option<String>, String> {
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(15))
.build()
.map_err(|e| format!("Failed to create HTTP client: {}", e))?;
// 1. Obtain anonymous bearer token from GHCR
let token = match fetch_ghcr_token(&client).await {
Ok(t) => t,
Err(e) => {
log::warn!("Failed to obtain GHCR token: {}", e);
return Ok(None);
}
};
// 2. HEAD the manifest with the token
let url = format!("{}/manifests/{}", REGISTRY_API_BASE, tag);
let response = client
.head(&url)
.header(
"Accept",
"application/vnd.docker.distribution.manifest.v2+json, application/vnd.oci.image.index.v1+json",
)
.header("Authorization", format!("Bearer {}", token))
.send()
.await;
match response {
Ok(resp) => {
if !resp.status().is_success() {
log::warn!(
"Registry returned status {} when checking image digest",
resp.status()
);
return Ok(None);
}
// The digest is returned in the Docker-Content-Digest header
if let Some(digest) = resp.headers().get("docker-content-digest") {
if let Ok(val) = digest.to_str() {
return Ok(Some(val.to_string()));
}
}
Ok(None)
}
Err(e) => {
log::warn!("Failed to check registry for image update: {}", e);
Ok(None)
}
}
}
/// Fetch an anonymous bearer token from GHCR for pulling public images.
async fn fetch_ghcr_token(client: &reqwest::Client) -> Result<String, String> {
#[derive(Deserialize)]
struct TokenResponse {
token: String,
}
let resp: TokenResponse = client
.get(GHCR_TOKEN_URL)
.header("User-Agent", "triple-c-updater")
.send()
.await
.map_err(|e| format!("GHCR token request failed: {}", e))?
.json()
.await
.map_err(|e| format!("Failed to parse GHCR token response: {}", e))?;
Ok(resp.token)
}
@@ -0,0 +1,143 @@
use serde::Serialize;
use tauri::State;
use crate::web_terminal::WebTerminalServer;
use crate::AppState;
#[derive(Serialize)]
pub struct WebTerminalInfo {
pub running: bool,
pub port: u16,
pub access_token: String,
pub local_ip: Option<String>,
pub url: Option<String>,
}
fn generate_token() -> String {
use rand::Rng;
let mut rng = rand::rng();
let bytes: Vec<u8> = (0..32).map(|_| rng.random::<u8>()).collect();
use base64::Engine;
base64::engine::general_purpose::URL_SAFE_NO_PAD.encode(&bytes)
}
fn get_local_ip() -> Option<String> {
local_ip_address::local_ip().ok().map(|ip| ip.to_string())
}
fn build_info(running: bool, port: u16, token: &str) -> WebTerminalInfo {
let local_ip = get_local_ip();
let url = if running {
local_ip
.as_ref()
.map(|ip| format!("http://{}:{}?token={}", ip, port, token))
} else {
None
};
WebTerminalInfo {
running,
port,
access_token: token.to_string(),
local_ip,
url,
}
}
#[tauri::command]
pub async fn start_web_terminal(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
let mut server_guard = state.web_terminal_server.lock().await;
if server_guard.is_some() {
return Err("Web terminal server is already running".to_string());
}
let mut settings = state.settings_store.get();
// Auto-generate token if not set
if settings.web_terminal.access_token.is_none() {
settings.web_terminal.access_token = Some(generate_token());
settings.web_terminal.enabled = true;
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
}
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
let port = settings.web_terminal.port;
let server = WebTerminalServer::start(
port,
token.clone(),
state.exec_manager.clone(),
state.projects_store.clone(),
state.settings_store.clone(),
)
.await?;
*server_guard = Some(server);
// Mark as enabled in settings
if !settings.web_terminal.enabled {
settings.web_terminal.enabled = true;
let _ = state.settings_store.update(settings);
}
Ok(build_info(true, port, &token))
}
#[tauri::command]
pub async fn stop_web_terminal(state: State<'_, AppState>) -> Result<(), String> {
let mut server_guard = state.web_terminal_server.lock().await;
if let Some(server) = server_guard.take() {
server.stop();
}
// Mark as disabled in settings
let mut settings = state.settings_store.get();
if settings.web_terminal.enabled {
settings.web_terminal.enabled = false;
let _ = state.settings_store.update(settings);
}
Ok(())
}
#[tauri::command]
pub async fn get_web_terminal_status(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
let server_guard = state.web_terminal_server.lock().await;
let settings = state.settings_store.get();
let token = settings.web_terminal.access_token.clone().unwrap_or_default();
let running = server_guard.is_some();
Ok(build_info(running, settings.web_terminal.port, &token))
}
#[tauri::command]
pub async fn regenerate_web_terminal_token(state: State<'_, AppState>) -> Result<WebTerminalInfo, String> {
// Stop current server if running
{
let mut server_guard = state.web_terminal_server.lock().await;
if let Some(server) = server_guard.take() {
server.stop();
}
}
// Generate new token and save
let new_token = generate_token();
let mut settings = state.settings_store.get();
settings.web_terminal.access_token = Some(new_token.clone());
state.settings_store.update(settings.clone()).map_err(|e| format!("Failed to save settings: {}", e))?;
// Restart if was enabled
if settings.web_terminal.enabled {
let server = WebTerminalServer::start(
settings.web_terminal.port,
new_token.clone(),
state.exec_manager.clone(),
state.projects_store.clone(),
state.settings_store.clone(),
)
.await?;
let mut server_guard = state.web_terminal_server.lock().await;
*server_guard = Some(server);
return Ok(build_info(true, settings.web_terminal.port, &new_token));
}
Ok(build_info(false, settings.web_terminal.port, &new_token))
}
File diff suppressed because it is too large Load Diff
+218 -71
View File
@@ -1,13 +1,78 @@
use bollard::container::UploadToContainerOptions;
use bollard::container::{LogOutput, UploadToContainerOptions};
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
use futures_util::StreamExt;
use futures_util::{Stream, StreamExt};
use std::collections::HashMap;
use std::pin::Pin;
use std::sync::Arc;
use tokio::io::AsyncWriteExt;
use tokio::io::{AsyncWrite, AsyncWriteExt};
use tokio::sync::{mpsc, Mutex};
use super::client::get_docker;
/// A `docker exec` that has been created and started with stdin/stdout/stderr
/// attached — the raw duplex halves, before any policy about what to do with
/// them.
///
/// This is the single place in the codebase that knows how to open an attached
/// exec. Both consumers are built on it:
/// * [`ExecSessionManager`] — interactive terminals and the audio bridge,
/// which pump bytes through mpsc channels and a callback.
/// * `auth_bridge` — per-connection `socat` tunnels, which pump bytes
/// straight between a host TCP socket and these halves.
///
/// With `tty = false` the output stream is demultiplexed by Docker, so the
/// consumer can tell [`LogOutput::StdOut`] from [`LogOutput::StdErr`]. That
/// distinction matters for the auth bridge: `socat`'s diagnostics must not be
/// spliced into the proxied byte stream.
pub struct AttachedExec {
pub exec_id: String,
pub output: Pin<Box<dyn Stream<Item = Result<LogOutput, bollard::errors::Error>> + Send>>,
pub input: Pin<Box<dyn AsyncWrite + Send>>,
}
/// Create and start an exec with stdin + stdout + stderr attached, returning the
/// raw duplex halves. Runs as `claude` in `/workspace`, like every other exec
/// this app opens.
pub async fn create_attached_exec(
container_id: &str,
cmd: Vec<String>,
tty: bool,
) -> Result<AttachedExec, String> {
let docker = get_docker()?;
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(tty),
cmd: Some(cmd),
user: Some("claude".to_string()),
working_dir: Some("/workspace".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create exec: {}", e))?;
let exec_id = exec.id.clone();
match docker
.start_exec(&exec_id, None)
.await
.map_err(|e| format!("Failed to start exec: {}", e))?
{
StartExecResults::Attached { output, input } => Ok(AttachedExec {
exec_id,
output,
input,
}),
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
}
}
pub struct ExecSession {
pub exec_id: String,
pub container_id: String,
@@ -80,82 +145,55 @@ impl ExecSessionManager {
where
F: Fn(Vec<u8>) + Send + 'static,
{
let docker = get_docker()?;
let exec = docker
.create_exec(
container_id,
CreateExecOptions {
attach_stdin: Some(true),
attach_stdout: Some(true),
attach_stderr: Some(true),
tty: Some(tty),
cmd: Some(cmd),
user: Some("claude".to_string()),
working_dir: Some("/workspace".to_string()),
..Default::default()
},
)
.await
.map_err(|e| format!("Failed to create exec: {}", e))?;
let exec_id = exec.id.clone();
let result = docker
.start_exec(&exec_id, None)
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
let AttachedExec {
exec_id,
mut output,
mut input,
} = create_attached_exec(container_id, cmd, tty).await?;
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
match result {
StartExecResults::Attached { mut output, mut input } => {
// Output reader task
let session_id_clone = session_id.to_string();
let shutdown_tx_clone = shutdown_tx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
msg = output.next() => {
match msg {
Some(Ok(output)) => {
on_output(output.into_bytes().to_vec());
}
Some(Err(e)) => {
log::error!("Exec output error for {}: {}", session_id_clone, e);
break;
}
None => {
log::info!("Exec output stream ended for {}", session_id_clone);
break;
}
}
// Output reader task
let session_id_clone = session_id.to_string();
let shutdown_tx_clone = shutdown_tx.clone();
tokio::spawn(async move {
loop {
tokio::select! {
msg = output.next() => {
match msg {
Some(Ok(output)) => {
on_output(output.into_bytes().to_vec());
}
_ = shutdown_rx.recv() => {
log::info!("Exec session {} shutting down", session_id_clone);
Some(Err(e)) => {
log::error!("Exec output error for {}: {}", session_id_clone, e);
break;
}
None => {
log::info!("Exec output stream ended for {}", session_id_clone);
break;
}
}
}
on_exit();
let _ = shutdown_tx_clone;
});
// Input writer task
tokio::spawn(async move {
while let Some(data) = input_rx.recv().await {
if let Err(e) = input.write_all(&data).await {
log::error!("Failed to write to exec stdin: {}", e);
break;
}
_ = shutdown_rx.recv() => {
log::info!("Exec session {} shutting down", session_id_clone);
break;
}
});
}
}
StartExecResults::Detached => {
return Err("Exec started in detached mode".to_string());
on_exit();
let _ = shutdown_tx_clone;
});
// Input writer task
tokio::spawn(async move {
while let Some(data) = input_rx.recv().await {
if let Err(e) = input.write_all(&data).await {
log::error!("Failed to write to exec stdin: {}", e);
break;
}
}
}
});
let session = ExecSession {
exec_id,
@@ -279,8 +317,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.
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 exec = docker
@@ -290,6 +410,7 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
attach_stdout: Some(true),
attach_stderr: Some(true),
cmd: Some(cmd),
env: if env.is_empty() { None } else { Some(env) },
user: Some("claude".to_string()),
..Default::default()
},
@@ -302,17 +423,43 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
.await
.map_err(|e| format!("Failed to start exec: {}", e))?;
let mut combined = String::new();
match result {
StartExecResults::Attached { mut output, .. } => {
let mut stdout = String::new();
while let Some(msg) = output.next().await {
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)),
}
}
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
}
+74 -26
View File
@@ -1,6 +1,7 @@
use bollard::image::{BuildImageOptions, CreateImageOptions, ListImagesOptions};
use bollard::models::ImageSummary;
use futures_util::StreamExt;
use include_dir::{include_dir, Dir};
use std::collections::HashMap;
use std::io::Write;
@@ -11,6 +12,11 @@ const DOCKERFILE: &str = include_str!("../../../../container/Dockerfile");
const ENTRYPOINT: &str = include_str!("../../../../container/entrypoint.sh");
const SCHEDULER: &str = include_str!("../../../../container/triple-c-scheduler");
const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runner");
const OSC52_CLIPBOARD: &str = include_str!("../../../../container/osc52-clipboard");
const AUDIO_SHIM: &str = include_str!("../../../../container/audio-shim");
const SSO_REFRESH: &str = include_str!("../../../../container/triple-c-sso-refresh");
static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control");
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
let docker = get_docker()?;
@@ -31,6 +37,38 @@ pub async fn image_exists(image_name: &str) -> Result<bool, String> {
Ok(!images.is_empty())
}
/// Returns the first repo digest (e.g. "sha256:abc...") for the given image,
/// or None if the image doesn't exist locally or has no repo digests.
pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>, String> {
let docker = get_docker()?;
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"reference".to_string(),
vec![image_name.to_string()],
)]);
let images: Vec<ImageSummary> = docker
.list_images(Some(ListImagesOptions {
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list images: {}", e))?;
if let Some(img) = images.first() {
// RepoDigests contains entries like "registry/repo@sha256:abc..."
if let Some(digest_str) = img.repo_digests.first() {
// Extract the sha256:... part after '@'
if let Some(pos) = digest_str.find('@') {
return Ok(Some(digest_str[pos + 1..].to_string()));
}
return Ok(Some(digest_str.clone()));
}
}
Ok(None)
}
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
@@ -118,38 +156,48 @@ where
Ok(())
}
fn append_file_to_archive(
archive: &mut tar::Builder<&mut Vec<u8>>,
path: &str,
content: &[u8],
mode: u32,
) -> Result<(), std::io::Error> {
let mut header = tar::Header::new_gnu();
header.set_size(content.len() as u64);
header.set_mode(mode);
header.set_cksum();
archive.append_data(&mut header, path, content)
}
fn append_embedded_dir(
archive: &mut tar::Builder<&mut Vec<u8>>,
dir: &Dir,
prefix: &str,
) -> Result<(), std::io::Error> {
for file in dir.files() {
let path = format!("{}/{}", prefix, file.path().display());
append_file_to_archive(archive, &path, file.contents(), 0o644)?;
}
for subdir in dir.dirs() {
append_embedded_dir(archive, subdir, prefix)?;
}
Ok(())
}
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
let mut buf = Vec::new();
{
let mut archive = tar::Builder::new(&mut buf);
let dockerfile_bytes = DOCKERFILE.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(dockerfile_bytes.len() as u64);
header.set_mode(0o644);
header.set_cksum();
archive.append_data(&mut header, "Dockerfile", dockerfile_bytes)?;
append_file_to_archive(&mut archive, "Dockerfile", DOCKERFILE.as_bytes(), 0o644)?;
append_file_to_archive(&mut archive, "entrypoint.sh", ENTRYPOINT.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "triple-c-scheduler", SCHEDULER.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "triple-c-task-runner", TASK_RUNNER.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "osc52-clipboard", OSC52_CLIPBOARD.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "audio-shim", AUDIO_SHIM.as_bytes(), 0o755)?;
append_file_to_archive(&mut archive, "triple-c-sso-refresh", SSO_REFRESH.as_bytes(), 0o755)?;
let entrypoint_bytes = ENTRYPOINT.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(entrypoint_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "entrypoint.sh", entrypoint_bytes)?;
let scheduler_bytes = SCHEDULER.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(scheduler_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "triple-c-scheduler", scheduler_bytes)?;
let task_runner_bytes = TASK_RUNNER.as_bytes();
let mut header = tar::Header::new_gnu();
header.set_size(task_runner_bytes.len() as u64);
header.set_mode(0o755);
header.set_cksum();
archive.append_data(&mut header, "triple-c-task-runner", task_runner_bytes)?;
append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?;
archive.finish()?;
}
+137
View File
@@ -0,0 +1,137 @@
//! One-release migration shim for the removed built-in MCP feature.
//!
//! Older releases created a per-project user-defined bridge network
//! (`triple-c-net-<projectId>`) plus one container per Docker-backed MCP
//! server, and attached the project container to that network. Now that MCP
//! support is gone, those leftovers have to be torn down — a container whose
//! `NetworkMode` names a network that no longer exists refuses to start, so
//! the cleanup is paired with a forced container recreation (see
//! `container_needs_recreation`).
//!
//! Everything here is best-effort: failures are logged and never abort the
//! caller, and absent resources are a silent no-op. This module can be deleted
//! a release after all users have migrated.
use bollard::container::{ListContainersOptions, RemoveContainerOptions};
use bollard::network::InspectNetworkOptions;
use std::collections::HashMap;
use super::client::get_docker;
/// Network name used by the old MCP implementation for a project.
fn legacy_network_name(project_id: &str) -> String {
format!("triple-c-net-{}", project_id)
}
/// Force-remove every leftover MCP server container.
///
/// Matched by the `triple-c.mcp-server` label rather than by name, so
/// containers survive even if the MCP server definitions they came from are
/// already gone from storage. Best-effort: errors are logged and skipped.
pub async fn remove_legacy_mcp_containers(project_id: &str) {
let docker = match get_docker() {
Ok(d) => d,
Err(e) => {
log::debug!(
"Skipping legacy MCP container cleanup for project {}: {}",
project_id,
e
);
return;
}
};
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"label".to_string(),
vec!["triple-c.mcp-server".to_string()],
)]);
let containers = match docker
.list_containers(Some(ListContainersOptions {
all: true,
filters,
..Default::default()
}))
.await
{
Ok(c) => c,
Err(e) => {
log::warn!("Failed to list legacy MCP containers: {}", e);
return;
}
};
for container in containers {
let Some(id) = container.id else { continue };
match docker
.remove_container(
&id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
{
Ok(_) => log::info!("Removed legacy MCP container {}", id),
Err(e) => log::warn!("Failed to remove legacy MCP container {}: {}", id, e),
}
}
}
/// Remove the old per-project Docker network, disconnecting any remaining
/// members first (a network with attached endpoints cannot be deleted).
///
/// Silent no-op when the network does not exist. Best-effort: errors are
/// logged and never propagated.
pub async fn remove_legacy_project_network(project_id: &str) {
let docker = match get_docker() {
Ok(d) => d,
Err(e) => {
log::debug!(
"Skipping legacy network cleanup for project {}: {}",
project_id,
e
);
return;
}
};
let network_name = legacy_network_name(project_id);
// Inspect to discover connected containers; absence means nothing to do.
let info = match docker
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
.await
{
Ok(info) => info,
Err(_) => {
log::debug!("Legacy network {} not present, nothing to do", network_name);
return;
}
};
if let Some(containers) = info.containers {
for container_id in containers.into_keys() {
let disconnect_opts = bollard::network::DisconnectNetworkOptions {
container: container_id.clone(),
force: true,
};
if let Err(e) = docker
.disconnect_network(&network_name, disconnect_opts)
.await
{
log::warn!(
"Failed to disconnect container {} from legacy network {}: {}",
container_id,
network_name,
e
);
}
}
}
match docker.remove_network(&network_name).await {
Ok(_) => log::info!("Removed legacy Docker network {}", network_name),
Err(e) => log::warn!("Failed to remove legacy network {}: {}", network_name, e),
}
}
+5 -2
View File
@@ -2,8 +2,11 @@ pub mod client;
pub mod container;
pub mod image;
pub mod exec;
pub mod network;
pub mod legacy_cleanup;
pub mod stt;
#[allow(unused_imports)]
pub use stt::*;
#[allow(unused_imports)]
pub use client::*;
#[allow(unused_imports)]
@@ -13,4 +16,4 @@ pub use image::*;
#[allow(unused_imports)]
pub use exec::*;
#[allow(unused_imports)]
pub use network::*;
pub use legacy_cleanup::*;
-129
View File
@@ -1,129 +0,0 @@
use bollard::network::{CreateNetworkOptions, InspectNetworkOptions};
use std::collections::HashMap;
use super::client::get_docker;
/// Network name for a project's MCP containers.
fn project_network_name(project_id: &str) -> String {
format!("triple-c-net-{}", project_id)
}
/// Ensure a Docker bridge network exists for the project.
/// Returns the network name.
pub async fn ensure_project_network(project_id: &str) -> Result<String, String> {
let docker = get_docker()?;
let network_name = project_network_name(project_id);
// Check if network already exists
match docker
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
.await
{
Ok(_) => {
log::debug!("Network {} already exists", network_name);
return Ok(network_name);
}
Err(_) => {
// Network doesn't exist, create it
}
}
let options = CreateNetworkOptions {
name: network_name.clone(),
driver: "bridge".to_string(),
labels: HashMap::from([
("triple-c.managed".to_string(), "true".to_string()),
("triple-c.project-id".to_string(), project_id.to_string()),
]),
..Default::default()
};
docker
.create_network(options)
.await
.map_err(|e| format!("Failed to create network {}: {}", network_name, e))?;
log::info!("Created Docker network {}", network_name);
Ok(network_name)
}
/// Connect a container to the project network.
#[allow(dead_code)]
pub async fn connect_container_to_network(
container_id: &str,
network_name: &str,
) -> Result<(), String> {
let docker = get_docker()?;
let config = bollard::network::ConnectNetworkOptions {
container: container_id.to_string(),
..Default::default()
};
docker
.connect_network(network_name, config)
.await
.map_err(|e| {
format!(
"Failed to connect container {} to network {}: {}",
container_id, network_name, e
)
})?;
log::debug!(
"Connected container {} to network {}",
container_id,
network_name
);
Ok(())
}
/// Remove the project network (best-effort). Disconnects all containers first.
pub async fn remove_project_network(project_id: &str) -> Result<(), String> {
let docker = get_docker()?;
let network_name = project_network_name(project_id);
// Inspect to get connected containers
let info = match docker
.inspect_network(&network_name, None::<InspectNetworkOptions<String>>)
.await
{
Ok(info) => info,
Err(_) => {
log::debug!(
"Network {} not found, nothing to remove",
network_name
);
return Ok(());
}
};
// Disconnect all containers
if let Some(containers) = info.containers {
for (container_id, _) in containers {
let disconnect_opts = bollard::network::DisconnectNetworkOptions {
container: container_id.clone(),
force: true,
};
if let Err(e) = docker
.disconnect_network(&network_name, disconnect_opts)
.await
{
log::warn!(
"Failed to disconnect container {} from network {}: {}",
container_id,
network_name,
e
);
}
}
}
// Remove the network
match docker.remove_network(&network_name).await {
Ok(_) => log::info!("Removed Docker network {}", network_name),
Err(e) => log::warn!("Failed to remove network {}: {}", network_name, e),
}
Ok(())
}
+266
View File
@@ -0,0 +1,266 @@
use bollard::container::{
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
StartContainerOptions, StopContainerOptions,
};
use bollard::image::BuildImageOptions;
use bollard::models::{HostConfig, Mount, MountTypeEnum, PortBinding};
use futures_util::StreamExt;
use std::collections::HashMap;
use std::io::Write;
use super::client::get_docker;
use crate::models::app_settings::{SttSettings, SttStatus};
const STT_CONTAINER_NAME: &str = "triple-c-stt";
const STT_MODEL_VOLUME: &str = "triple-c-stt-model-cache";
const STT_REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-stt:latest";
const STT_LOCAL_IMAGE: &str = "triple-c-stt:latest";
const STT_DOCKERFILE: &str = include_str!("../../../../stt-container/Dockerfile");
const STT_SERVER: &str = include_str!("../../../../stt-container/server.py");
pub async fn get_stt_status(settings: &SttSettings) -> Result<SttStatus, String> {
let image_exists = super::image::image_exists(STT_REGISTRY_IMAGE).await.unwrap_or(false)
|| super::image::image_exists(STT_LOCAL_IMAGE).await.unwrap_or(false);
let (container_exists, running, model) = match find_stt_container().await? {
Some((_, state, env_model)) => (true, state == "running", env_model),
None => (false, false, settings.model.clone()),
};
Ok(SttStatus {
container_exists,
running,
port: settings.port,
model,
image_exists,
})
}
async fn find_stt_container() -> Result<Option<(String, String, String)>, String> {
let docker = get_docker()?;
let filters: HashMap<String, Vec<String>> = HashMap::from([(
"name".to_string(),
vec![format!("/{}", STT_CONTAINER_NAME)],
)]);
let containers = docker
.list_containers(Some(ListContainersOptions {
all: true,
filters,
..Default::default()
}))
.await
.map_err(|e| format!("Failed to list containers: {}", e))?;
if let Some(container) = containers.first() {
let id = container.id.clone().unwrap_or_default();
let state = container.state.clone().unwrap_or_default();
// Extract WHISPER_MODEL from container env
let model = container
.labels
.as_ref()
.and_then(|l| l.get("triple-c.stt.model"))
.cloned()
.unwrap_or_else(|| "tiny".to_string());
return Ok(Some((id, state, model)));
}
Ok(None)
}
async fn create_stt_container(settings: &SttSettings) -> Result<String, String> {
let docker = get_docker()?;
// Try local image first, fall back to registry
let image = if super::image::image_exists(STT_LOCAL_IMAGE).await.unwrap_or(false) {
STT_LOCAL_IMAGE.to_string()
} else if super::image::image_exists(STT_REGISTRY_IMAGE).await.unwrap_or(false) {
STT_REGISTRY_IMAGE.to_string()
} else {
return Err("STT image not found. Please build or pull the image first.".to_string());
};
let port_binding = PortBinding {
host_ip: Some("127.0.0.1".to_string()),
host_port: Some(settings.port.to_string()),
};
let mut port_bindings = HashMap::new();
port_bindings.insert(
"9876/tcp".to_string(),
Some(vec![port_binding]),
);
let host_config = HostConfig {
port_bindings: Some(port_bindings),
mounts: Some(vec![Mount {
target: Some("/root/.cache/huggingface".to_string()),
source: Some(STT_MODEL_VOLUME.to_string()),
typ: Some(MountTypeEnum::VOLUME),
..Default::default()
}]),
..Default::default()
};
let mut labels = HashMap::new();
labels.insert(
"triple-c.stt.model".to_string(),
settings.model.clone(),
);
labels.insert(
"triple-c.stt.port".to_string(),
settings.port.to_string(),
);
let config = Config {
image: Some(image),
env: Some(vec![format!("WHISPER_MODEL={}", settings.model)]),
host_config: Some(host_config),
labels: Some(labels),
..Default::default()
};
let options = CreateContainerOptions {
name: STT_CONTAINER_NAME,
..Default::default()
};
let response = docker
.create_container(Some(options), config)
.await
.map_err(|e| format!("Failed to create STT container: {}", e))?;
Ok(response.id)
}
pub async fn ensure_stt_running(settings: &SttSettings) -> Result<SttStatus, String> {
let docker = get_docker()?;
// Check if container exists and if settings match
if let Some((id, state, model)) = find_stt_container().await? {
let needs_recreate = model != settings.model;
if needs_recreate {
// Settings changed, recreate
if state == "running" {
docker
.stop_container(&id, None::<StopContainerOptions>)
.await
.map_err(|e| format!("Failed to stop STT container: {}", e))?;
}
docker
.remove_container(
&id,
Some(RemoveContainerOptions {
force: true,
..Default::default()
}),
)
.await
.map_err(|e| format!("Failed to remove STT container: {}", e))?;
} else if state == "running" {
return get_stt_status(settings).await;
} else {
// Container exists but stopped, start it
docker
.start_container(&id, None::<StartContainerOptions<String>>)
.await
.map_err(|e| format!("Failed to start STT container: {}", e))?;
return get_stt_status(settings).await;
}
}
// Create and start new container
let id = create_stt_container(settings).await?;
docker
.start_container(&id, None::<StartContainerOptions<String>>)
.await
.map_err(|e| format!("Failed to start STT container: {}", e))?;
get_stt_status(settings).await
}
pub async fn stop_stt_container() -> Result<(), String> {
let docker = get_docker()?;
if let Some((id, state, _)) = find_stt_container().await? {
if state == "running" {
docker
.stop_container(&id, None::<StopContainerOptions>)
.await
.map_err(|e| format!("Failed to stop STT container: {}", e))?;
}
}
Ok(())
}
pub async fn pull_stt_image<F>(on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
{
super::image::pull_image(STT_REGISTRY_IMAGE, on_progress).await
}
pub async fn build_stt_image<F>(on_progress: F) -> Result<(), String>
where
F: Fn(String) + Send + 'static,
{
let docker = get_docker()?;
let tar_bytes = create_stt_build_context()
.map_err(|e| format!("Failed to create STT build context: {}", e))?;
let options = BuildImageOptions {
t: STT_LOCAL_IMAGE,
rm: true,
forcerm: true,
..Default::default()
};
let mut stream = docker.build_image(options, None, Some(tar_bytes.into()));
while let Some(result) = stream.next().await {
match result {
Ok(output) => {
if let Some(stream) = output.stream {
on_progress(stream);
}
if let Some(error) = output.error {
return Err(format!("Build error: {}", error));
}
}
Err(e) => return Err(format!("Build stream error: {}", e)),
}
}
Ok(())
}
fn create_stt_build_context() -> Result<Vec<u8>, std::io::Error> {
let mut buf = Vec::new();
{
let mut archive = tar::Builder::new(&mut buf);
let mut dockerfile_header = tar::Header::new_gnu();
dockerfile_header.set_size(STT_DOCKERFILE.len() as u64);
dockerfile_header.set_mode(0o644);
dockerfile_header.set_cksum();
archive.append_data(&mut dockerfile_header, "Dockerfile", STT_DOCKERFILE.as_bytes())?;
let mut server_header = tar::Header::new_gnu();
server_header.set_size(STT_SERVER.len() as u64);
server_header.set_mode(0o644);
server_header.set_cksum();
archive.append_data(&mut server_header, "server.py", STT_SERVER.as_bytes())?;
archive.finish()?;
}
let _ = buf.flush();
Ok(buf)
}
+53
View File
@@ -0,0 +1,53 @@
// Helpers for detecting whether Docker (or a Docker-compatible runtime) is
// installed on the host and, when missing, offering to install it for the user.
//
// We use the Docker convenience script on Linux and Rancher Desktop on macOS /
// Windows. On every platform we also surface an official documentation URL so
// users without a recognised package manager can install manually.
use serde::{Deserialize, Serialize};
pub mod platform;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct InstallOptions {
/// "linux" | "macos" | "windows" | "unknown"
pub os: String,
/// User-facing name of what we'd install ("Docker Engine" / "Rancher Desktop").
pub product_name: String,
/// Whether we can kick off a one-click install with what's on this machine.
pub can_auto_install: bool,
/// Short identifier of the method we'd use ("pkexec", "brew", "winget", or None).
pub auto_install_method: Option<String>,
/// If auto-install isn't possible, a human-readable reason to show the user.
pub auto_install_blocker: Option<String>,
/// Official documentation URL for manual install.
pub docs_url: String,
/// Ordered manual install steps (plain text lines).
pub manual_steps: Vec<String>,
/// Notes to display after a successful auto-install (e.g. log out/back in).
pub post_install_notes: Vec<String>,
}
pub fn detect_install_options() -> InstallOptions {
if cfg!(target_os = "linux") {
platform::linux_options()
} else if cfg!(target_os = "macos") {
platform::macos_options()
} else if cfg!(target_os = "windows") {
platform::windows_options()
} else {
InstallOptions {
os: "unknown".into(),
product_name: "Docker".into(),
can_auto_install: false,
auto_install_method: None,
auto_install_blocker: Some("Unsupported operating system".into()),
docs_url: "https://docs.docker.com/get-docker/".into(),
manual_steps: vec![
"Visit the Docker documentation and follow the install guide for your OS.".into(),
],
post_install_notes: vec![],
}
}
}
@@ -0,0 +1,288 @@
use std::path::PathBuf;
use std::process::Stdio;
use tauri::{AppHandle, Emitter};
use tokio::io::{AsyncBufReadExt, BufReader};
use tokio::process::Command;
use super::InstallOptions;
const PROGRESS_EVENT: &str = "docker-install-progress";
fn which(cmd: &str) -> bool {
find_on_path(cmd).is_some()
}
/// Search PATH for an executable, plus a handful of well-known locations that
/// GUI-launched apps on macOS/Linux typically miss (Homebrew prefixes, etc.).
fn find_on_path(cmd: &str) -> Option<PathBuf> {
#[cfg(unix)]
let extra: &[&str] = &[
"/opt/homebrew/bin",
"/usr/local/bin",
"/usr/bin",
"/bin",
];
#[cfg(windows)]
let extra: &[&str] = &[];
if let Ok(path) = std::env::var("PATH") {
let sep = if cfg!(windows) { ';' } else { ':' };
for dir in path.split(sep).chain(extra.iter().copied()) {
let candidate = PathBuf::from(dir).join(cmd);
if candidate.is_file() {
return Some(candidate);
}
#[cfg(windows)]
for ext in ["exe", "cmd", "bat"] {
let mut with_ext = candidate.clone();
with_ext.set_extension(ext);
if with_ext.is_file() {
return Some(with_ext);
}
}
}
}
for dir in extra {
let candidate = PathBuf::from(dir).join(cmd);
if candidate.is_file() {
return Some(candidate);
}
}
None
}
async fn stream(app: &AppHandle, mut child: tokio::process::Child) -> Result<(), String> {
let stdout = child.stdout.take();
let stderr = child.stderr.take();
let app_out = app.clone();
let out_task = tokio::spawn(async move {
if let Some(out) = stdout {
let mut lines = BufReader::new(out).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = app_out.emit(PROGRESS_EVENT, line);
}
}
});
let app_err = app.clone();
let err_task = tokio::spawn(async move {
if let Some(err) = stderr {
let mut lines = BufReader::new(err).lines();
while let Ok(Some(line)) = lines.next_line().await {
let _ = app_err.emit(PROGRESS_EVENT, line);
}
}
});
let status = child
.wait()
.await
.map_err(|e| format!("install process failed: {}", e))?;
let _ = out_task.await;
let _ = err_task.await;
if !status.success() {
return Err(format!(
"installer exited with status {}",
status.code().map(|c| c.to_string()).unwrap_or_else(|| "signal".into())
));
}
Ok(())
}
// ─── Linux ───────────────────────────────────────────────────────────────────
pub fn linux_options() -> InstallOptions {
let has_pkexec = which("pkexec");
let has_curl = which("curl");
let (can_auto, blocker) = match (has_pkexec, has_curl) {
(true, true) => (true, None),
(false, _) => (
false,
Some("pkexec not found — install policykit-1 or follow manual steps.".into()),
),
(_, false) => (
false,
Some("curl not found — install curl or follow manual steps.".into()),
),
};
InstallOptions {
os: "linux".into(),
product_name: "Docker Engine".into(),
can_auto_install: can_auto,
auto_install_method: if can_auto { Some("pkexec".into()) } else { None },
auto_install_blocker: blocker,
docs_url: "https://docs.docker.com/engine/install/".into(),
manual_steps: vec![
"Open a terminal.".into(),
"Run: curl -fsSL https://get.docker.com | sh".into(),
"Add yourself to the docker group: sudo usermod -aG docker $USER".into(),
"Log out and log back in for group changes to take effect.".into(),
],
post_install_notes: vec![
"Log out and log back in (or reboot) so your user picks up the docker group.".into(),
"If Docker isn't detected after re-login, start the service: sudo systemctl start docker".into(),
],
}
}
async fn run_linux_install(app: &AppHandle) -> Result<(), String> {
// Grab the current username so pkexec (which runs as root) can add the
// original invoking user to the docker group.
let invoking_user = std::env::var("USER")
.or_else(|_| std::env::var("LOGNAME"))
.map_err(|_| "could not determine invoking username".to_string())?;
// Write a self-contained installer script to a temp file. Running the
// Docker convenience script then appending the user to the docker group
// and enabling the service.
let script = format!(
r#"#!/bin/sh
set -e
echo "[triple-c] Downloading Docker install script..."
curl -fsSL https://get.docker.com -o /tmp/triple-c-get-docker.sh
echo "[triple-c] Running Docker install script (may take a few minutes)..."
sh /tmp/triple-c-get-docker.sh
rm -f /tmp/triple-c-get-docker.sh
echo "[triple-c] Adding {user} to docker group..."
usermod -aG docker "{user}" || true
echo "[triple-c] Enabling docker service..."
systemctl enable --now docker 2>/dev/null || service docker start 2>/dev/null || true
echo "[triple-c] Install complete. Log out and back in to use Docker without sudo."
"#,
user = invoking_user
);
let script_path: PathBuf = std::env::temp_dir().join("triple-c-install-docker.sh");
tokio::fs::write(&script_path, script)
.await
.map_err(|e| format!("failed to write install script: {}", e))?;
let _ = app.emit(
PROGRESS_EVENT,
format!("Requesting administrator privileges via pkexec..."),
);
let child = Command::new("pkexec")
.arg("sh")
.arg(&script_path)
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch pkexec: {}", e))?;
let result = stream(app, child).await;
let _ = tokio::fs::remove_file(&script_path).await;
result
}
// ─── macOS ───────────────────────────────────────────────────────────────────
pub fn macos_options() -> InstallOptions {
let has_brew = which("brew");
InstallOptions {
os: "macos".into(),
product_name: "Rancher Desktop".into(),
can_auto_install: has_brew,
auto_install_method: if has_brew { Some("brew".into()) } else { None },
auto_install_blocker: if has_brew {
None
} else {
Some("Homebrew not found — use the manual download.".into())
},
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
manual_steps: vec![
"Download the Rancher Desktop .dmg from the official site.".into(),
"Open the .dmg and drag Rancher Desktop into Applications.".into(),
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
"Once the Docker socket is available, come back and click Refresh.".into(),
],
post_install_notes: vec![
"Launch Rancher Desktop from Applications if it didn't open automatically.".into(),
"In Preferences, make sure the container engine is set to dockerd (moby).".into(),
],
}
}
async fn run_macos_install(app: &AppHandle) -> Result<(), String> {
let brew = find_on_path("brew")
.ok_or_else(|| "Homebrew not found — follow the manual steps instead.".to_string())?;
let _ = app.emit(
PROGRESS_EVENT,
format!("Running: {} install --cask rancher", brew.display()),
);
let child = Command::new(&brew)
.args(["install", "--cask", "rancher"])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch brew: {}", e))?;
stream(app, child).await
}
// ─── Windows ─────────────────────────────────────────────────────────────────
pub fn windows_options() -> InstallOptions {
let has_winget = which("winget");
InstallOptions {
os: "windows".into(),
product_name: "Rancher Desktop".into(),
can_auto_install: has_winget,
auto_install_method: if has_winget { Some("winget".into()) } else { None },
auto_install_blocker: if has_winget {
None
} else {
Some("winget not found — use the manual download.".into())
},
docs_url: "https://docs.rancherdesktop.io/getting-started/installation/".into(),
manual_steps: vec![
"Download the Rancher Desktop .msi from the official site.".into(),
"Run the installer and accept the WSL2 prompts if asked.".into(),
"Launch Rancher Desktop and complete the first-run setup (choose dockerd/moby).".into(),
"Once the Docker engine is running, come back and click Refresh.".into(),
],
post_install_notes: vec![
"Launch Rancher Desktop from the Start menu if it didn't open automatically.".into(),
"In Preferences > Container Engine, make sure dockerd (moby) is selected.".into(),
],
}
}
async fn run_windows_install(app: &AppHandle) -> Result<(), String> {
let _ = app.emit(
PROGRESS_EVENT,
"Running: winget install --id SUSE.RancherDesktop -e --accept-package-agreements --accept-source-agreements".to_string(),
);
let child = Command::new("winget")
.args([
"install",
"--id",
"SUSE.RancherDesktop",
"-e",
"--accept-package-agreements",
"--accept-source-agreements",
])
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()
.map_err(|e| format!("failed to launch winget: {}", e))?;
stream(app, child).await
}
// ─── Dispatcher ──────────────────────────────────────────────────────────────
pub async fn run_install(app: &AppHandle) -> Result<(), String> {
if cfg!(target_os = "linux") {
run_linux_install(app).await
} else if cfg!(target_os = "macos") {
run_macos_install(app).await
} else if cfg!(target_os = "windows") {
run_windows_install(app).await
} else {
Err("auto-install is not supported on this OS".into())
}
}
+134 -24
View File
@@ -1,46 +1,53 @@
mod auth_bridge;
mod commands;
mod docker;
mod install_helper;
mod logging;
mod models;
mod storage;
pub mod web_terminal;
use std::sync::Arc;
use auth_bridge::AuthBridgeManager;
use docker::exec::ExecSessionManager;
use storage::projects_store::ProjectsStore;
use storage::settings_store::SettingsStore;
use storage::mcp_store::McpStore;
use tauri::Manager;
use web_terminal::WebTerminalServer;
pub struct AppState {
pub projects_store: ProjectsStore,
pub settings_store: SettingsStore,
pub mcp_store: McpStore,
pub exec_manager: ExecSessionManager,
pub projects_store: Arc<ProjectsStore>,
pub settings_store: Arc<SettingsStore>,
pub exec_manager: Arc<ExecSessionManager>,
pub auth_bridge: Arc<AuthBridgeManager>,
pub web_terminal_server: Arc<tokio::sync::Mutex<Option<WebTerminalServer>>>,
}
pub fn run() {
logging::init();
let projects_store = match ProjectsStore::new() {
let projects_store = Arc::new(match ProjectsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize projects store: {}", e);
panic!("Failed to initialize projects store: {}", e);
}
};
let settings_store = match SettingsStore::new() {
});
let settings_store = Arc::new(match SettingsStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize settings store: {}", e);
panic!("Failed to initialize settings store: {}", e);
}
};
let mcp_store = match McpStore::new() {
Ok(s) => s,
Err(e) => {
log::error!("Failed to initialize MCP store: {}", e);
panic!("Failed to initialize MCP store: {}", e);
}
};
});
let exec_manager = Arc::new(ExecSessionManager::new());
let auth_bridge = Arc::new(AuthBridgeManager::new());
// Clone Arcs for the setup closure (web terminal auto-start)
let projects_store_setup = projects_store.clone();
let settings_store_setup = settings_store.clone();
let exec_manager_setup = exec_manager.clone();
tauri::Builder::default()
.plugin(tauri_plugin_store::Builder::default().build())
@@ -49,10 +56,11 @@ pub fn run() {
.manage(AppState {
projects_store,
settings_store,
mcp_store,
exec_manager: ExecSessionManager::new(),
exec_manager,
auth_bridge,
web_terminal_server: Arc::new(tokio::sync::Mutex::new(None)),
})
.setup(|app| {
.setup(move |app| {
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
Ok(icon) => {
if let Some(window) = app.get_webview_window("main") {
@@ -63,13 +71,78 @@ pub fn run() {
log::error!("Failed to load window icon: {}", e);
}
}
// Auto-start web terminal server if enabled in settings
let settings = settings_store_setup.get();
if settings.web_terminal.enabled {
if let Some(token) = &settings.web_terminal.access_token {
let token = token.clone();
let port = settings.web_terminal.port;
let exec_mgr = exec_manager_setup.clone();
let proj_store = projects_store_setup.clone();
let set_store = settings_store_setup.clone();
let state = app.state::<AppState>();
let web_server_mutex = state.web_terminal_server.clone();
tauri::async_runtime::spawn(async move {
match WebTerminalServer::start(
port,
token,
exec_mgr,
proj_store,
set_store,
)
.await
{
Ok(server) => {
let mut guard = web_server_mutex.lock().await;
*guard = Some(server);
log::info!("Web terminal auto-started on port {}", port);
}
Err(e) => {
log::error!("Failed to auto-start web terminal: {}", e);
}
}
});
}
}
// Auto-start STT container if enabled in settings
if settings.stt.enabled {
let stt_settings = settings.stt.clone();
tauri::async_runtime::spawn(async move {
match docker::stt::ensure_stt_running(&stt_settings).await {
Ok(status) => {
if status.running {
log::info!("STT container auto-started on port {}", stt_settings.port);
} else {
log::warn!("STT auto-start: container not running after ensure_stt_running");
}
}
Err(e) => {
log::error!("Failed to auto-start STT container: {}", e);
}
}
});
}
Ok(())
})
.on_window_event(|window, event| {
if let tauri::WindowEvent::CloseRequested { .. } = event {
let state = window.state::<AppState>();
tauri::async_runtime::block_on(async {
// Stop web terminal server
let mut server_guard = state.web_terminal_server.lock().await;
if let Some(server) = server_guard.take() {
server.stop();
}
// Stop STT container
let _ = docker::stt::stop_stt_container().await;
// Close all exec sessions
state.exec_manager.close_all_sessions().await;
// Release every host loopback port held by the auth bridge
state.auth_bridge.stop_all().await;
});
}
})
@@ -89,6 +162,15 @@ pub fn run() {
commands::project_commands::stop_project_container,
commands::project_commands::rebuild_project_container,
commands::project_commands::reconcile_project_statuses,
// Auth bridge
commands::auth_bridge_commands::set_auth_bridge_enabled,
commands::auth_bridge_commands::get_auth_bridge_status,
// Shared Claude Code auth token
commands::auth_token_commands::acquire_claude_token,
commands::auth_token_commands::submit_claude_token_code,
commands::auth_token_commands::cancel_claude_token,
commands::auth_token_commands::has_claude_token,
commands::auth_token_commands::clear_claude_token,
// Settings
commands::settings_commands::get_settings,
commands::settings_commands::update_settings,
@@ -102,23 +184,51 @@ pub fn run() {
commands::terminal_commands::terminal_resize,
commands::terminal_commands::close_terminal_session,
commands::terminal_commands::paste_image_to_terminal,
commands::terminal_commands::upload_host_file_to_terminal,
commands::terminal_commands::start_audio_bridge,
commands::terminal_commands::send_audio_data,
commands::terminal_commands::stop_audio_bridge,
// Files
commands::file_commands::list_container_files,
commands::file_commands::download_container_file,
commands::file_commands::download_container_backup,
commands::file_commands::upload_file_to_container,
// MCP
commands::mcp_commands::list_mcp_servers,
commands::mcp_commands::add_mcp_server,
commands::mcp_commands::update_mcp_server,
commands::mcp_commands::remove_mcp_server,
// AWS
commands::aws_commands::aws_sso_refresh,
// Updates
commands::update_commands::get_app_version,
commands::update_commands::check_for_updates,
commands::update_commands::check_image_update,
// Help
commands::help_commands::get_help_content,
// Install helper
commands::install_helper_commands::detect_install_options,
commands::install_helper_commands::run_docker_install,
// Web Terminal
commands::web_terminal_commands::start_web_terminal,
commands::web_terminal_commands::stop_web_terminal,
commands::web_terminal_commands::get_web_terminal_status,
commands::web_terminal_commands::regenerate_web_terminal_token,
// STT
commands::stt_commands::get_stt_status,
commands::stt_commands::start_stt,
commands::stt_commands::stop_stt,
commands::stt_commands::build_stt_image,
commands::stt_commands::pull_stt_image,
commands::stt_commands::transcribe_audio,
// Container introspection (sessions / capabilities / scheduler)
commands::inspect_commands::list_claude_sessions,
commands::inspect_commands::resume_session_command,
commands::inspect_commands::list_container_capabilities,
commands::inspect_commands::list_scheduled_tasks,
commands::inspect_commands::add_scheduled_task,
commands::inspect_commands::update_scheduled_task,
commands::inspect_commands::get_scheduled_task_log,
commands::inspect_commands::set_scheduled_task_enabled,
commands::inspect_commands::run_scheduled_task_now,
commands::inspect_commands::remove_scheduled_task,
commands::inspect_commands::get_scheduler_notifications,
commands::inspect_commands::clear_scheduler_notifications,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
+102 -1
View File
@@ -1,6 +1,6 @@
use serde::{Deserialize, Serialize};
use super::project::EnvVar;
use super::project::{ClaudeCodeSettings, EnvVar};
fn default_true() -> bool {
true
@@ -32,6 +32,8 @@ pub struct GlobalAwsSettings {
pub aws_profile: Option<String>,
#[serde(default)]
pub aws_region: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
impl Default for GlobalAwsSettings {
@@ -40,10 +42,27 @@ impl Default for GlobalAwsSettings {
aws_config_path: None,
aws_profile: None,
aws_region: None,
default_model_id: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOllamaSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct GlobalOpenAiCompatibleSettings {
#[serde(default)]
pub base_url: Option<String>,
#[serde(default)]
pub default_model_id: Option<String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct AppSettings {
#[serde(default)]
@@ -60,6 +79,10 @@ pub struct AppSettings {
pub custom_image_name: Option<String>,
#[serde(default)]
pub global_aws: GlobalAwsSettings,
#[serde(default)]
pub global_ollama: GlobalOllamaSettings,
#[serde(default)]
pub global_openai_compatible: GlobalOpenAiCompatibleSettings,
#[serde(default = "default_global_instructions")]
pub global_claude_instructions: Option<String>,
#[serde(default)]
@@ -72,6 +95,78 @@ pub struct AppSettings {
pub timezone: Option<String>,
#[serde(default)]
pub default_microphone: Option<String>,
#[serde(default)]
pub dismissed_image_digest: Option<String>,
#[serde(default)]
pub web_terminal: WebTerminalSettings,
#[serde(default)]
pub stt: SttSettings,
#[serde(default)]
pub global_claude_code_settings: Option<ClaudeCodeSettings>,
}
fn default_stt_model() -> String {
"tiny".to_string()
}
fn default_stt_port() -> u16 {
9876
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SttSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_stt_model")]
pub model: String,
#[serde(default = "default_stt_port")]
pub port: u16,
#[serde(default)]
pub language: Option<String>,
}
impl Default for SttSettings {
fn default() -> Self {
Self {
enabled: false,
model: default_stt_model(),
port: 9876,
language: None,
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SttStatus {
pub container_exists: bool,
pub running: bool,
pub port: u16,
pub model: String,
pub image_exists: bool,
}
fn default_web_terminal_port() -> u16 {
7681
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct WebTerminalSettings {
#[serde(default)]
pub enabled: bool,
#[serde(default = "default_web_terminal_port")]
pub port: u16,
#[serde(default)]
pub access_token: Option<String>,
}
impl Default for WebTerminalSettings {
fn default() -> Self {
Self {
enabled: false,
port: 7681,
access_token: None,
}
}
}
impl Default for AppSettings {
@@ -84,12 +179,18 @@ impl Default for AppSettings {
image_source: ImageSource::default(),
custom_image_name: None,
global_aws: GlobalAwsSettings::default(),
global_ollama: GlobalOllamaSettings::default(),
global_openai_compatible: GlobalOpenAiCompatibleSettings::default(),
global_claude_instructions: default_global_instructions(),
global_custom_env_vars: Vec::new(),
auto_check_updates: true,
dismissed_update_version: None,
timezone: None,
default_microphone: None,
dismissed_image_digest: None,
web_terminal: WebTerminalSettings::default(),
stt: SttSettings::default(),
global_claude_code_settings: None,
}
}
}
+1 -1
View File
@@ -12,7 +12,7 @@ pub struct ContainerInfo {
pub const LOCAL_IMAGE_NAME: &str = "triple-c";
pub const IMAGE_TAG: &str = "latest";
pub const REGISTRY_IMAGE: &str = "repo.anhonesthost.net/cybercovellc/triple-c/triple-c-sandbox:latest";
pub const REGISTRY_IMAGE: &str = "ghcr.io/shadowdao/triple-c-sandbox:latest";
pub fn local_build_image_name() -> String {
format!("{LOCAL_IMAGE_NAME}:{IMAGE_TAG}")
-70
View File
@@ -1,70 +0,0 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum McpTransportType {
Stdio,
#[serde(alias = "sse")]
Http,
}
impl Default for McpTransportType {
fn default() -> Self {
Self::Stdio
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct McpServer {
pub id: String,
pub name: String,
#[serde(default)]
pub transport_type: McpTransportType,
pub command: Option<String>,
#[serde(default)]
pub args: Vec<String>,
#[serde(default)]
pub env: HashMap<String, String>,
pub url: Option<String>,
#[serde(default)]
pub headers: HashMap<String, String>,
#[serde(default)]
pub docker_image: Option<String>,
#[serde(default)]
pub container_port: Option<u16>,
pub created_at: String,
pub updated_at: String,
}
impl McpServer {
pub fn new(name: String) -> Self {
let now = chrono::Utc::now().to_rfc3339();
Self {
id: uuid::Uuid::new_v4().to_string(),
name,
transport_type: McpTransportType::default(),
command: None,
args: Vec::new(),
env: HashMap::new(),
url: None,
headers: HashMap::new(),
docker_image: None,
container_port: None,
created_at: now.clone(),
updated_at: now,
}
}
pub fn is_docker(&self) -> bool {
self.docker_image.is_some()
}
pub fn mcp_container_name(&self) -> String {
format!("triple-c-mcp-{}", self.id)
}
pub fn effective_container_port(&self) -> u16 {
self.container_port.unwrap_or(3000)
}
}
-2
View File
@@ -2,10 +2,8 @@ pub mod project;
pub mod container_config;
pub mod app_settings;
pub mod update_info;
pub mod mcp_server;
pub use project::*;
pub use container_config::*;
pub use app_settings::*;
pub use update_info::*;
pub use mcp_server::*;
+164 -19
View File
@@ -1,3 +1,5 @@
use std::collections::HashMap;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
@@ -24,6 +26,92 @@ fn default_protocol() -> String {
"tcp".to_string()
}
fn default_full_permissions() -> bool {
true
}
/// `use_shared_auth_token` defaults to **on**: once the user has run
/// `claude setup-token` once, every existing Anthropic-backend project should
/// pick the token up without being edited one by one. Projects deliberately
/// pinned to their own `claude login` identity opt out.
fn default_use_shared_auth_token() -> bool {
true
}
/// How much autonomy Claude Code is granted inside the container.
///
/// Maps onto Claude Code CLI flags — see [`PermissionMode::cli_args`], which is
/// the single definition of that mapping and must be used by every call site.
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
#[serde(rename_all = "camelCase")]
pub enum PermissionMode {
/// Read-only planning mode.
Plan,
/// Claude Code's own default behavior (prompts for permission).
#[default]
Default,
/// Auto-accept file edits, prompt for everything else.
AcceptEdits,
/// Skip all permission prompts.
Bypass,
}
impl PermissionMode {
/// The CLI flags this mode adds to a `claude` invocation.
/// Defined once here so every call site stays in sync.
pub fn cli_args(&self) -> Vec<String> {
match self {
PermissionMode::Plan => vec!["--permission-mode".to_string(), "plan".to_string()],
PermissionMode::Default => Vec::new(),
PermissionMode::AcceptEdits => {
vec!["--permission-mode".to_string(), "acceptEdits".to_string()]
}
PermissionMode::Bypass => vec!["--dangerously-skip-permissions".to_string()],
}
}
/// The wire value used for the `TRIPLE_C_PERMISSION_MODE` container env var.
/// Matches the serde `camelCase` representation.
pub fn as_env_value(&self) -> &'static str {
match self {
PermissionMode::Plan => "plan",
PermissionMode::Default => "default",
PermissionMode::AcceptEdits => "acceptEdits",
PermissionMode::Bypass => "bypass",
}
}
}
/// Settings for Claude Code CLI behavior inside the container.
/// These map to Claude Code env vars and ~/.claude/settings.json entries.
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)]
pub struct ClaudeCodeSettings {
/// TUI rendering mode: None = default, Some("fullscreen") = flicker-free alt-screen
#[serde(default)]
pub tui_mode: Option<String>,
/// Effort level: None = default, Some("low"|"medium"|"high")
#[serde(default)]
pub effort: Option<String>,
/// Disable auto-scroll in fullscreen TUI mode
#[serde(default)]
pub auto_scroll_disabled: bool,
/// Enable focus mode (collapsed tool output)
#[serde(default)]
pub focus_mode: bool,
/// Show thinking summaries in responses
#[serde(default)]
pub show_thinking_summaries: bool,
/// Enable session recap when returning to a session
#[serde(default)]
pub enable_session_recap: bool,
/// Strip credentials from subprocess environments
#[serde(default)]
pub env_scrub: bool,
/// Enable 1-hour prompt cache TTL (vs default 5-minute)
#[serde(default)]
pub prompt_caching_1h: bool,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Project {
pub id: String,
@@ -31,13 +119,44 @@ pub struct Project {
pub paths: Vec<ProjectPath>,
pub container_id: Option<String>,
pub status: ProjectStatus,
pub auth_mode: AuthMode,
#[serde(alias = "auth_mode")]
pub backend: Backend,
pub bedrock_config: Option<BedrockConfig>,
pub ollama_config: Option<OllamaConfig>,
pub litellm_config: Option<LiteLlmConfig>,
#[serde(alias = "litellm_config")]
pub openai_compatible_config: Option<OpenAiCompatibleConfig>,
pub allow_docker_access: bool,
#[serde(default)]
pub sandbox_mode_enabled: bool,
#[serde(default)]
pub mission_control_enabled: bool,
/// Opt in to the auth bridge: while the container runs, its loopback
/// listeners are mirrored onto the host's loopback so browser OAuth
/// callbacks (`claude login`, `fly login`, `aws sso login`) can reach them.
/// Purely host-side — it deliberately has no container-recreation label,
/// because toggling it changes nothing about the container itself.
#[serde(default)]
pub auth_bridge_enabled: bool,
/// Use the shared, long-lived Claude Code OAuth token (from
/// `claude setup-token`, held in the OS keychain) for this project instead
/// of requiring its own `claude login`. Only consulted when `backend` is
/// [`Backend::Anthropic`] and a token has actually been stored.
///
/// Defaults to **true** so a single `setup-token` run covers every project;
/// turn it off to pin a project to the identity it logged in with inside
/// its own container.
#[serde(default = "default_use_shared_auth_token")]
pub use_shared_auth_token: bool,
/// Legacy binary permission flag. Superseded by `permission_mode`, but kept
/// because it is the value already stored in users' `projects.json`; it is
/// the fallback in `effective_permission_mode()` so old projects keep
/// behaving identically without a data migration.
#[serde(default = "default_full_permissions")]
pub full_permissions: bool,
/// Per-project permission mode. `None` means "not set yet" → fall back to
/// the legacy `full_permissions` flag.
#[serde(default)]
pub permission_mode: Option<PermissionMode>,
pub ssh_key_path: Option<String>,
#[serde(skip_serializing, default)]
pub git_token: Option<String>,
@@ -50,7 +169,10 @@ pub struct Project {
#[serde(default)]
pub claude_instructions: Option<String>,
#[serde(default)]
pub enabled_mcp_servers: Vec<String>,
pub claude_code_settings: Option<ClaudeCodeSettings>,
/// User-defined display names for terminal tabs, keyed by session id.
#[serde(default)]
pub renamed_session_names: HashMap<String, String>,
pub created_at: String,
pub updated_at: String,
}
@@ -65,23 +187,24 @@ pub enum ProjectStatus {
Error,
}
/// How the project authenticates with Claude.
/// - `Anthropic`: User runs `claude login` inside the container (OAuth via Anthropic Console,
/// persisted in the config volume)
/// - `Bedrock`: Uses AWS Bedrock with per-project AWS credentials
/// Which AI model backend/provider the project uses.
/// - `Anthropic`: Direct Anthropic API (user runs `claude login` inside the container)
/// - `Bedrock`: AWS Bedrock with per-project AWS credentials
/// - `Ollama`: Local or remote Ollama server
/// - `OpenAiCompatible`: Any OpenAI API-compatible endpoint (e.g., LiteLLM, vLLM, etc.)
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
#[serde(rename_all = "snake_case")]
pub enum AuthMode {
pub enum Backend {
/// Backward compat: old projects stored as "login" or "api_key" map to Anthropic.
#[serde(alias = "login", alias = "api_key")]
Anthropic,
Bedrock,
Ollama,
#[serde(alias = "litellm")]
LiteLlm,
#[serde(alias = "lite_llm", alias = "litellm")]
OpenAiCompatible,
}
impl Default for AuthMode {
impl Default for Backend {
fn default() -> Self {
Self::Anthropic
}
@@ -118,6 +241,10 @@ pub struct BedrockConfig {
pub aws_bearer_token: Option<String>,
pub model_id: Option<String>,
pub disable_prompt_caching: bool,
/// Optional value for the `ANTHROPIC_BEDROCK_SERVICE_TIER` env var
/// (e.g. "priority"). Empty/None means leave unset.
#[serde(default)]
pub service_tier: Option<String>,
}
/// Ollama configuration for a project.
@@ -130,13 +257,14 @@ pub struct OllamaConfig {
pub model_id: Option<String>,
}
/// LiteLLM gateway configuration for a project.
/// LiteLLM translates Anthropic API calls to 100+ model providers.
/// OpenAI Compatible endpoint configuration for a project.
/// Routes Anthropic API calls through any OpenAI API-compatible endpoint
/// (e.g., LiteLLM, vLLM, or other compatible gateways).
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct LiteLlmConfig {
/// The base URL of the LiteLLM proxy (e.g., "http://host.docker.internal:4000" or "https://litellm.example.com")
pub struct OpenAiCompatibleConfig {
/// The base URL of the OpenAI-compatible endpoint (e.g., "http://host.docker.internal:4000" or "https://api.example.com")
pub base_url: String,
/// API key for the LiteLLM proxy
/// API key for the OpenAI-compatible endpoint
#[serde(skip_serializing, default)]
pub api_key: Option<String>,
/// Optional model override
@@ -152,12 +280,17 @@ impl Project {
paths,
container_id: None,
status: ProjectStatus::Stopped,
auth_mode: AuthMode::default(),
backend: Backend::default(),
bedrock_config: None,
ollama_config: None,
litellm_config: None,
openai_compatible_config: None,
allow_docker_access: false,
sandbox_mode_enabled: false,
mission_control_enabled: false,
auth_bridge_enabled: false,
use_shared_auth_token: default_use_shared_auth_token(),
full_permissions: false,
permission_mode: None,
ssh_key_path: None,
git_token: None,
git_user_name: None,
@@ -165,12 +298,24 @@ impl Project {
custom_env_vars: Vec::new(),
port_mappings: Vec::new(),
claude_instructions: None,
enabled_mcp_servers: Vec::new(),
claude_code_settings: None,
renamed_session_names: HashMap::new(),
created_at: now.clone(),
updated_at: now,
}
}
/// The permission mode to actually use for this project.
/// Falls back to the legacy `full_permissions` boolean when the newer
/// `permission_mode` field has never been set.
pub fn effective_permission_mode(&self) -> PermissionMode {
self.permission_mode.unwrap_or(if self.full_permissions {
PermissionMode::Bypass
} else {
PermissionMode::Default
})
}
pub fn container_name(&self) -> String {
format!("triple-c-{}", self.id)
}
+16 -5
View File
@@ -18,20 +18,31 @@ pub struct ReleaseAsset {
pub size: u64,
}
/// Gitea API release response (internal).
/// GitHub API release response (internal).
#[derive(Debug, Clone, Deserialize)]
pub struct GiteaRelease {
pub struct GitHubRelease {
pub tag_name: String,
pub html_url: String,
pub body: String,
pub assets: Vec<GiteaAsset>,
pub assets: Vec<GitHubAsset>,
pub published_at: String,
}
/// Gitea API asset response (internal).
/// GitHub API asset response (internal).
#[derive(Debug, Clone, Deserialize)]
pub struct GiteaAsset {
pub struct GitHubAsset {
pub name: String,
pub browser_download_url: String,
pub size: u64,
}
/// Info returned to the frontend about an available container image update.
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ImageUpdateInfo {
/// The remote digest (e.g. sha256:abc...)
pub remote_digest: String,
/// The local digest, if available
pub local_digest: Option<String>,
/// When the remote image was last updated (if known)
pub remote_updated_at: Option<String>,
}
-106
View File
@@ -1,106 +0,0 @@
use std::fs;
use std::path::PathBuf;
use std::sync::Mutex;
use crate::models::McpServer;
pub struct McpStore {
servers: Mutex<Vec<McpServer>>,
file_path: PathBuf,
}
impl McpStore {
pub fn new() -> Result<Self, String> {
let data_dir = dirs::data_dir()
.ok_or_else(|| "Could not determine data directory. Set XDG_DATA_HOME on Linux.".to_string())?
.join("triple-c");
fs::create_dir_all(&data_dir).ok();
let file_path = data_dir.join("mcp_servers.json");
let servers = if file_path.exists() {
match fs::read_to_string(&file_path) {
Ok(data) => {
match serde_json::from_str::<Vec<McpServer>>(&data) {
Ok(parsed) => parsed,
Err(e) => {
log::error!("Failed to parse mcp_servers.json: {}. Starting with empty list.", e);
let backup = file_path.with_extension("json.bak");
if let Err(be) = fs::copy(&file_path, &backup) {
log::error!("Failed to back up corrupted mcp_servers.json: {}", be);
}
Vec::new()
}
}
}
Err(e) => {
log::error!("Failed to read mcp_servers.json: {}", e);
Vec::new()
}
}
} else {
Vec::new()
};
Ok(Self {
servers: Mutex::new(servers),
file_path,
})
}
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<McpServer>> {
self.servers.lock().unwrap_or_else(|e| e.into_inner())
}
fn save(&self, servers: &[McpServer]) -> Result<(), String> {
let data = serde_json::to_string_pretty(servers)
.map_err(|e| format!("Failed to serialize MCP servers: {}", e))?;
// Atomic write: write to temp file, then rename
let tmp_path = self.file_path.with_extension("json.tmp");
fs::write(&tmp_path, data)
.map_err(|e| format!("Failed to write temp MCP servers file: {}", e))?;
fs::rename(&tmp_path, &self.file_path)
.map_err(|e| format!("Failed to rename MCP servers file: {}", e))?;
Ok(())
}
pub fn list(&self) -> Vec<McpServer> {
self.lock().clone()
}
pub fn get(&self, id: &str) -> Option<McpServer> {
self.lock().iter().find(|s| s.id == id).cloned()
}
pub fn add(&self, server: McpServer) -> Result<McpServer, String> {
let mut servers = self.lock();
let cloned = server.clone();
servers.push(server);
self.save(&servers)?;
Ok(cloned)
}
pub fn update(&self, updated: McpServer) -> Result<McpServer, String> {
let mut servers = self.lock();
if let Some(s) = servers.iter_mut().find(|s| s.id == updated.id) {
*s = updated.clone();
self.save(&servers)?;
Ok(updated)
} else {
Err(format!("MCP server {} not found", updated.id))
}
}
pub fn remove(&self, id: &str) -> Result<(), String> {
let mut servers = self.lock();
let initial_len = servers.len();
servers.retain(|s| s.id != id);
if servers.len() == initial_len {
return Err(format!("MCP server {} not found", id));
}
self.save(&servers)?;
Ok(())
}
}
-3
View File
@@ -1,7 +1,6 @@
pub mod projects_store;
pub mod secure;
pub mod settings_store;
pub mod mcp_store;
#[allow(unused_imports)]
pub use projects_store::*;
@@ -9,5 +8,3 @@ pub use projects_store::*;
pub use secure::*;
#[allow(unused_imports)]
pub use settings_store::*;
#[allow(unused_imports)]
pub use mcp_store::*;
@@ -177,6 +177,20 @@ impl ProjectsStore {
}
}
/// Granular setter for the auth bridge opt-in, so toggling it can't clobber
/// concurrent edits to the rest of the project record.
pub fn set_auth_bridge_enabled(&self, project_id: &str, enabled: bool) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
p.auth_bridge_enabled = enabled;
p.updated_at = chrono::Utc::now().to_rfc3339();
self.save(&projects)?;
Ok(())
} else {
Err(format!("Project {} not found", project_id))
}
}
pub fn set_container_id(&self, project_id: &str, container_id: Option<String>) -> Result<(), String> {
let mut projects = self.lock();
if let Some(p) = projects.iter_mut().find(|p| p.id == project_id) {
+113
View File
@@ -1,3 +1,31 @@
//! OS keychain access, via the `keyring` crate.
//!
//! Two kinds of secret live here:
//! * **per-project** secrets (git token, AWS keys, …), keyed by project id;
//! * the **shared Claude Code OAuth token**, which is global — one
//! `claude setup-token` run authenticates every Anthropic-backend project.
//!
//! Nothing in this module ever logs a secret or folds one into an error string.
/// Keychain service for the single, global Claude Code OAuth token minted by
/// `claude setup-token` and consumed via `CLAUDE_CODE_OAUTH_TOKEN`.
const CLAUDE_TOKEN_SERVICE: &str = "triple-c-claude-oauth-token";
/// Keychain service for the token's **rotation id** — a fresh random value
/// written every time the token is stored.
///
/// Container recreation is driven off Docker labels, which anything on the host
/// can read with `docker inspect`. The token itself must obviously not go in a
/// label, and neither should a bare hash of it: a hash is a verification oracle
/// (holding a candidate token, you could confirm it). This id is not derived
/// from the token at all — it is unrelated random data that merely *changes*
/// whenever the token does, which is exactly (and only) what change detection
/// needs.
const CLAUDE_TOKEN_VERSION_SERVICE: &str = "triple-c-claude-oauth-token-version";
/// Fixed account name used for every triple-c keychain entry.
const KEYCHAIN_ACCOUNT: &str = "secret";
/// Store a per-project secret in the OS keychain.
pub fn store_project_secret(project_id: &str, key_name: &str, value: &str) -> Result<(), String> {
let service = format!("triple-c-project-{}-{}", project_id, key_name);
@@ -43,3 +71,88 @@ pub fn delete_project_secrets(project_id: &str) -> Result<(), String> {
}
Ok(())
}
// ─────────────────────────────────────────────────────────────────────────────
// Shared Claude Code OAuth token (global, not per project)
// ─────────────────────────────────────────────────────────────────────────────
/// Read a single-value keychain entry. `Ok(None)` when the entry is absent.
/// The error text names the entry, never its value.
fn read_entry(service: &str, label: &str) -> Result<Option<String>, String> {
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.get_password() {
Ok(value) => Ok(Some(value)),
Err(keyring::Error::NoEntry) => Ok(None),
Err(e) => Err(format!("Failed to retrieve {}: {}", label, e)),
}
}
/// Delete a keychain entry, treating "wasn't there" as success.
fn delete_entry(service: &str, label: &str) -> Result<(), String> {
let entry = keyring::Entry::new(service, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
match entry.delete_credential() {
Ok(()) | Err(keyring::Error::NoEntry) => Ok(()),
Err(e) => Err(format!("Failed to delete {}: {}", label, e)),
}
}
/// Store the shared Claude Code OAuth token, replacing any previous one, and
/// mint a fresh rotation id so containers holding the old token are flagged for
/// recreation. Blank input is rejected rather than silently stored.
pub fn store_claude_oauth_token(token: &str) -> Result<(), String> {
if token.trim().is_empty() {
return Err("Refusing to store an empty Claude authentication token.".to_string());
}
let entry = keyring::Entry::new(CLAUDE_TOKEN_SERVICE, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
entry
.set_password(token)
.map_err(|e| format!("Failed to store the Claude authentication token: {}", e))?;
// Rotation id second: if this fails the token is still usable, and the
// stale id only costs one extra container recreation later.
let version = uuid::Uuid::new_v4().to_string();
let version_entry = keyring::Entry::new(CLAUDE_TOKEN_VERSION_SERVICE, KEYCHAIN_ACCOUNT)
.map_err(|e| format!("Keyring error: {}", e))?;
version_entry
.set_password(&version)
.map_err(|e| format!("Failed to store the Claude token rotation id: {}", e))?;
Ok(())
}
/// Retrieve the shared Claude Code OAuth token, if one has been stored.
pub fn get_claude_oauth_token() -> Result<Option<String>, String> {
read_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token")
}
/// The rotation id of the currently stored token. Opaque random data — safe to
/// put in a Docker label, unlike the token or any hash of it.
pub fn get_claude_oauth_token_version() -> Result<Option<String>, String> {
read_entry(
CLAUDE_TOKEN_VERSION_SERVICE,
"the Claude token rotation id",
)
}
/// Whether a shared Claude Code OAuth token is currently stored. A keychain
/// failure is reported as "no token" rather than surfacing as an error, so the
/// UI degrades to the un-authenticated state instead of breaking.
pub fn has_claude_oauth_token() -> bool {
matches!(get_claude_oauth_token(), Ok(Some(t)) if !t.trim().is_empty())
}
/// Delete the shared Claude Code OAuth token and its rotation id. Both are
/// attempted even if the first fails, so a partial failure cannot strand the
/// token behind a deleted id.
pub fn delete_claude_oauth_token() -> Result<(), String> {
let token_result = delete_entry(CLAUDE_TOKEN_SERVICE, "the Claude authentication token");
let version_result = delete_entry(
CLAUDE_TOKEN_VERSION_SERVICE,
"the Claude token rotation id",
);
token_result.and(version_result)
}
+4
View File
@@ -0,0 +1,4 @@
pub mod server;
mod ws_handler;
pub use server::WebTerminalServer;
+155
View File
@@ -0,0 +1,155 @@
use std::sync::Arc;
use axum::extract::{Query, State as AxumState, WebSocketUpgrade};
use axum::response::{Html, IntoResponse};
use axum::routing::get;
use axum::Router;
use serde::{Deserialize, Serialize};
use tokio::sync::watch;
use tower_http::cors::CorsLayer;
use crate::docker::exec::ExecSessionManager;
use crate::storage::projects_store::ProjectsStore;
use crate::storage::settings_store::SettingsStore;
use super::ws_handler;
/// Shared state passed to all axum handlers.
pub struct WebTerminalState {
pub exec_manager: Arc<ExecSessionManager>,
pub projects_store: Arc<ProjectsStore>,
pub settings_store: Arc<SettingsStore>,
pub access_token: String,
}
/// Manages the lifecycle of the axum HTTP+WS server.
pub struct WebTerminalServer {
shutdown_tx: watch::Sender<()>,
port: u16,
}
#[derive(Deserialize)]
pub struct TokenQuery {
pub token: Option<String>,
}
#[derive(Serialize)]
struct ProjectInfo {
id: String,
name: String,
status: String,
}
impl WebTerminalServer {
/// Start the web terminal server on the given port.
pub async fn start(
port: u16,
access_token: String,
exec_manager: Arc<ExecSessionManager>,
projects_store: Arc<ProjectsStore>,
settings_store: Arc<SettingsStore>,
) -> Result<Self, String> {
let (shutdown_tx, shutdown_rx) = watch::channel(());
let shared_state = Arc::new(WebTerminalState {
exec_manager,
projects_store,
settings_store,
access_token,
});
let app = Router::new()
.route("/", get(serve_html))
.route("/ws", get(ws_upgrade))
.route("/api/projects", get(list_projects))
.layer(CorsLayer::permissive())
.with_state(shared_state);
let addr = format!("0.0.0.0:{}", port);
let listener = tokio::net::TcpListener::bind(&addr)
.await
.map_err(|e| format!("Failed to bind web terminal to {}: {}", addr, e))?;
log::info!("Web terminal server listening on {}", addr);
let mut shutdown_rx_clone = shutdown_rx.clone();
tokio::spawn(async move {
axum::serve(listener, app)
.with_graceful_shutdown(async move {
let _ = shutdown_rx_clone.changed().await;
})
.await
.unwrap_or_else(|e| {
log::error!("Web terminal server error: {}", e);
});
log::info!("Web terminal server shut down");
});
Ok(Self { shutdown_tx, port })
}
/// Stop the server gracefully.
pub fn stop(&self) {
log::info!("Stopping web terminal server on port {}", self.port);
let _ = self.shutdown_tx.send(());
}
pub fn port(&self) -> u16 {
self.port
}
}
/// Serve the embedded HTML page.
async fn serve_html() -> Html<&'static str> {
Html(include_str!("terminal.html"))
}
/// Validate token from query params.
fn validate_token(state: &WebTerminalState, token: &Option<String>) -> bool {
match token {
Some(t) => t == &state.access_token,
None => false,
}
}
/// WebSocket upgrade handler.
async fn ws_upgrade(
ws: WebSocketUpgrade,
AxumState(state): AxumState<Arc<WebTerminalState>>,
Query(query): Query<TokenQuery>,
) -> impl IntoResponse {
if !validate_token(&state, &query.token) {
return (axum::http::StatusCode::UNAUTHORIZED, "Invalid token").into_response();
}
ws.on_upgrade(move |socket| ws_handler::handle_connection(socket, state))
.into_response()
}
/// List running projects (REST endpoint).
async fn list_projects(
AxumState(state): AxumState<Arc<WebTerminalState>>,
Query(query): Query<TokenQuery>,
) -> impl IntoResponse {
if !validate_token(&state, &query.token) {
return (
axum::http::StatusCode::UNAUTHORIZED,
axum::Json(serde_json::json!({"error": "Invalid token"})),
)
.into_response();
}
let projects = state.projects_store.list();
let infos: Vec<ProjectInfo> = projects
.into_iter()
.map(|p| ProjectInfo {
id: p.id,
name: p.name,
status: serde_json::to_value(&p.status)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string()),
})
.collect();
axum::Json(infos).into_response()
}
@@ -0,0 +1,669 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<title>Triple-C Web Terminal</title>
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/css/xterm.min.css">
<script src="https://cdn.jsdelivr.net/npm/@xterm/xterm@5.5.0/lib/xterm.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-fit@0.10.0/lib/addon-fit.min.js"></script>
<script src="https://cdn.jsdelivr.net/npm/@xterm/addon-web-links@0.11.0/lib/addon-web-links.min.js"></script>
<style>
:root {
--bg-primary: #1a1b26;
--bg-secondary: #24283b;
--bg-tertiary: #2f3347;
--text-primary: #c0caf5;
--text-secondary: #565f89;
--accent: #7aa2f7;
--accent-hover: #89b4fa;
--border: #3b3f57;
--success: #9ece6a;
--warning: #e0af68;
--error: #f7768e;
}
* { margin: 0; padding: 0; box-sizing: border-box; }
body {
background: var(--bg-primary);
color: var(--text-primary);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
height: 100vh;
height: 100dvh; /* dynamic viewport height — shrinks when mobile keyboard opens */
display: flex;
flex-direction: column;
overflow: hidden;
-webkit-tap-highlight-color: transparent;
}
/* ── Top Bar ─────────────────────────────── */
.topbar {
display: flex;
align-items: center;
gap: 8px;
padding: 6px 12px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
min-height: 42px;
position: sticky;
top: 0;
z-index: 20;
}
.topbar-title {
font-size: 13px;
font-weight: 600;
color: var(--accent);
white-space: nowrap;
margin-right: 8px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
background: var(--error);
flex-shrink: 0;
}
.status-dot.connected { background: var(--success); }
.status-dot.reconnecting { background: var(--warning); animation: pulse 1s infinite; }
@keyframes pulse { 0%,100% { opacity: 1; } 50% { opacity: 0.4; } }
select, button {
font-size: 12px;
padding: 4px 8px;
background: var(--bg-tertiary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 4px;
cursor: pointer;
touch-action: manipulation;
}
select:focus, button:focus { outline: none; border-color: var(--accent); }
button:hover { background: var(--border); }
button:active { background: var(--accent); color: var(--bg-primary); }
.btn-new {
font-weight: 600;
min-width: 44px;
min-height: 32px;
}
/* ── Tab Bar ─────────────────────────────── */
.tabbar {
display: flex;
align-items: center;
gap: 1px;
padding: 0 8px;
background: var(--bg-secondary);
border-bottom: 1px solid var(--border);
flex-shrink: 0;
overflow-x: auto;
-webkit-overflow-scrolling: touch;
min-height: 32px;
position: sticky;
top: 42px; /* below .topbar min-height */
z-index: 20;
}
.tab {
display: flex;
align-items: center;
gap: 6px;
padding: 6px 12px;
font-size: 11px;
color: var(--text-secondary);
cursor: pointer;
white-space: nowrap;
border-bottom: 2px solid transparent;
transition: all 0.15s;
min-height: 32px;
}
.tab:hover { color: var(--text-primary); }
.tab.active {
color: var(--text-primary);
border-bottom-color: var(--accent);
}
.tab-close {
display: inline-flex;
align-items: center;
justify-content: center;
width: 16px;
height: 16px;
border-radius: 3px;
font-size: 12px;
line-height: 1;
color: var(--text-secondary);
background: none;
border: none;
padding: 0;
min-width: unset;
min-height: unset;
}
.tab-close:hover { background: var(--error); color: white; }
/* ── Terminal Area ───────────────────────── */
.terminal-area {
flex: 1;
position: relative;
overflow: hidden;
}
.terminal-container {
position: absolute;
inset: 0;
display: none;
padding: 4px 4px 16px 4px;
}
.terminal-container.active { display: block; }
/* ── Input Bar (mobile/tablet) ──────────── */
.input-bar {
display: flex;
align-items: center;
gap: 4px;
padding: 6px 8px;
background: var(--bg-secondary);
border-top: 1px solid var(--border);
flex-shrink: 0;
}
.input-bar input {
flex: 1;
min-width: 0;
padding: 8px 10px;
font-size: 16px; /* prevents iOS zoom on focus */
font-family: 'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace;
background: var(--bg-primary);
color: var(--text-primary);
border: 1px solid var(--border);
border-radius: 6px;
outline: none;
-webkit-appearance: none;
}
.input-bar input:focus { border-color: var(--accent); }
.input-bar .key-btn {
padding: 8px 10px;
font-size: 11px;
font-weight: 600;
min-width: 40px;
min-height: 36px;
border-radius: 6px;
white-space: nowrap;
}
/* ── Scroll-to-bottom FAB ──────────────── */
.scroll-bottom-btn {
position: absolute;
bottom: 12px;
right: 16px;
width: 36px;
height: 36px;
border-radius: 50%;
background: var(--accent);
color: var(--bg-primary);
border: none;
font-size: 18px;
font-weight: bold;
display: none;
align-items: center;
justify-content: center;
cursor: pointer;
box-shadow: 0 2px 8px rgba(0,0,0,0.4);
z-index: 10;
padding: 0;
min-width: unset;
min-height: unset;
line-height: 1;
}
.scroll-bottom-btn:hover { background: var(--accent-hover); }
.scroll-bottom-btn.visible { display: flex; }
/* ── Empty State ─────────────────────────── */
.empty-state {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100%;
color: var(--text-secondary);
font-size: 14px;
gap: 12px;
}
.empty-state .hint {
font-size: 12px;
opacity: 0.7;
}
/* ── Scrollbar ───────────────────────────── */
::-webkit-scrollbar { width: 6px; height: 6px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
</style>
</head>
<body>
<!-- Top Bar -->
<div class="topbar">
<span class="topbar-title">Triple-C</span>
<span class="status-dot" id="statusDot"></span>
<select id="projectSelect" style="flex:1; max-width:240px;">
<option value="">Select project...</option>
</select>
<button class="btn-new" id="btnClaude" title="New Claude session">Claude</button>
<button class="btn-new" id="btnBash" title="New Bash session">Bash</button>
</div>
<!-- Tab Bar -->
<div class="tabbar" id="tabbar"></div>
<!-- Terminal Area -->
<div class="terminal-area" id="terminalArea">
<div class="empty-state" id="emptyState">
<div>Select a project and open a terminal session</div>
<div class="hint">Use the buttons above to start a Claude or Bash session</div>
</div>
<button class="scroll-bottom-btn" id="scrollBottomBtn" title="Scroll to bottom">&#8595;</button>
</div>
<!-- Input Bar for mobile/tablet -->
<div class="input-bar" id="inputBar">
<input type="text" id="mobileInput" placeholder="Type here..."
autocomplete="off" autocorrect="off" autocapitalize="off" spellcheck="false"
enterkeyhint="send" inputmode="text">
<button class="key-btn" id="btnEnter">Enter</button>
<button class="key-btn" id="btnTab">Tab</button>
<button class="key-btn" id="btnCtrlC">^C</button>
</div>
<script>
(function() {
'use strict';
// ── State ──────────────────────────────────
const params = new URLSearchParams(window.location.search);
const TOKEN = params.get('token') || '';
let ws = null;
let reconnectTimer = null;
let sessions = {}; // { sessionId: { term, fitAddon, projectName, type, containerId } }
let activeSessionId = null;
// ── DOM refs ───────────────────────────────
const statusDot = document.getElementById('statusDot');
const projectSelect = document.getElementById('projectSelect');
const btnClaude = document.getElementById('btnClaude');
const btnBash = document.getElementById('btnBash');
const tabbar = document.getElementById('tabbar');
const terminalArea = document.getElementById('terminalArea');
const emptyState = document.getElementById('emptyState');
const mobileInput = document.getElementById('mobileInput');
const btnEnter = document.getElementById('btnEnter');
const btnTab = document.getElementById('btnTab');
const btnCtrlC = document.getElementById('btnCtrlC');
const scrollBottomBtn = document.getElementById('scrollBottomBtn');
// ── WebSocket ──────────────────────────────
function connect() {
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
const url = `${proto}//${location.host}/ws?token=${encodeURIComponent(TOKEN)}`;
ws = new WebSocket(url);
ws.onopen = () => {
statusDot.className = 'status-dot connected';
clearTimeout(reconnectTimer);
send({ type: 'list_projects' });
// Start keepalive
ws._pingInterval = setInterval(() => send({ type: 'ping' }), 30000);
};
ws.onmessage = (evt) => {
try {
const msg = JSON.parse(evt.data);
handleMessage(msg);
} catch (e) {
console.error('Parse error:', e);
}
};
ws.onclose = () => {
statusDot.className = 'status-dot reconnecting';
if (ws && ws._pingInterval) clearInterval(ws._pingInterval);
reconnectTimer = setTimeout(connect, 2000);
};
ws.onerror = () => {
ws.close();
};
}
function send(msg) {
if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify(msg));
}
}
// ── Message handling ───────────────────────
function handleMessage(msg) {
switch (msg.type) {
case 'projects':
updateProjectList(msg.projects);
break;
case 'opened':
onSessionOpened(msg.session_id, msg.project_name);
break;
case 'output':
onSessionOutput(msg.session_id, msg.data);
break;
case 'exit':
onSessionExit(msg.session_id);
break;
case 'error':
console.error('Server error:', msg.message);
// Show in active terminal if available
if (activeSessionId && sessions[activeSessionId]) {
sessions[activeSessionId].term.writeln(`\r\n\x1b[31mError: ${msg.message}\x1b[0m`);
}
break;
case 'pong':
break;
}
}
function updateProjectList(projects) {
const current = projectSelect.value;
projectSelect.innerHTML = '<option value="">Select project...</option>';
projects.forEach(p => {
const opt = document.createElement('option');
opt.value = p.id;
opt.textContent = `${p.name} (${p.status})`;
opt.disabled = p.status !== 'running';
projectSelect.appendChild(opt);
});
// Restore selection if still valid
if (current) projectSelect.value = current;
}
// ── Session management ─────────────────────
let pendingSessionType = null;
function openSession(type) {
const projectId = projectSelect.value;
if (!projectId) {
alert('Please select a running project first.');
return;
}
pendingSessionType = type;
send({
type: 'open',
project_id: projectId,
session_type: type,
});
}
function onSessionOpened(sessionId, projectName) {
const sessionType = pendingSessionType || 'claude';
pendingSessionType = null;
// Create terminal
const term = new Terminal({
theme: {
background: '#1a1b26',
foreground: '#c0caf5',
cursor: '#c0caf5',
selectionBackground: '#33467c',
black: '#15161e',
red: '#f7768e',
green: '#9ece6a',
yellow: '#e0af68',
blue: '#7aa2f7',
magenta: '#bb9af7',
cyan: '#7dcfff',
white: '#a9b1d6',
brightBlack: '#414868',
brightRed: '#f7768e',
brightGreen: '#9ece6a',
brightYellow: '#e0af68',
brightBlue: '#7aa2f7',
brightMagenta: '#bb9af7',
brightCyan: '#7dcfff',
brightWhite: '#c0caf5',
},
fontSize: 14,
fontFamily: "'Cascadia Code', 'Fira Code', 'JetBrains Mono', 'Menlo', monospace",
cursorBlink: true,
allowProposedApi: true,
});
const fitAddon = new FitAddon.FitAddon();
term.loadAddon(fitAddon);
const webLinksAddon = new WebLinksAddon.WebLinksAddon();
term.loadAddon(webLinksAddon);
// Create container div
const container = document.createElement('div');
container.className = 'terminal-container';
container.id = `term-${sessionId}`;
terminalArea.appendChild(container);
term.open(container);
fitAddon.fit();
// Send initial resize
send({
type: 'resize',
session_id: sessionId,
cols: term.cols,
rows: term.rows,
});
// Handle user input
term.onData(data => {
const bytes = new TextEncoder().encode(data);
const b64 = btoa(String.fromCharCode(...bytes));
send({
type: 'input',
session_id: sessionId,
data: b64,
});
});
// Track scroll position for scroll-to-bottom button
term.onScroll(() => updateScrollButton());
// Store session
sessions[sessionId] = { term, fitAddon, projectName, type: sessionType, container };
// Add tab and switch to it
addTab(sessionId, projectName, sessionType);
switchToSession(sessionId);
emptyState.style.display = 'none';
}
function onSessionOutput(sessionId, b64data) {
const session = sessions[sessionId];
if (!session) return;
const bytes = Uint8Array.from(atob(b64data), c => c.charCodeAt(0));
session.term.write(bytes);
// Update scroll button if this is the active session
if (sessionId === activeSessionId) updateScrollButton();
}
function onSessionExit(sessionId) {
const session = sessions[sessionId];
if (!session) return;
session.term.writeln('\r\n\x1b[90m[Session ended]\x1b[0m');
}
function closeSession(sessionId) {
send({ type: 'close', session_id: sessionId });
removeSession(sessionId);
}
function removeSession(sessionId) {
const session = sessions[sessionId];
if (!session) return;
session.term.dispose();
session.container.remove();
delete sessions[sessionId];
// Remove tab
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) tab.remove();
// Switch to another session or show empty state
const remaining = Object.keys(sessions);
if (remaining.length > 0) {
switchToSession(remaining[remaining.length - 1]);
} else {
activeSessionId = null;
emptyState.style.display = '';
}
}
// ── Tab bar ────────────────────────────────
function addTab(sessionId, projectName, sessionType) {
const tab = document.createElement('div');
tab.className = 'tab';
tab.id = `tab-${sessionId}`;
const label = document.createElement('span');
label.textContent = `${projectName} (${sessionType})`;
tab.appendChild(label);
const close = document.createElement('button');
close.className = 'tab-close';
close.textContent = '\u00d7';
close.onclick = (e) => { e.stopPropagation(); closeSession(sessionId); };
tab.appendChild(close);
tab.onclick = () => switchToSession(sessionId);
tabbar.appendChild(tab);
}
function switchToSession(sessionId) {
activeSessionId = sessionId;
// Update tab styles
document.querySelectorAll('.tab').forEach(t => t.classList.remove('active'));
const tab = document.getElementById(`tab-${sessionId}`);
if (tab) tab.classList.add('active');
// Show/hide terminal containers
document.querySelectorAll('.terminal-container').forEach(c => c.classList.remove('active'));
const container = document.getElementById(`term-${sessionId}`);
if (container) {
container.classList.add('active');
const session = sessions[sessionId];
if (session) {
// Fit after making visible
requestAnimationFrame(() => {
session.fitAddon.fit();
session.term.focus();
updateScrollButton();
});
}
}
}
// ── Resize handling ────────────────────────
function handleResize() {
if (activeSessionId && sessions[activeSessionId]) {
const session = sessions[activeSessionId];
session.fitAddon.fit();
send({
type: 'resize',
session_id: activeSessionId,
cols: session.term.cols,
rows: session.term.rows,
});
}
}
let resizeTimeout;
window.addEventListener('resize', () => {
clearTimeout(resizeTimeout);
resizeTimeout = setTimeout(handleResize, 100);
});
// ── Send helper ─────────────────────────────
function sendTerminalInput(str) {
if (!activeSessionId) return;
const bytes = new TextEncoder().encode(str);
const b64 = btoa(String.fromCharCode(...bytes));
send({
type: 'input',
session_id: activeSessionId,
data: b64,
});
}
// ── Input bar (mobile/tablet) ──────────────
// Send characters immediately, bypassing IME composition buffering.
// Clearing value on each input event cancels any active composition.
mobileInput.addEventListener('input', () => {
const val = mobileInput.value;
if (val) {
sendTerminalInput(val);
mobileInput.value = '';
}
});
// Catch Enter in the input field itself
mobileInput.addEventListener('keydown', (e) => {
if (e.key === 'Enter') {
e.preventDefault();
const val = mobileInput.value;
if (val) {
sendTerminalInput(val);
mobileInput.value = '';
}
sendTerminalInput('\r');
} else if (e.key === 'Tab') {
e.preventDefault();
sendTerminalInput('\t');
}
});
btnEnter.onclick = () => { sendTerminalInput('\r'); mobileInput.focus(); };
btnTab.onclick = () => { sendTerminalInput('\t'); mobileInput.focus(); };
btnCtrlC.onclick = () => { sendTerminalInput('\x03'); mobileInput.focus(); };
// ── Scroll to bottom ──────────────────────
function updateScrollButton() {
if (!activeSessionId || !sessions[activeSessionId]) {
scrollBottomBtn.classList.remove('visible');
return;
}
const term = sessions[activeSessionId].term;
const isAtBottom = term.buffer.active.viewportY >= term.buffer.active.baseY;
scrollBottomBtn.classList.toggle('visible', !isAtBottom);
}
scrollBottomBtn.onclick = () => {
if (activeSessionId && sessions[activeSessionId]) {
sessions[activeSessionId].term.scrollToBottom();
scrollBottomBtn.classList.remove('visible');
}
};
// ── Event listeners ────────────────────────
btnClaude.onclick = () => openSession('claude');
btnBash.onclick = () => openSession('bash');
// ── Init ───────────────────────────────────
connect();
})();
</script>
</body>
</html>
@@ -0,0 +1,325 @@
use std::sync::Arc;
use axum::extract::ws::{Message, WebSocket};
use base64::engine::general_purpose::STANDARD as BASE64;
use base64::Engine;
use futures_util::{SinkExt, StreamExt};
use serde::{Deserialize, Serialize};
use tokio::sync::mpsc;
use crate::commands::aws_commands;
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectStatus};
use super::server::WebTerminalState;
// ── Wire protocol types ──────────────────────────────────────────────
#[derive(Deserialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ClientMessage {
ListProjects,
Open {
project_id: String,
session_type: Option<String>,
},
Input {
session_id: String,
data: String, // base64
},
Resize {
session_id: String,
cols: u16,
rows: u16,
},
Close {
session_id: String,
},
Ping,
}
#[derive(Serialize)]
#[serde(tag = "type", rename_all = "snake_case")]
enum ServerMessage {
Projects {
projects: Vec<ProjectEntry>,
},
Opened {
session_id: String,
project_name: String,
},
Output {
session_id: String,
data: String, // base64
},
Exit {
session_id: String,
},
Error {
message: String,
},
Pong,
}
#[derive(Serialize)]
struct ProjectEntry {
id: String,
name: String,
status: String,
}
// ── Connection handler ───────────────────────────────────────────────
pub async fn handle_connection(socket: WebSocket, state: Arc<WebTerminalState>) {
let (mut ws_tx, mut ws_rx) = socket.split();
// Channel for sending messages from session output tasks → WS writer
let (out_tx, mut out_rx) = mpsc::unbounded_channel::<ServerMessage>();
// Track session IDs owned by this connection for cleanup
let owned_sessions: Arc<tokio::sync::Mutex<Vec<String>>> =
Arc::new(tokio::sync::Mutex::new(Vec::new()));
// Writer task: serializes ServerMessages and sends as WS text frames
let writer_handle = tokio::spawn(async move {
while let Some(msg) = out_rx.recv().await {
if let Ok(json) = serde_json::to_string(&msg) {
if ws_tx.send(Message::Text(json.into())).await.is_err() {
break;
}
}
}
});
// Reader loop: parse incoming messages and dispatch
while let Some(Ok(msg)) = ws_rx.next().await {
let text = match &msg {
Message::Text(t) => t.to_string(),
Message::Close(_) => break,
_ => continue,
};
let client_msg: ClientMessage = match serde_json::from_str(&text) {
Ok(m) => m,
Err(e) => {
let _ = out_tx.send(ServerMessage::Error {
message: format!("Invalid message: {}", e),
});
continue;
}
};
match client_msg {
ClientMessage::Ping => {
let _ = out_tx.send(ServerMessage::Pong);
}
ClientMessage::ListProjects => {
let projects = state.projects_store.list();
let entries: Vec<ProjectEntry> = projects
.into_iter()
.map(|p| ProjectEntry {
id: p.id,
name: p.name,
status: serde_json::to_value(&p.status)
.ok()
.and_then(|v| v.as_str().map(|s| s.to_string()))
.unwrap_or_else(|| "unknown".to_string()),
})
.collect();
let _ = out_tx.send(ServerMessage::Projects { projects: entries });
}
ClientMessage::Open {
project_id,
session_type,
} => {
let result = handle_open(
&state,
&project_id,
session_type.as_deref(),
&out_tx,
&owned_sessions,
)
.await;
if let Err(e) = result {
let _ = out_tx.send(ServerMessage::Error { message: e });
}
}
ClientMessage::Input { session_id, data } => {
match BASE64.decode(&data) {
Ok(bytes) => {
if let Err(e) = state.exec_manager.send_input(&session_id, bytes).await {
let _ = out_tx.send(ServerMessage::Error {
message: format!("Input error: {}", e),
});
}
}
Err(e) => {
let _ = out_tx.send(ServerMessage::Error {
message: format!("Base64 decode error: {}", e),
});
}
}
}
ClientMessage::Resize {
session_id,
cols,
rows,
} => {
if let Err(e) = state.exec_manager.resize(&session_id, cols, rows).await {
let _ = out_tx.send(ServerMessage::Error {
message: format!("Resize error: {}", e),
});
}
}
ClientMessage::Close { session_id } => {
state.exec_manager.close_session(&session_id).await;
// Remove from owned list
owned_sessions
.lock()
.await
.retain(|id| id != &session_id);
}
}
}
// Connection closed — clean up all owned sessions
log::info!("Web terminal WebSocket disconnected, cleaning up sessions");
let sessions = owned_sessions.lock().await.clone();
for session_id in sessions {
state.exec_manager.close_session(&session_id).await;
}
writer_handle.abort();
}
/// Build the command for a terminal session, mirroring terminal_commands.rs logic.
fn build_terminal_cmd(project: &Project, settings_store: &crate::storage::settings_store::SettingsStore) -> Vec<String> {
let is_bedrock_profile = project.backend == Backend::Bedrock
&& project
.bedrock_config
.as_ref()
.map(|b| b.auth_method == BedrockAuthMethod::Profile)
.unwrap_or(false);
let permission_args = project.effective_permission_mode().cli_args();
if !is_bedrock_profile {
let mut cmd = vec!["claude".to_string()];
cmd.extend(permission_args);
return cmd;
}
let profile = aws_commands::resolve_profile_for_project(
project,
settings_store.get().global_aws.aws_profile.as_deref(),
);
// The args are interpolated into a shell script string below, so
// single-quote each one.
let permission_flags: String = permission_args
.iter()
.map(|a| format!(" '{}'", a.replace('\'', "'\\''")))
.collect();
let claude_cmd = format!("exec claude{}", permission_flags);
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."
if aws configure get sso_start_url --profile '{profile}' >/dev/null 2>&1 || \
aws configure get sso_session --profile '{profile}' >/dev/null 2>&1; then
echo "Starting SSO login..."
echo ""
triple-c-sso-refresh
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
{claude_cmd}
"#,
profile = profile,
claude_cmd = claude_cmd
);
vec!["bash".to_string(), "-c".to_string(), script]
}
/// Open a new terminal session for a project.
async fn handle_open(
state: &WebTerminalState,
project_id: &str,
session_type: Option<&str>,
out_tx: &mpsc::UnboundedSender<ServerMessage>,
owned_sessions: &Arc<tokio::sync::Mutex<Vec<String>>>,
) -> Result<(), String> {
let project = state
.projects_store
.get(project_id)
.ok_or_else(|| format!("Project {} not found", project_id))?;
if project.status != ProjectStatus::Running {
return Err(format!("Project '{}' is not running", project.name));
}
let container_id = project
.container_id
.as_ref()
.ok_or_else(|| "Container not running".to_string())?;
let cmd = match session_type {
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
_ => build_terminal_cmd(&project, &state.settings_store),
};
let session_id = uuid::Uuid::new_v4().to_string();
let project_name = project.name.clone();
// Set up output routing through the WS channel
let out_tx_output = out_tx.clone();
let session_id_output = session_id.clone();
let on_output = move |data: Vec<u8>| {
let encoded = BASE64.encode(&data);
let _ = out_tx_output.send(ServerMessage::Output {
session_id: session_id_output.clone(),
data: encoded,
});
};
let out_tx_exit = out_tx.clone();
let session_id_exit = session_id.clone();
let on_exit = Box::new(move || {
let _ = out_tx_exit.send(ServerMessage::Exit {
session_id: session_id_exit,
});
});
state
.exec_manager
.create_session(container_id, &session_id, cmd, on_output, on_exit)
.await?;
// Track this session for cleanup on disconnect
owned_sessions.lock().await.push(session_id.clone());
let _ = out_tx.send(ServerMessage::Opened {
session_id,
project_name,
});
Ok(())
}
+1 -1
View File
@@ -1,7 +1,7 @@
{
"$schema": "https://raw.githubusercontent.com/tauri-apps/tauri/dev/crates/tauri-cli/schema.json",
"productName": "Triple-C",
"version": "0.2.0",
"version": "0.3.0",
"identifier": "com.triple-c.desktop",
"build": {
"beforeDevCommand": "npm run dev",
+147 -24
View File
@@ -1,26 +1,55 @@
import { useEffect } from "react";
import { useEffect, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import Sidebar from "./components/layout/Sidebar";
import TopBar from "./components/layout/TopBar";
import StatusBar from "./components/layout/StatusBar";
import TerminalView from "./components/terminal/TerminalView";
import DockerInstallDialog from "./components/DockerInstallDialog";
import ProjectHome from "./components/projects/home/ProjectHome";
import AddProjectDialog from "./components/projects/AddProjectDialog";
import ToastHost from "./components/ui/ToastHost";
import StatusIndicator from "./components/ui/StatusIndicator";
import Button from "./components/ui/Button";
import { useDocker } from "./hooks/useDocker";
import { useSettings } from "./hooks/useSettings";
import { useProjects } from "./hooks/useProjects";
import { useMcpServers } from "./hooks/useMcpServers";
import { useUpdates } from "./hooks/useUpdates";
import { useAppState } from "./store/appState";
import { useTerminal } from "./hooks/useTerminal";
import { useSTT } from "./hooks/useSTT";
import { useContainerProgress } from "./hooks/useContainerProgress";
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
import { useAppState, isHomeTab, tabKeyId, homeTabKey } from "./store/appState";
import { reconcileProjectStatuses } from "./lib/tauri-commands";
export default function App() {
const { checkDocker, checkImage, startDockerPolling } = useDocker();
const { loadSettings } = useSettings();
const { refresh } = useProjects();
const { refresh: refreshMcp } = useMcpServers();
const { loadVersion, checkForUpdates, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, setProjects } = useAppState(
useShallow(s => ({ sessions: s.sessions, activeSessionId: s.activeSessionId, setProjects: s.setProjects }))
);
const { loadVersion, checkForUpdates, checkImageUpdate, startPeriodicCheck } = useUpdates();
const { sessions, activeSessionId, tabOrder, activeTabKey, setProjects, setSttToggle } =
useAppState(
useShallow(s => ({
sessions: s.sessions,
activeSessionId: s.activeSessionId,
tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey,
setProjects: s.setProjects,
setSttToggle: s.setSttToggle,
}))
);
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]);
useContainerProgress();
useKeyboardShortcuts();
// Initialize on mount
useEffect(() => {
@@ -38,15 +67,18 @@ export default function App() {
refresh();
});
} else {
setShowInstallDialog(true);
stopPolling = startDockerPolling();
}
});
refresh();
refreshMcp();
// Update detection
loadVersion();
const updateTimer = setTimeout(() => checkForUpdates(), 3000);
const updateTimer = setTimeout(() => {
checkForUpdates();
checkImageUpdate();
}, 3000);
const cleanup = startPeriodicCheck();
return () => {
clearTimeout(updateTimer);
@@ -55,16 +87,25 @@ export default function App() {
};
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const homeProjectIds = tabOrder.filter(isHomeTab).map(tabKeyId);
return (
<div className="flex flex-col h-screen p-6 gap-4 bg-[var(--bg-primary)]">
<div className="flex flex-col h-screen p-3 gap-3 bg-[var(--bg-primary)]">
<TopBar />
<div className="flex flex-1 min-h-0 gap-4">
<div className="flex flex-1 min-h-0 gap-3">
<Sidebar />
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg min-w-0 overflow-hidden">
{sessions.length === 0 ? (
<main className="flex-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] min-w-0 overflow-hidden">
{tabOrder.length === 0 ? (
<WelcomeScreen />
) : (
<div className="w-full h-full">
{homeProjectIds.map((projectId) => (
<ProjectHome
key={projectId}
projectId={projectId}
active={activeTabKey === homeTabKey(projectId)}
/>
))}
{sessions.map((session) => (
<TerminalView
key={session.id}
@@ -76,23 +117,105 @@ export default function App() {
)}
</main>
</div>
<StatusBar />
<StatusBar stt={stt} />
<ToastHost />
{showInstallDialog && (
<DockerInstallDialog onClose={() => setShowInstallDialog(false)} />
)}
</div>
);
}
/**
* First run is a checklist, not a paragraph: it reuses state the app already
* tracks and ends in a real button.
*/
function WelcomeScreen() {
const { dockerAvailable, imageExists, projects, openProjectHome } = useAppState(
useShallow((s) => ({
dockerAvailable: s.dockerAvailable,
imageExists: s.imageExists,
projects: s.projects,
openProjectHome: s.openProjectHome,
})),
);
const [showAdd, setShowAdd] = useState(false);
const steps: {
label: string;
state: boolean | null;
pendingLabel: string;
failLabel: string;
}[] = [
{
label: "Docker detected",
state: dockerAvailable,
pendingLabel: "Checking for Docker…",
failLabel: "Docker not available",
},
{
label: "Container image ready",
state: imageExists,
pendingLabel: "Checking for the image…",
failLabel: "Image not pulled yet — see Settings Container",
},
{
label: `${projects.length} project${projects.length === 1 ? "" : "s"} configured`,
state: projects.length > 0 ? true : false,
pendingLabel: "",
failLabel: "No projects yet",
},
];
return (
<div className="flex items-center justify-center h-full text-[var(--text-secondary)]">
<div className="text-center">
<h1 className="text-3xl font-bold mb-2 text-[var(--text-primary)]">
Triple-C
</h1>
<p className="text-sm mb-4">Claude Code Container</p>
<p className="text-xs max-w-md">
Add a project from the sidebar, start its container, then open a
terminal to begin using Claude Code in a sandboxed environment.
<div className="flex items-center justify-center h-full p-6">
<div className="w-full max-w-md">
<h1 className="text-xl font-semibold text-[var(--text-primary)]">Triple-C</h1>
<p className="text-[13px] text-[var(--text-secondary)] mb-5">
Claude Code, sandboxed in a container.
</p>
<ol className="space-y-2 mb-5">
{steps.map((step) => (
<li
key={step.label}
className="flex items-center gap-2 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
>
<StatusIndicator
tone={step.state === true ? "ok" : step.state === false ? "error" : "unknown"}
label={
step.state === true
? step.label
: step.state === false
? step.failLabel
: step.pendingLabel
}
className="text-[13px]"
/>
</li>
))}
</ol>
<div className="flex items-center gap-2">
<Button size="md" variant="primary" onClick={() => setShowAdd(true)}>
{projects.length === 0 ? "Add your first project" : "Add a project"}
</Button>
{projects.length > 0 && (
<Button size="md" onClick={() => openProjectHome(projects[0].id)}>
Open {projects[0].name}
</Button>
)}
</div>
<p className="mt-4 text-xs text-[var(--text-secondary)]">
Then start its container and press{" "}
<kbd className="px-1 py-0.5 font-mono bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[4px]">
Ctrl+T
</kbd>{" "}
to open a Claude terminal.
</p>
{showAdd && <AddProjectDialog onClose={() => setShowAdd(false)} />}
</div>
</div>
);
+177
View File
@@ -0,0 +1,177 @@
import { useEffect, useState } from "react";
import { openUrl } from "@tauri-apps/plugin-opener";
import { useInstallHelper } from "../hooks/useInstallHelper";
import { useDocker } from "../hooks/useDocker";
import Modal from "./ui/Modal";
import Button from "./ui/Button";
interface Props {
onClose: () => void;
}
type Phase = "idle" | "installing" | "done" | "error";
export default function DockerInstallDialog({ onClose }: Props) {
const { options, loadOptions, runInstall } = useInstallHelper();
const { checkDocker } = useDocker();
const [showManual, setShowManual] = useState(false);
const [phase, setPhase] = useState<Phase>("idle");
const [log, setLog] = useState<string[]>([]);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
loadOptions();
}, [loadOptions]);
const handleInstall = async () => {
setPhase("installing");
setLog([]);
setError(null);
try {
await runInstall((line) => setLog((prev) => [...prev, line]));
setPhase("done");
// Re-check Docker so the rest of the app can proceed without a reload.
await checkDocker();
} catch (e) {
setError(String(e));
setPhase("error");
}
};
const handleOpenDocs = async () => {
if (!options) return;
try {
await openUrl(options.docs_url);
} catch (e) {
console.error("Failed to open docs URL:", e);
}
};
const handleRecheck = async () => {
const available = await checkDocker();
if (available) onClose();
};
if (!options) {
return null;
}
const installVerb =
phase === "installing" ? "Installing…" : `Install ${options.product_name}`;
return (
<Modal
title="Docker not detected"
onClose={onClose}
widthClassName="w-[34rem]"
// Closing mid-install would orphan a privileged installer.
dismissible={phase !== "installing"}
footer={
phase === "idle" ? (
<Button variant="ghost" onClick={onClose}>
Dismiss
</Button>
) : undefined
}
>
<p className="text-[13px] text-[var(--text-secondary)] mb-4">
Triple-C needs a Docker-compatible runtime to manage sandboxed project
containers. We can install{" "}
<span className="text-[var(--text-primary)]">{options.product_name}</span> for
you, or you can follow the official instructions.
</p>
{phase === "idle" && (
<div className="flex flex-col gap-2">
{options.can_auto_install ? (
<Button size="md" variant="primary" onClick={handleInstall}>
{installVerb} ({options.auto_install_method})
</Button>
) : (
<div className="text-xs text-[var(--text-secondary)] bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
One-click install unavailable:{" "}
<span className="text-[var(--text-primary)]">
{options.auto_install_blocker ?? "required tooling missing."}
</span>
</div>
)}
<Button size="md" onClick={() => setShowManual((s) => !s)}>
{showManual ? "Hide manual instructions" : "Show manual instructions"}
</Button>
<Button size="md" onClick={handleOpenDocs}>
Open official documentation
</Button>
</div>
)}
{phase === "installing" && (
<div className="text-xs text-[var(--text-secondary)]">
Installing a system password prompt may appear. Do not close this window.
</div>
)}
{phase === "done" && (
<div className="flex flex-col gap-2">
<div className="text-[13px] text-[var(--success)]">Install finished.</div>
{options.post_install_notes.length > 0 && (
<ul className="text-xs text-[var(--text-secondary)] list-disc list-inside space-y-1">
{options.post_install_notes.map((note, i) => (
<li key={i}>{note}</li>
))}
</ul>
)}
<div className="flex gap-2 mt-2">
<Button size="md" variant="primary" onClick={handleRecheck}>
Re-check Docker
</Button>
<Button size="md" onClick={onClose}>
Close
</Button>
</div>
</div>
)}
{phase === "error" && (
<div className="flex flex-col gap-2">
<div className="text-[13px] text-[var(--error)]">Install failed.</div>
{error && (
<div className="text-xs font-mono text-[var(--error)] break-words">
{error}
</div>
)}
<div className="flex gap-2 mt-2">
<Button size="md" onClick={() => setPhase("idle")}>
Back
</Button>
<Button size="md" variant="primary" onClick={handleOpenDocs}>
Open official docs
</Button>
</div>
</div>
)}
{(showManual || phase === "error") && (
<div className="mt-4">
<div className="text-xs font-medium mb-1.5 text-[var(--text-secondary)]">
Manual install steps
</div>
<ol className="text-xs text-[var(--text-secondary)] list-decimal list-inside space-y-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2">
{options.manual_steps.map((step, i) => (
<li key={i}>{step}</li>
))}
</ol>
</div>
)}
{log.length > 0 && (
<div className="mt-4 max-h-48 overflow-y-auto bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] p-2 text-xs font-mono text-[var(--text-secondary)]">
{log.map((line, i) => (
<div key={i}>{line}</div>
))}
</div>
)}
</Modal>
);
}
+189
View File
@@ -0,0 +1,189 @@
import { useEffect, useRef, useCallback, useState } from "react";
import { getHelpContent } from "../../lib/tauri-commands";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
interface Props {
onClose: () => void;
}
/** Convert header text to a URL-friendly slug for anchor links. */
function slugify(text: string): string {
return text
.toLowerCase()
.replace(/<[^>]+>/g, "") // strip HTML tags (e.g. from inline code)
.replace(/[^\w\s-]/g, "") // remove non-word chars except spaces/dashes
.replace(/\s+/g, "-") // spaces to dashes
.replace(/-+/g, "-") // collapse consecutive dashes
.replace(/^-|-$/g, ""); // trim leading/trailing dashes
}
/** Simple markdown-to-HTML converter for the help content. */
function renderMarkdown(md: string): string {
let html = md;
// Normalize line endings
html = html.replace(/\r\n/g, "\n");
// Escape HTML entities (but we'll re-introduce tags below)
html = html.replace(/&/g, "&amp;").replace(/</g, "&lt;").replace(/>/g, "&gt;");
// Fenced code blocks (```...```)
html = html.replace(/```(\w*)\n([\s\S]*?)```/g, (_m, _lang, code) => {
return `<pre class="help-code-block"><code>${code.trimEnd()}</code></pre>`;
});
// Inline code (`...`)
html = html.replace(/`([^`]+)`/g, '<code class="help-inline-code">$1</code>');
// Tables
html = html.replace(
/(?:^|\n)(\|.+\|)\n(\|[\s:|-]+\|)\n((?:\|.+\|\n?)+)/g,
(_m, headerRow: string, _sep: string, bodyRows: string) => {
const headers = headerRow
.split("|")
.slice(1, -1)
.map((c: string) => `<th>${c.trim()}</th>`)
.join("");
const rows = bodyRows
.trim()
.split("\n")
.map((row: string) => {
const cells = row
.split("|")
.slice(1, -1)
.map((c: string) => `<td>${c.trim()}</td>`)
.join("");
return `<tr>${cells}</tr>`;
})
.join("");
return `<table class="help-table"><thead><tr>${headers}</tr></thead><tbody>${rows}</tbody></table>`;
},
);
// Blockquotes (> ...)
html = html.replace(/(?:^|\n)&gt; (.+)/g, '<blockquote class="help-blockquote">$1</blockquote>');
// Merge adjacent blockquotes
html = html.replace(/<\/blockquote>\s*<blockquote class="help-blockquote">/g, "<br/>");
// Horizontal rules
html = html.replace(/\n---\n/g, '<hr class="help-hr"/>');
// Headers with id attributes for anchor navigation (process from h4 down to h1)
html = html.replace(/^#### (.+)$/gm, (_m, title) => `<h4 class="help-h4" id="${slugify(title)}">${title}</h4>`);
html = html.replace(/^### (.+)$/gm, (_m, title) => `<h3 class="help-h3" id="${slugify(title)}">${title}</h3>`);
html = html.replace(/^## (.+)$/gm, (_m, title) => `<h2 class="help-h2" id="${slugify(title)}">${title}</h2>`);
html = html.replace(/^# (.+)$/gm, (_m, title) => `<h1 class="help-h1" id="${slugify(title)}">${title}</h1>`);
// Bold (**...**)
html = html.replace(/\*\*([^*]+)\*\*/g, "<strong>$1</strong>");
// Italic (*...*)
html = html.replace(/\*([^*]+)\*/g, "<em>$1</em>");
// Markdown-style anchor links [text](#anchor)
html = html.replace(
/\[([^\]]+)\]\(#([^)]+)\)/g,
'<a class="help-link" href="#$2">$1</a>',
);
// Markdown-style external links [text](url)
html = html.replace(
/\[([^\]]+)\]\((https?:\/\/[^)]+)\)/g,
'<a class="help-link" href="$2" target="_blank" rel="noopener noreferrer">$1</a>',
);
// Unordered list items (- ...)
// Group consecutive list items
html = html.replace(/((?:^|\n)- .+(?:\n- .+)*)/g, (block) => {
const items = block
.trim()
.split("\n")
.map((line) => `<li>${line.replace(/^- /, "")}</li>`)
.join("");
return `<ul class="help-ul">${items}</ul>`;
});
// Ordered list items (1. ...)
html = html.replace(/((?:^|\n)\d+\. .+(?:\n\d+\. .+)*)/g, (block) => {
const items = block
.trim()
.split("\n")
.map((line) => `<li>${line.replace(/^\d+\. /, "")}</li>`)
.join("");
return `<ol class="help-ol">${items}</ol>`;
});
// Links - convert bare URLs to clickable links (skip already-wrapped URLs)
html = html.replace(
/(?<!="|'>)(https?:\/\/[^\s<)]+)/g,
'<a class="help-link" href="$1" target="_blank" rel="noopener noreferrer">$1</a>',
);
// Wrap remaining loose text lines in paragraphs
// Split by double newlines for paragraph breaks
const blocks = html.split(/\n\n+/);
html = blocks
.map((block) => {
const trimmed = block.trim();
if (!trimmed) return "";
// Don't wrap blocks that are already HTML elements
if (
/^<(h[1-4]|ul|ol|pre|table|blockquote|hr)/.test(trimmed)
) {
return trimmed;
}
// Wrap in paragraph, replacing single newlines with <br/>
return `<p class="help-p">${trimmed.replace(/\n/g, "<br/>")}</p>`;
})
.join("\n");
return html;
}
export default function HelpDialog({ onClose }: Props) {
const contentRef = useRef<HTMLDivElement>(null);
const [markdown, setMarkdown] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
getHelpContent()
.then(setMarkdown)
.catch((e) => setError(String(e)));
}, []);
// Handle anchor link clicks to scroll within the dialog
const handleContentClick = useCallback((e: React.MouseEvent<HTMLDivElement>) => {
const target = e.target as HTMLElement;
const anchor = target.closest("a");
if (!anchor) return;
const href = anchor.getAttribute("href");
if (!href || !href.startsWith("#")) return;
e.preventDefault();
const el = contentRef.current?.querySelector(href);
if (el) el.scrollIntoView({ behavior: "smooth" });
}, []);
return (
<Modal
title="How to Use Triple-C"
onClose={onClose}
widthClassName="w-[48rem]"
footer={<Button onClick={onClose}>Close</Button>}
>
<div ref={contentRef} onClick={handleContentClick} className="help-content">
{error && (
<p className="text-[var(--error)] text-sm">
Failed to load help content: {error}
</p>
)}
{!markdown && !error && (
<p className="text-[var(--text-secondary)] text-sm">Loading</p>
)}
{markdown && (
<div dangerouslySetInnerHTML={{ __html: renderMarkdown(markdown) }} />
)}
</div>
</Modal>
);
}
+328
View File
@@ -0,0 +1,328 @@
import { useEffect, useRef, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useTerminal } from "../../hooks/useTerminal";
import { useProjects } from "../../hooks/useProjects";
import {
useAppState,
isHomeTab,
tabKeyId,
terminalTabKey,
} from "../../store/appState";
import { effectivePermissionMode } from "../projects/PermissionModeControl";
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
import type { PermissionMode } from "../../lib/types";
interface ContextMenuState {
sessionId: string;
x: number;
y: number;
}
const MODE_BADGE: Record<PermissionMode, { text: string; className: string }> = {
plan: { text: "plan", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
default: { text: "ask", className: "bg-[var(--bg-tertiary)] text-[var(--text-secondary)]" },
acceptEdits: { text: "edits", className: "bg-[var(--accent-muted)] text-[var(--accent)]" },
bypass: { text: "bypass", className: "bg-[var(--warning-muted)] text-[var(--warning)]" },
};
/**
* One strip for both main-area tab kinds: Project Home views () and
* terminals ().
*/
export default function MainTabs() {
const { sessions, close } = useTerminal();
const { projects, update } = useProjects();
const { tabOrder, activeTabKey, setActiveTabKey, closeHomeTab } = useAppState(
useShallow((s) => ({
tabOrder: s.tabOrder,
activeTabKey: s.activeTabKey,
setActiveTabKey: s.setActiveTabKey,
closeHomeTab: s.closeHomeTab,
})),
);
const [menu, setMenu] = useState<ContextMenuState | null>(null);
const [renamingId, setRenamingId] = useState<string | null>(null);
const [renameDraft, setRenameDraft] = useState("");
const renameInputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
if (!menu) return;
const dismiss = () => setMenu(null);
window.addEventListener("click", dismiss);
window.addEventListener("scroll", dismiss, true);
return () => {
window.removeEventListener("click", dismiss);
window.removeEventListener("scroll", dismiss, true);
};
}, [menu]);
useEffect(() => {
if (renamingId) {
renameInputRef.current?.focus();
renameInputRef.current?.select();
}
}, [renamingId]);
if (tabOrder.length === 0) {
return (
<div className="px-3 text-xs text-[var(--text-secondary)] leading-10">
No open tabs select a project to open its home view.
</div>
);
}
const getCustomName = (projectId: string, sessionId: string): string | null => {
const project = projects.find((p) => p.id === projectId);
return project?.renamed_session_names?.[sessionId] ?? null;
};
const startRename = (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return;
const current =
getCustomName(session.projectId, sessionId) ??
session.sessionName ??
session.projectName;
setRenameDraft(current);
setRenamingId(sessionId);
setMenu(null);
};
const commitRename = async (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) {
setRenamingId(null);
return;
}
const project = projects.find((p) => p.id === session.projectId);
if (!project) {
setRenamingId(null);
return;
}
const trimmed = renameDraft.trim();
const map = { ...(project.renamed_session_names ?? {}) };
if (trimmed) {
map[sessionId] = trimmed;
} else {
delete map[sessionId];
}
try {
await update({ ...project, renamed_session_names: map });
} catch (err) {
console.error("Failed to rename terminal tab:", err);
} finally {
setRenamingId(null);
}
};
const clearCustomName = async (sessionId: string) => {
const session = sessions.find((s) => s.id === sessionId);
if (!session) return;
const project = projects.find((p) => p.id === session.projectId);
if (!project) return;
const map = { ...(project.renamed_session_names ?? {}) };
if (!(sessionId in map)) {
setMenu(null);
return;
}
delete map[sessionId];
try {
await update({ ...project, renamed_session_names: map });
} catch (err) {
console.error("Failed to reset terminal tab name:", err);
} finally {
setMenu(null);
}
};
const tabClass = (active: boolean) =>
`flex items-center gap-1.5 pl-3 pr-1.5 h-full text-xs cursor-pointer border-r border-[var(--border-color)] transition-colors ${
active
? "bg-[var(--bg-primary)] text-[var(--text-primary)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`;
return (
<div className="flex items-center h-full" role="tablist" aria-label="Open tabs">
{tabOrder.map((key) => {
const active = activeTabKey === key;
if (isHomeTab(key)) {
const projectId = tabKeyId(key);
const project = projects.find((p) => p.id === projectId);
if (!project) return null;
return (
<div
key={key}
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => setActiveTabKey(key)}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(key);
}
}}
className={tabClass(active)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
<span className="truncate max-w-[160px]" title={`${project.name} — project home`}>
{project.name}
</span>
<ProjectStatusIndicator status={project.status} iconOnly />
<button
type="button"
onClick={(e) => {
e.stopPropagation();
closeHomeTab(projectId);
}}
aria-label={`Close ${project.name} home tab`}
title="Close tab"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
}
const sessionId = tabKeyId(key);
const session = sessions.find((s) => s.id === sessionId);
if (!session) return null;
const project = projects.find((p) => p.id === session.projectId);
const customName = getCustomName(session.projectId, session.id);
const baseLabel =
(session.sessionName ?? session.projectName) +
(session.sessionType === "bash" ? " (bash)" : "");
const displayLabel = customName
? `${session.projectName}: ${customName}`
: baseLabel;
const isRenaming = renamingId === session.id;
const badge = project ? MODE_BADGE[effectivePermissionMode(project)] : null;
return (
<div
key={key}
role="tab"
aria-selected={active}
tabIndex={0}
onClick={() => setActiveTabKey(terminalTabKey(session.id))}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
setActiveTabKey(terminalTabKey(session.id));
}
}}
onContextMenu={(e) => {
e.preventDefault();
setMenu({ sessionId: session.id, x: e.clientX, y: e.clientY });
}}
onDoubleClick={() => startRename(session.id)}
className={tabClass(active)}
>
<span aria-hidden="true" className="text-[var(--text-secondary)]"></span>
{isRenaming ? (
<input
ref={renameInputRef}
value={renameDraft}
aria-label="Rename tab"
onChange={(e) => setRenameDraft(e.target.value)}
onClick={(e) => e.stopPropagation()}
onBlur={() => commitRename(session.id)}
onKeyDown={(e) => {
if (e.key === "Enter") (e.target as HTMLInputElement).blur();
if (e.key === "Escape") setRenamingId(null);
}}
className="max-w-[180px] px-1 py-0 bg-[var(--bg-primary)] border border-[var(--accent)] rounded-[var(--radius-control)] text-xs text-[var(--text-primary)]"
/>
) : (
<span className="truncate max-w-[180px]" title={displayLabel}>
{displayLabel}
</span>
)}
{badge && (
<span
className={`px-1 py-0.5 rounded-[4px] text-[10px] leading-none font-medium ${badge.className}`}
title={`Permission mode: ${badge.text}`}
>
{badge.text}
</span>
)}
<button
type="button"
onClick={(e) => {
e.stopPropagation();
close(session.id);
}}
aria-label={`Close ${displayLabel}`}
title="Close terminal"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
<span aria-hidden="true">×</span>
</button>
</div>
);
})}
{menu && (() => {
const session = sessions.find((s) => s.id === menu.sessionId);
const hasCustom = session
? !!getCustomName(session.projectId, menu.sessionId)
: false;
return (
<div
role="menu"
className="fixed z-50 min-w-[160px] py-1 bg-[var(--bg-overlay)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs"
style={{ top: menu.y, left: menu.x, boxShadow: "var(--shadow-overlay)" }}
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
role="menuitem"
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
onClick={() => startRename(menu.sessionId)}
>
Rename tab
</button>
{hasCustom && (
<button
type="button"
role="menuitem"
className="w-full text-left px-3 py-1.5 text-[var(--text-secondary)] hover:bg-[var(--bg-tertiary)] transition-colors"
onClick={() => clearCustomName(menu.sessionId)}
>
Reset name
</button>
)}
{session && (
<button
type="button"
role="menuitem"
className="w-full text-left px-3 py-1.5 text-[var(--text-primary)] hover:bg-[var(--bg-tertiary)] transition-colors"
onClick={() => {
useAppState.getState().openProjectHome(session.projectId);
setMenu(null);
}}
>
Open project home
</button>
)}
<div className="border-t border-[var(--border-color)] my-1" />
<button
type="button"
role="menuitem"
className="w-full text-left px-3 py-1.5 text-[var(--error)] hover:bg-[var(--bg-tertiary)] transition-colors"
onClick={() => {
close(menu.sessionId);
setMenu(null);
}}
>
Close tab
</button>
</div>
);
})()}
</div>
);
}
+9 -3
View File
@@ -8,6 +8,9 @@ vi.mock("../../store/appState", () => ({
selector({
sidebarView: "projects",
setSidebarView: vi.fn(),
sidebarCollapsed: false,
setSidebarCollapsed: vi.fn(),
toggleSidebarCollapsed: vi.fn(),
})
),
}));
@@ -19,9 +22,6 @@ vi.mock("../projects/ProjectList", () => ({
vi.mock("../settings/SettingsPanel", () => ({
default: () => <div data-testid="settings-panel">SettingsPanel</div>,
}));
vi.mock("../mcp/McpPanel", () => ({
default: () => <div data-testid="mcp-panel">McpPanel</div>,
}));
describe("Sidebar", () => {
beforeEach(() => {
@@ -34,6 +34,12 @@ describe("Sidebar", () => {
expect(screen.getByText("Settings")).toBeInTheDocument();
});
it("renders the project list, not a settings form, in the projects view", () => {
render(<Sidebar />);
expect(screen.getByTestId("project-list")).toBeInTheDocument();
expect(screen.queryByTestId("settings-panel")).not.toBeInTheDocument();
});
it("content area has min-w-0 to prevent flex overflow", () => {
const { container } = render(<Sidebar />);
const contentArea = container.querySelector(".overflow-y-auto");
+88 -15
View File
@@ -1,15 +1,87 @@
import type { ReactNode } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState";
import ProjectList from "../projects/ProjectList";
import McpPanel from "../mcp/McpPanel";
import SettingsPanel from "../settings/SettingsPanel";
type SidebarView = "projects" | "settings";
const RAIL_ICONS: { view: SidebarView; label: string; icon: ReactNode }[] = [
{
view: "projects",
label: "Projects",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M3 7a2 2 0 0 1 2-2h4l2 2h8a2 2 0 0 1 2 2v9a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V7z" />
</svg>
),
},
{
view: "settings",
label: "Settings",
icon: (
<svg className="w-5 h-5" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 1 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09a1.65 1.65 0 0 0-1-1.51 1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 1 1-2.83-2.83l.06-.06a1.65 1.65 0 0 0 .33-1.82 1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09a1.65 1.65 0 0 0 1.51-1 1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 1 1 2.83-2.83l.06.06a1.65 1.65 0 0 0 1.82.33H9a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 1 1 2.83 2.83l-.06.06a1.65 1.65 0 0 0-.33 1.82V9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
),
},
];
export default function Sidebar() {
const { sidebarView, setSidebarView } = useAppState(
useShallow(s => ({ sidebarView: s.sidebarView, setSidebarView: s.setSidebarView }))
const { sidebarView, setSidebarView, sidebarCollapsed, setSidebarCollapsed, toggleSidebarCollapsed } = useAppState(
useShallow(s => ({
sidebarView: s.sidebarView,
setSidebarView: s.setSidebarView,
sidebarCollapsed: s.sidebarCollapsed,
setSidebarCollapsed: s.setSidebarCollapsed,
toggleSidebarCollapsed: s.toggleSidebarCollapsed,
}))
);
const tabCls = (view: typeof sidebarView) =>
if (sidebarCollapsed) {
const railBtn = (view: SidebarView, label: string, icon: ReactNode) => {
const active = sidebarView === view;
return (
<button
key={view}
onClick={() => {
setSidebarView(view);
setSidebarCollapsed(false);
}}
title={label}
aria-label={label}
className={`flex items-center justify-center h-10 w-full transition-colors ${
active
? "text-[var(--accent)]"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)]"
}`}
>
{icon}
</button>
);
};
return (
<div className="flex flex-col h-full w-12 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
<button
onClick={toggleSidebarCollapsed}
title="Expand sidebar"
aria-label="Expand sidebar"
className="flex items-center justify-center h-10 border-b border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="9 18 15 12 9 6" />
</svg>
</button>
<div className="flex flex-col py-1">
{RAIL_ICONS.map(({ view, label, icon }) => railBtn(view, label, icon))}
</div>
</div>
);
}
const tabCls = (view: SidebarView) =>
`flex-1 px-3 py-2 text-sm font-medium transition-colors ${
sidebarView === view
? "text-[var(--accent)] border-b-2 border-[var(--accent)]"
@@ -17,29 +89,30 @@ export default function Sidebar() {
}`;
return (
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<div className="flex flex-col h-full w-[25%] min-w-56 max-w-80 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
{/* Nav tabs */}
<div className="flex border-b border-[var(--border-color)]">
<button onClick={() => setSidebarView("projects")} className={tabCls("projects")}>
Projects
</button>
<button onClick={() => setSidebarView("mcp")} className={tabCls("mcp")}>
MCP <span className="text-[0.6rem] px-1 py-0.5 rounded bg-yellow-500/20 text-yellow-400 ml-0.5">Beta</span>
</button>
<button onClick={() => setSidebarView("settings")} className={tabCls("settings")}>
Settings
</button>
<button
onClick={toggleSidebarCollapsed}
title="Collapse sidebar"
aria-label="Collapse sidebar"
className="px-2 text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<svg className="w-4 h-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto overflow-x-hidden p-1 min-w-0">
{sidebarView === "projects" ? (
<ProjectList />
) : sidebarView === "mcp" ? (
<McpPanel />
) : (
<SettingsPanel />
)}
{sidebarView === "projects" ? <ProjectList /> : <SettingsPanel />}
</div>
</div>
);
+49 -4
View File
@@ -1,14 +1,31 @@
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../store/appState";
import SttButton from "../terminal/SttButton";
import type { useSTT } from "../../hooks/useSTT";
export default function StatusBar() {
const { projects, sessions } = useAppState(
useShallow(s => ({ projects: s.projects, sessions: s.sessions }))
interface Props {
stt: ReturnType<typeof useSTT>;
}
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;
return (
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-lg text-xs text-[var(--text-secondary)]">
<div className="flex items-center h-6 px-4 bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] text-xs text-[var(--text-secondary)]">
<span>
{projects.length} project{projects.length !== 1 ? "s" : ""}
</span>
@@ -20,6 +37,34 @@ export default function StatusBar() {
<span>
{sessions.length} terminal{sessions.length !== 1 ? "s" : ""}
</span>
{terminalHasSelection && (
<>
<span className="mx-2">|</span>
<span className="text-[var(--accent)]">
Ctrl+Shift+C: copy trimmed &middot; Ctrl+Shift+Alt+C: copy raw
</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>
);
}
+92 -20
View File
@@ -1,22 +1,29 @@
import { useState } from "react";
import { useShallow } from "zustand/react/shallow";
import TerminalTabs from "../terminal/TerminalTabs";
import MainTabs from "./MainTabs";
import { useAppState } from "../../store/appState";
import { useSettings } from "../../hooks/useSettings";
import UpdateDialog from "../settings/UpdateDialog";
import ImageUpdateDialog from "../settings/ImageUpdateDialog";
import HelpDialog from "./HelpDialog";
import StatusIndicator, { type StatusTone } from "../ui/StatusIndicator";
export default function TopBar() {
const { dockerAvailable, imageExists, updateInfo, appVersion, setUpdateInfo } = useAppState(
const { dockerAvailable, imageExists, updateInfo, imageUpdateInfo, appVersion, setUpdateInfo, setImageUpdateInfo } = useAppState(
useShallow(s => ({
dockerAvailable: s.dockerAvailable,
imageExists: s.imageExists,
updateInfo: s.updateInfo,
imageUpdateInfo: s.imageUpdateInfo,
appVersion: s.appVersion,
setUpdateInfo: s.setUpdateInfo,
setImageUpdateInfo: s.setImageUpdateInfo,
}))
);
const { appSettings, saveSettings } = useSettings();
const [showUpdateDialog, setShowUpdateDialog] = useState(false);
const [showImageUpdateDialog, setShowImageUpdateDialog] = useState(false);
const [showHelpDialog, setShowHelpDialog] = useState(false);
const handleDismiss = async () => {
if (appSettings && updateInfo) {
@@ -29,23 +36,64 @@ export default function TopBar() {
setShowUpdateDialog(false);
};
const handleImageUpdateDismiss = async () => {
if (appSettings && imageUpdateInfo) {
await saveSettings({
...appSettings,
dismissed_image_digest: imageUpdateInfo.remote_digest,
});
}
setImageUpdateInfo(null);
setShowImageUpdateDialog(false);
};
return (
<>
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg overflow-hidden">
<div className="flex-1 overflow-x-auto pl-2">
<TerminalTabs />
<div className="flex items-center h-10 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] overflow-hidden">
<div className="flex-1 overflow-x-auto pl-1">
<MainTabs />
</div>
<div className="flex items-center gap-2 px-4 flex-shrink-0 text-xs text-[var(--text-secondary)]">
<div className="flex items-center gap-3 px-3 flex-shrink-0 text-xs text-[var(--text-secondary)]">
{updateInfo && (
<button
type="button"
onClick={() => setShowUpdateDialog(true)}
className="px-2 py-0.5 rounded text-xs font-medium bg-[var(--accent)] text-white animate-pulse hover:bg-[var(--accent-hover)] transition-colors"
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--accent-emphasis)] text-white hover:bg-[var(--accent-emphasis-hover)] transition-colors"
>
Update
</button>
)}
<StatusDot ok={dockerAvailable === true} label="Docker" />
<StatusDot ok={imageExists === true} label="Image" />
{imageUpdateInfo && (
<button
type="button"
onClick={() => setShowImageUpdateDialog(true)}
className="h-6 px-2 rounded-[var(--radius-control)] text-xs font-medium bg-[var(--warning-emphasis)] text-white hover:opacity-90 transition-colors"
title="A newer container image is available"
>
Image Update
</button>
)}
<HealthDot
state={dockerAvailable}
okLabel="Docker"
failLabel="Docker unavailable"
pendingLabel="Docker — checking"
/>
<HealthDot
state={imageExists}
okLabel="Image"
failLabel="Image missing"
pendingLabel="Image — checking"
/>
<button
type="button"
onClick={() => setShowHelpDialog(true)}
title="Help"
aria-label="Help"
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] border border-[var(--border-color)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:border-[var(--text-secondary)] transition-colors text-xs font-semibold leading-none"
>
?
</button>
</div>
</div>
{showUpdateDialog && updateInfo && (
@@ -56,19 +104,43 @@ export default function TopBar() {
onClose={() => setShowUpdateDialog(false)}
/>
)}
{showImageUpdateDialog && imageUpdateInfo && (
<ImageUpdateDialog
imageUpdateInfo={imageUpdateInfo}
onDismiss={handleImageUpdateDismiss}
onClose={() => setShowImageUpdateDialog(false)}
/>
)}
{showHelpDialog && (
<HelpDialog onClose={() => setShowHelpDialog(false)} />
)}
</>
);
}
function StatusDot({ ok, label }: { ok: boolean; label: string }) {
return (
<span className="flex items-center gap-1">
<span
className={`inline-block w-2 h-2 rounded-full ${
ok ? "bg-[var(--success)]" : "bg-[var(--text-secondary)]"
}`}
/>
{label}
</span>
);
/**
* `null` (still checking) is visually distinct and pulses; `false` is an
* outage and renders red previously both fell through to the same gray dot.
*/
function HealthDot({
state,
okLabel,
failLabel,
pendingLabel,
}: {
state: boolean | null;
okLabel: string;
failLabel: string;
pendingLabel: string;
}) {
let tone: StatusTone = "unknown";
let label = pendingLabel;
if (state === true) {
tone = "ok";
label = okLabel;
} else if (state === false) {
tone = "error";
label = failLabel;
}
return <StatusIndicator tone={tone} label={label} />;
}
-79
View File
@@ -1,79 +0,0 @@
import { useState, useEffect } from "react";
import { useMcpServers } from "../../hooks/useMcpServers";
import McpServerCard from "./McpServerCard";
export default function McpPanel() {
const { mcpServers, refresh, add, update, remove } = useMcpServers();
const [newName, setNewName] = useState("");
const [error, setError] = useState<string | null>(null);
useEffect(() => {
refresh();
}, []); // eslint-disable-line react-hooks/exhaustive-deps
const handleAdd = async () => {
const name = newName.trim();
if (!name) return;
setError(null);
try {
await add(name);
setNewName("");
} catch (e) {
setError(String(e));
}
};
return (
<div className="space-y-3 p-2">
<div>
<h2 className="text-sm font-semibold text-[var(--text-primary)]">
MCP Servers{" "}
<span className="text-xs px-1.5 py-0.5 rounded bg-yellow-500/20 text-yellow-400">Beta</span>
</h2>
<p className="text-xs text-[var(--text-secondary)] mt-0.5">
Define MCP servers globally, then enable them per-project.
</p>
</div>
{/* Add new server */}
<div className="flex gap-1">
<input
value={newName}
onChange={(e) => setNewName(e.target.value)}
onKeyDown={(e) => { if (e.key === "Enter") handleAdd(); }}
placeholder="Server name..."
className="flex-1 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
/>
<button
onClick={handleAdd}
disabled={!newName.trim()}
className="px-3 py-1 text-xs bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
Add
</button>
</div>
{error && (
<div className="text-xs text-[var(--error)]">{error}</div>
)}
{/* Server list */}
<div className="space-y-2">
{mcpServers.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)] italic">
No MCP servers configured.
</p>
) : (
mcpServers.map((server) => (
<McpServerCard
key={server.id}
server={server}
onUpdate={update}
onRemove={remove}
/>
))
)}
</div>
</div>
);
}
-331
View File
@@ -1,331 +0,0 @@
import { useState, useEffect } from "react";
import type { McpServer, McpTransportType } from "../../lib/types";
interface Props {
server: McpServer;
onUpdate: (server: McpServer) => Promise<McpServer | void>;
onRemove: (id: string) => Promise<void>;
}
export default function McpServerCard({ server, onUpdate, onRemove }: Props) {
const [expanded, setExpanded] = useState(false);
const [name, setName] = useState(server.name);
const [transportType, setTransportType] = useState<McpTransportType>(server.transport_type);
const [command, setCommand] = useState(server.command ?? "");
const [args, setArgs] = useState(server.args.join(" "));
const [envPairs, setEnvPairs] = useState<[string, string][]>(Object.entries(server.env));
const [url, setUrl] = useState(server.url ?? "");
const [headerPairs, setHeaderPairs] = useState<[string, string][]>(Object.entries(server.headers));
const [dockerImage, setDockerImage] = useState(server.docker_image ?? "");
const [containerPort, setContainerPort] = useState(server.container_port?.toString() ?? "3000");
useEffect(() => {
setName(server.name);
setTransportType(server.transport_type);
setCommand(server.command ?? "");
setArgs(server.args.join(" "));
setEnvPairs(Object.entries(server.env));
setUrl(server.url ?? "");
setHeaderPairs(Object.entries(server.headers));
setDockerImage(server.docker_image ?? "");
setContainerPort(server.container_port?.toString() ?? "3000");
}, [server]);
const saveServer = async (patch: Partial<McpServer>) => {
try {
await onUpdate({ ...server, ...patch });
} catch (err) {
console.error("Failed to update MCP server:", err);
}
};
const handleNameBlur = () => {
if (name !== server.name) saveServer({ name });
};
const handleTransportChange = (t: McpTransportType) => {
setTransportType(t);
saveServer({ transport_type: t });
};
const handleCommandBlur = () => {
saveServer({ command: command || null });
};
const handleArgsBlur = () => {
const parsed = args.trim() ? args.trim().split(/\s+/) : [];
saveServer({ args: parsed });
};
const handleUrlBlur = () => {
saveServer({ url: url || null });
};
const handleDockerImageBlur = () => {
saveServer({ docker_image: dockerImage || null });
};
const handleContainerPortBlur = () => {
const port = parseInt(containerPort, 10);
saveServer({ container_port: isNaN(port) ? null : port });
};
const saveEnv = (pairs: [string, string][]) => {
const env: Record<string, string> = {};
for (const [k, v] of pairs) {
if (k.trim()) env[k.trim()] = v;
}
saveServer({ env });
};
const saveHeaders = (pairs: [string, string][]) => {
const headers: Record<string, string> = {};
for (const [k, v] of pairs) {
if (k.trim()) headers[k.trim()] = v;
}
saveServer({ headers });
};
const inputCls = "w-full px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
const isDocker = !!dockerImage;
const transportBadge = {
stdio: "Stdio",
http: "HTTP",
}[transportType];
const modeBadge = isDocker ? "Docker" : "Manual";
return (
<div className="border border-[var(--border-color)] rounded bg-[var(--bg-primary)]">
{/* Header */}
<div className="flex items-center gap-2 px-3 py-2">
<button
onClick={() => setExpanded(!expanded)}
className="flex-1 flex items-center gap-2 text-left min-w-0"
>
<span className="text-xs text-[var(--text-secondary)]">{expanded ? "\u25BC" : "\u25B6"}</span>
<span className="text-sm font-medium truncate">{server.name}</span>
<span className="text-xs px-1.5 py-0.5 rounded bg-[var(--bg-secondary)] text-[var(--text-secondary)]">
{transportBadge}
</span>
<span className={`text-xs px-1.5 py-0.5 rounded ${isDocker ? "bg-blue-500/20 text-blue-400" : "bg-[var(--bg-secondary)] text-[var(--text-secondary)]"}`}>
{modeBadge}
</span>
</button>
<button
onClick={() => { if (confirm(`Remove MCP server "${server.name}"?`)) onRemove(server.id); }}
className="text-xs px-2 py-0.5 text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
>
Remove
</button>
</div>
{/* Expanded config */}
{expanded && (
<div className="px-3 pb-3 space-y-2 border-t border-[var(--border-color)] pt-2">
{/* Name */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Name</label>
<input
value={name}
onChange={(e) => setName(e.target.value)}
onBlur={handleNameBlur}
className={inputCls}
/>
</div>
{/* Docker Image (primary field — determines Docker vs Manual mode) */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Docker Image</label>
<input
value={dockerImage}
onChange={(e) => setDockerImage(e.target.value)}
onBlur={handleDockerImageBlur}
placeholder="e.g. mcp/filesystem:latest (leave empty for manual mode)"
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
Set a Docker image to run this MCP server in its own container. Leave empty to run commands inside the project container. Images are pulled automatically if not present.
</p>
</div>
{/* Transport type */}
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Transport</label>
<div className="flex items-center gap-1">
{(["stdio", "http"] as McpTransportType[]).map((t) => (
<button
key={t}
onClick={() => handleTransportChange(t)}
className={`px-2 py-0.5 text-xs rounded transition-colors ${
transportType === t
? "bg-[var(--accent)] text-white"
: "text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-secondary)]"
}`}
>
{t === "stdio" ? "Stdio" : "HTTP"}
</button>
))}
</div>
</div>
{/* Mode description */}
<p className="text-xs text-[var(--text-secondary)] opacity-60">
{transportType === "stdio" && isDocker && "Runs via docker exec in a separate MCP container."}
{transportType === "stdio" && !isDocker && "Runs inside the project container (e.g. npx commands)."}
{transportType === "http" && isDocker && "Runs in a separate container, reached by hostname on the project network."}
{transportType === "http" && !isDocker && "Connects to an MCP server at the URL you specify."}
</p>
{/* Container Port (HTTP+Docker only) */}
{transportType === "http" && isDocker && (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Container Port</label>
<input
value={containerPort}
onChange={(e) => setContainerPort(e.target.value)}
onBlur={handleContainerPortBlur}
placeholder="3000"
className={inputCls}
/>
<p className="text-xs text-[var(--text-secondary)] mt-0.5 opacity-60">
Port the MCP server listens on inside its container. The URL is auto-generated as http://&lt;container&gt;:&lt;port&gt;/mcp on the project network.
</p>
</div>
)}
{/* Stdio fields */}
{transportType === "stdio" && (
<>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Command</label>
<input
value={command}
onChange={(e) => setCommand(e.target.value)}
onBlur={handleCommandBlur}
placeholder={isDocker ? "Command inside container" : "npx"}
className={inputCls}
/>
</div>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">Arguments (space-separated)</label>
<input
value={args}
onChange={(e) => setArgs(e.target.value)}
onBlur={handleArgsBlur}
placeholder="-y @modelcontextprotocol/server-filesystem /path"
className={inputCls}
/>
</div>
<KeyValueEditor
label="Environment Variables"
pairs={envPairs}
onChange={(pairs) => { setEnvPairs(pairs); }}
onSave={saveEnv}
/>
</>
)}
{/* HTTP fields (only for manual mode — Docker mode auto-generates URL) */}
{transportType === "http" && !isDocker && (
<>
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">URL</label>
<input
value={url}
onChange={(e) => setUrl(e.target.value)}
onBlur={handleUrlBlur}
placeholder="http://localhost:3000/mcp"
className={inputCls}
/>
</div>
<KeyValueEditor
label="Headers"
pairs={headerPairs}
onChange={(pairs) => { setHeaderPairs(pairs); }}
onSave={saveHeaders}
/>
</>
)}
{/* Environment variables for HTTP+Docker */}
{transportType === "http" && isDocker && (
<KeyValueEditor
label="Environment Variables"
pairs={envPairs}
onChange={(pairs) => { setEnvPairs(pairs); }}
onSave={saveEnv}
/>
)}
</div>
)}
</div>
);
}
function KeyValueEditor({
label,
pairs,
onChange,
onSave,
}: {
label: string;
pairs: [string, string][];
onChange: (pairs: [string, string][]) => void;
onSave: (pairs: [string, string][]) => void;
}) {
const inputCls = "flex-1 min-w-0 px-2 py-1 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]";
return (
<div>
<label className="block text-xs text-[var(--text-secondary)] mb-0.5">{label}</label>
{pairs.map(([key, value], i) => (
<div key={i} className="flex gap-1 items-center mb-1">
<input
value={key}
onChange={(e) => {
const updated = [...pairs] as [string, string][];
updated[i] = [e.target.value, value];
onChange(updated);
}}
onBlur={() => onSave(pairs)}
placeholder="KEY"
className={inputCls}
/>
<span className="text-xs text-[var(--text-secondary)]">=</span>
<input
value={value}
onChange={(e) => {
const updated = [...pairs] as [string, string][];
updated[i] = [key, e.target.value];
onChange(updated);
}}
onBlur={() => onSave(pairs)}
placeholder="value"
className={inputCls}
/>
<button
onClick={() => {
const updated = pairs.filter((_, j) => j !== i);
onChange(updated);
onSave(updated);
}}
className="flex-shrink-0 px-1.5 py-1 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
>
x
</button>
</div>
))}
<button
onClick={() => {
onChange([...pairs, ["", ""]]);
}}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
+ Add
</button>
</div>
);
}
@@ -1,7 +1,10 @@
import { useState, useEffect, useRef, useCallback } from "react";
import { useId, useRef, useState } from "react";
import { open } from "@tauri-apps/plugin-dialog";
import { useProjects } from "../../hooks/useProjects";
import type { ProjectPath } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import { inputClass, monoInputClass } from "../ui/Field";
interface Props {
onClose: () => void;
@@ -25,26 +28,7 @@ export default function AddProjectDialog({ onClose }: Props) {
const [error, setError] = useState<string | null>(null);
const [loading, setLoading] = useState(false);
const nameInputRef = useRef<HTMLInputElement>(null);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
nameInputRef.current?.focus();
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const formId = useId();
const handleBrowse = async (index: number) => {
const selected = await open({ directory: true, multiple: false });
@@ -63,24 +47,12 @@ export default function AddProjectDialog({ onClose }: Props) {
}
};
const updateEntry = (
index: number,
field: keyof PathEntry,
value: string,
) => {
const updateEntry = (index: number, field: keyof PathEntry, value: string) => {
const entries = [...pathEntries];
entries[index] = { ...entries[index], [field]: value };
setPathEntries(entries);
};
const removeEntry = (index: number) => {
setPathEntries(pathEntries.filter((_, i) => i !== index));
};
const addEntry = () => {
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }]);
};
const handleSubmit = async (e?: React.FormEvent) => {
if (e) e.preventDefault();
if (!name.trim()) {
@@ -115,98 +87,106 @@ export default function AddProjectDialog({ onClose }: Props) {
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
<Modal
title="Add Project"
onClose={onClose}
widthClassName="w-[30rem]"
initialFocusRef={nameInputRef}
footer={
<>
<Button size="md" variant="ghost" onClick={onClose}>
Cancel
</Button>
<Button size="md" variant="primary" type="submit" form={formId} disabled={loading}>
{loading ? "Adding…" : "Add Project"}
</Button>
</>
}
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[28rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Add Project</h2>
<form onSubmit={handleSubmit}>
<label className="block text-sm text-[var(--text-secondary)] mb-1">
Project Name
<form id={formId} onSubmit={handleSubmit} className="space-y-4">
<div>
<label
htmlFor={`${formId}-name`}
className="block text-[13px] font-medium mb-1"
>
Project name
</label>
<input
id={`${formId}-name`}
ref={nameInputRef}
value={name}
onChange={(e) => setName(e.target.value)}
placeholder="my-project"
className="w-full px-3 py-2 mb-3 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
className={inputClass}
/>
</div>
<label className="block text-sm text-[var(--text-secondary)] mb-1">
Folders
</label>
<div className="space-y-2 mb-3">
<div>
<span className="block text-[13px] font-medium mb-1">Folders</span>
<div className="space-y-2">
{pathEntries.map((entry, i) => (
<div key={i} className="space-y-1 p-2 bg-[var(--bg-primary)] rounded border border-[var(--border-color)]">
<div className="flex gap-1">
<div
key={i}
className="space-y-1.5 p-2 bg-[var(--bg-primary)] rounded-[var(--radius-control)] border border-[var(--border-color)]"
>
<div className="flex gap-1.5">
<input
value={entry.host_path}
onChange={(e) => updateEntry(i, "host_path", e.target.value)}
placeholder="/path/to/folder"
className="flex-1 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)]"
aria-label={`Folder ${i + 1} host path`}
className={inputClass}
/>
<button
type="button"
onClick={() => handleBrowse(i)}
className="px-2 py-1.5 text-xs bg-[var(--bg-tertiary)] border border-[var(--border-color)] rounded hover:bg-[var(--border-color)] transition-colors"
>
<Button size="md" onClick={() => handleBrowse(i)}>
Browse
</button>
</Button>
{pathEntries.length > 1 && (
<button
type="button"
onClick={() => removeEntry(i)}
className="px-1.5 py-1.5 text-xs text-[var(--error)] hover:bg-[var(--bg-secondary)] rounded transition-colors"
<Button
size="md"
variant="danger"
aria-label={`Remove folder ${i + 1}`}
onClick={() =>
setPathEntries(pathEntries.filter((_, j) => j !== i))
}
>
x
</button>
Remove
</Button>
)}
</div>
<div className="flex items-center gap-1">
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">/workspace/</span>
<div className="flex items-center gap-1.5">
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0 font-mono">
/workspace/
</span>
<input
value={entry.mount_name}
onChange={(e) => updateEntry(i, "mount_name", e.target.value)}
placeholder="mount-name"
className="flex-1 px-2 py-1 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded text-xs text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] font-mono"
aria-label={`Folder ${i + 1} mount name`}
className={monoInputClass}
/>
</div>
</div>
))}
</div>
<button
type="button"
onClick={addEntry}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] mb-4 transition-colors"
<Button
className="mt-2"
onClick={() =>
setPathEntries([...pathEntries, { host_path: "", mount_name: "" }])
}
>
+ Add folder
</button>
</Button>
</div>
{error && (
<div className="text-xs text-[var(--error)] mb-3">{error}</div>
)}
<div className="flex justify-end gap-2">
<button
type="button"
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Cancel
</button>
<button
type="submit"
disabled={loading}
className="px-4 py-2 text-sm bg-[var(--accent)] text-white rounded hover:bg-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
{loading ? "Adding..." : "Add Project"}
</button>
{error && (
<div
role="alert"
className="px-2 py-1.5 text-xs text-[var(--error)] bg-[var(--error-muted)] border border-[var(--error)]/30 rounded-[var(--radius-control)]"
>
{error}
</div>
</form>
</div>
</div>
)}
</form>
</Modal>
);
}
@@ -0,0 +1,151 @@
import { useEffect, useState } from "react";
import type { ClaudeCodeSettings } from "../../lib/types";
import Toggle from "../ui/Toggle";
import { SwitchRow, selectClass } from "../ui/Field";
interface Props {
settings: ClaudeCodeSettings | null;
disabled: boolean;
disabledReason?: string;
onSave: (settings: ClaudeCodeSettings | null) => Promise<unknown>;
}
export const CLAUDE_CODE_DEFAULTS: ClaudeCodeSettings = {
tui_mode: null,
effort: null,
auto_scroll_disabled: false,
focus_mode: false,
show_thinking_summaries: false,
enable_session_recap: false,
env_scrub: false,
prompt_caching_1h: false,
};
function isAllDefaults(s: ClaudeCodeSettings): boolean {
return (
s.tui_mode === null &&
s.effort === null &&
s.auto_scroll_disabled === false &&
s.focus_mode === false &&
s.show_thinking_summaries === false &&
s.enable_session_recap === false &&
s.env_scrub === false &&
s.prompt_caching_1h === false
);
}
const BOOLEAN_FIELDS: {
key: keyof Omit<ClaudeCodeSettings, "tui_mode" | "effort">;
label: string;
hint: string;
}[] = [
{ key: "focus_mode", label: "Focus mode", hint: "Collapses tool output to one-line summaries." },
{
key: "show_thinking_summaries",
label: "Thinking summaries",
hint: "Shows Claude's thinking process as summaries.",
},
{
key: "enable_session_recap",
label: "Session recap",
hint: "Provides context when returning to a session.",
},
{
key: "auto_scroll_disabled",
label: "Auto-scroll disabled",
hint: "Disables auto-scroll when in fullscreen TUI mode.",
},
{
key: "env_scrub",
label: "Env scrub",
hint: "Strips credentials from subprocess environments.",
},
{
key: "prompt_caching_1h",
label: "Prompt caching (1h)",
hint: "Uses a 1-hour prompt cache TTL instead of 5 minutes.",
},
];
export default function ClaudeCodeSettingsEditor({
settings,
disabled,
disabledReason,
onSave,
}: Props) {
const [local, setLocal] = useState<ClaudeCodeSettings>(
settings ?? { ...CLAUDE_CODE_DEFAULTS },
);
useEffect(() => {
setLocal(settings ?? { ...CLAUDE_CODE_DEFAULTS });
}, [settings]);
const apply = (patch: Partial<ClaudeCodeSettings>) => {
const next = { ...local, ...patch };
setLocal(next);
onSave(isAllDefaults(next) ? null : next);
};
return (
<div className="space-y-4">
{disabled && disabledReason && (
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
{disabledReason}
</p>
)}
<SwitchRow
label="TUI mode"
hint="Enables flicker-free alt-screen rendering."
control={
<select
value={local.tui_mode ?? ""}
aria-label="TUI mode"
onChange={(e) => apply({ tui_mode: e.target.value || null })}
disabled={disabled}
className={selectClass}
>
<option value="">Default</option>
<option value="fullscreen">Fullscreen</option>
</select>
}
/>
<SwitchRow
label="Effort level"
hint="Controls how much reasoning Claude applies."
control={
<select
value={local.effort ?? ""}
aria-label="Effort level"
onChange={(e) => apply({ effort: e.target.value || null })}
disabled={disabled}
className={selectClass}
>
<option value="">Default</option>
<option value="low">Low</option>
<option value="medium">Medium</option>
<option value="high">High</option>
</select>
}
/>
{BOOLEAN_FIELDS.map(({ key, label, hint }) => (
<SwitchRow
key={key}
label={label}
hint={hint}
control={
<Toggle
label={label}
checked={local[key]}
disabled={disabled}
onChange={(v) => apply({ [key]: v } as Partial<ClaudeCodeSettings>)}
/>
}
/>
))}
</div>
);
}
@@ -0,0 +1,41 @@
import type { ClaudeCodeSettings } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import ClaudeCodeSettingsEditor from "./ClaudeCodeSettingsEditor";
interface Props {
settings: ClaudeCodeSettings | null;
disabled: boolean;
onSave: (settings: ClaudeCodeSettings | null) => Promise<void>;
onClose: () => void;
}
/** Global Claude Code settings (Settings). Per-project lives in Config → Runtime. */
export default function ClaudeCodeSettingsModal({
settings,
disabled,
onSave,
onClose,
}: Props) {
return (
<Modal
title="Claude Code Settings"
onClose={onClose}
widthClassName="w-[34rem]"
footer={<Button onClick={onClose}>Close</Button>}
>
<ClaudeCodeSettingsEditor
settings={settings}
disabled={disabled}
disabledReason="Container must be stopped to change Claude Code settings."
onSave={async (next) => {
try {
await onSave(next);
} catch (err) {
console.error("Failed to save Claude Code settings:", err);
}
}}
/>
</Modal>
);
}
@@ -0,0 +1,46 @@
import { useEffect, useState } from "react";
interface Props {
instructions: string;
disabled: boolean;
disabledReason?: string;
onSave: (instructions: string) => Promise<unknown>;
rows?: number;
autoFocus?: boolean;
}
export default function ClaudeInstructionsEditor({
instructions: initial,
disabled,
disabledReason,
onSave,
rows = 10,
autoFocus = false,
}: Props) {
const [instructions, setInstructions] = useState(initial);
useEffect(() => {
setInstructions(initial);
}, [initial]);
return (
<div className="space-y-2">
{disabled && disabledReason && (
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
{disabledReason}
</p>
)}
<textarea
autoFocus={autoFocus}
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
onBlur={() => onSave(instructions)}
placeholder="Enter instructions for Claude Code in this project's container..."
aria-label="Claude instructions"
disabled={disabled}
rows={rows}
className="w-full px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded-[var(--radius-control)] text-[13px] text-[var(--text-primary)] focus:border-[var(--accent)] disabled:text-[var(--text-disabled)] disabled:bg-[var(--bg-secondary)] resize-y font-mono transition-colors"
/>
</div>
);
}
@@ -1,4 +1,6 @@
import { useState, useEffect, useRef, useCallback } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import ClaudeInstructionsEditor from "./ClaudeInstructionsEditor";
interface Props {
instructions: string;
@@ -7,74 +9,35 @@ interface Props {
onClose: () => void;
}
export default function ClaudeInstructionsModal({ instructions: initial, disabled, onSave, onClose }: Props) {
const [instructions, setInstructions] = useState(initial);
const overlayRef = useRef<HTMLDivElement>(null);
const textareaRef = useRef<HTMLTextAreaElement>(null);
useEffect(() => {
textareaRef.current?.focus();
}, []);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const handleBlur = async () => {
try { await onSave(instructions); } catch (err) {
console.error("Failed to update Claude instructions:", err);
}
};
/** Global Claude instructions (Settings). Per-project lives in Config → Runtime. */
export default function ClaudeInstructionsModal({
instructions,
disabled,
onSave,
onClose,
}: Props) {
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
<Modal
title="Claude Instructions"
description="Written to ~/.claude/CLAUDE.md inside containers."
onClose={onClose}
widthClassName="w-[40rem]"
footer={<Button onClick={onClose}>Close</Button>}
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[40rem] shadow-xl max-h-[80vh] flex flex-col">
<h2 className="text-lg font-semibold mb-1">Claude Instructions</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Per-project instructions for Claude Code (written to ~/.claude/CLAUDE.md in container)
</p>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change Claude instructions.
</div>
)}
<textarea
ref={textareaRef}
value={instructions}
onChange={(e) => setInstructions(e.target.value)}
onBlur={handleBlur}
placeholder="Enter instructions for Claude Code in this project's container..."
disabled={disabled}
rows={14}
className="w-full flex-1 px-3 py-2 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 resize-y font-mono"
/>
<div className="flex justify-end mt-4">
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
<ClaudeInstructionsEditor
instructions={instructions}
disabled={disabled}
disabledReason="Container must be stopped to change Claude instructions."
rows={14}
autoFocus
onSave={async (value) => {
try {
await onSave(value);
} catch (err) {
console.error("Failed to update Claude instructions:", err);
}
}}
/>
</Modal>
);
}
@@ -1,4 +1,5 @@
import { useEffect, useRef, useCallback } from "react";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
interface Props {
projectName: string;
@@ -7,49 +8,31 @@ interface Props {
}
export default function ConfirmRemoveModal({ projectName, onConfirm, onCancel }: Props) {
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onCancel();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onCancel]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onCancel();
},
[onCancel],
);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[24rem] shadow-xl">
<h2 className="text-lg font-semibold mb-3">Remove Project</h2>
<p className="text-sm text-[var(--text-secondary)] mb-5">
Are you sure you want to remove <strong className="text-[var(--text-primary)]">{projectName}</strong>? This will delete the container, config volume, and stored credentials.
</p>
<div className="flex justify-end gap-2">
<button
onClick={onCancel}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
<Modal
title="Remove Project"
onClose={onCancel}
widthClassName="w-[26rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</button>
<button
</Button>
<Button
size="md"
onClick={onConfirm}
className="px-4 py-2 text-sm text-white bg-[var(--error)] hover:opacity-80 rounded transition-colors"
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
>
Remove
</button>
</div>
</div>
</div>
</Button>
</>
}
>
<p className="text-[13px] text-[var(--text-secondary)]">
Are you sure you want to remove{" "}
<strong className="text-[var(--text-primary)]">{projectName}</strong>? This will
delete the container, config volume, and stored credentials.
</p>
</Modal>
);
}
@@ -0,0 +1,57 @@
import { describe, it, expect, vi, beforeEach, afterEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import ConfirmResetModal from "./ConfirmResetModal";
/** Modal focuses via rAF so the panel is laid out first; jsdom needs a flush. */
async function flushFocus() {
await act(async () => {
vi.advanceTimersByTime(20);
});
}
describe("ConfirmResetModal", () => {
beforeEach(() => {
vi.useFakeTimers({ toFake: ["requestAnimationFrame", "setTimeout"] });
});
afterEach(() => {
vi.useRealTimers();
});
async function renderModal() {
const onConfirm = vi.fn();
const onCancel = vi.fn();
render(
<ConfirmResetModal
projectName="api-server"
onConfirm={onConfirm}
onCancel={onCancel}
/>,
);
await flushFocus();
return { onConfirm, onCancel };
}
it("names what will be lost rather than just asking to confirm", async () => {
await renderModal();
// The whole point of the gate: Reset deletes the volumes, and the two
// losses users do not expect are the login and the session transcripts.
expect(screen.getByText(/sign in again/i)).toBeInTheDocument();
expect(screen.getByText(/session transcript/i)).toBeInTheDocument();
// And it must say what is safe, or the warning reads as "you lose everything".
expect(screen.getByText(/mounted project folders/i)).toBeInTheDocument();
});
it("does not reset until confirmed", async () => {
const { onConfirm, onCancel } = await renderModal();
fireEvent.click(screen.getByRole("button", { name: "Cancel" }));
expect(onCancel).toHaveBeenCalledTimes(1);
expect(onConfirm).not.toHaveBeenCalled();
});
it("resets on confirm", async () => {
const { onConfirm } = await renderModal();
fireEvent.click(screen.getByRole("button", { name: "Reset container" }));
expect(onConfirm).toHaveBeenCalledTimes(1);
});
});
@@ -0,0 +1,66 @@
import Modal from "../ui/Modal";
import Button from "../ui/Button";
interface Props {
projectName: string;
onConfirm: () => void;
onCancel: () => void;
}
/**
* Reset is destructive in a way its name does not advertise.
*
* `rebuild_project_container` calls `remove_project_volumes`, which deletes
* both `triple-c-home-{id}` and `triple-c-claude-config-{id}` so the OAuth
* login, any skills or agents installed in the container, and every session
* transcript go with them. That is intentional (Reset exists to get back to a
* clean base image), but it is not recoverable, so it gets the same
* confirmation gate as Remove.
*/
export default function ConfirmResetModal({ projectName, onConfirm, onCancel }: Props) {
return (
<Modal
title="Reset container"
onClose={onCancel}
widthClassName="w-[28rem]"
footer={
<>
<Button size="md" variant="ghost" onClick={onCancel}>
Cancel
</Button>
<Button
size="md"
onClick={onConfirm}
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
>
Reset container
</Button>
</>
}
>
<div className="space-y-2.5 text-[13px] text-[var(--text-secondary)]">
<p>
Rebuild{" "}
<strong className="text-[var(--text-primary)]">{projectName}</strong>&rsquo;s
container from the clean base image.
</p>
<p>
This deletes the container&rsquo;s volumes, which means you will lose:
</p>
<ul className="list-disc pl-5 space-y-1">
<li>
your <code className="font-mono">claude login</code> &mdash; you will need to
sign in again
</li>
<li>any skills, agents or plugins installed inside the container</li>
<li>every saved session transcript, so past sessions cannot be resumed</li>
<li>anything installed with <code className="font-mono">apt</code>, <code className="font-mono">pip</code> or <code className="font-mono">npm</code></li>
</ul>
<p>
Your mounted project folders are on the host and are{" "}
<strong className="text-[var(--text-primary)]">not</strong> affected.
</p>
</div>
</Modal>
);
}
@@ -1,109 +0,0 @@
import { useEffect, useRef, useCallback } from "react";
interface Props {
projectName: string;
operation: "starting" | "stopping" | "resetting";
progressMsg: string | null;
error: string | null;
completed: boolean;
onForceStop: () => void;
onClose: () => void;
}
const operationLabels: Record<string, string> = {
starting: "Starting",
stopping: "Stopping",
resetting: "Resetting",
};
export default function ContainerProgressModal({
projectName,
operation,
progressMsg,
error,
completed,
onForceStop,
onClose,
}: Props) {
const overlayRef = useRef<HTMLDivElement>(null);
// Auto-close on success after 800ms
useEffect(() => {
if (completed && !error) {
const timer = setTimeout(onClose, 800);
return () => clearTimeout(timer);
}
}, [completed, error, onClose]);
// Escape to close (only when completed or error)
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape" && (completed || error)) onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [completed, error, onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current && (completed || error)) onClose();
},
[completed, error, onClose],
);
const inProgress = !completed && !error;
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-80 shadow-xl text-center">
<h3 className="text-sm font-semibold mb-4">
{operationLabels[operation]} &ldquo;{projectName}&rdquo;
</h3>
{/* Spinner / checkmark / error icon */}
<div className="flex justify-center mb-3">
{error ? (
<span className="text-3xl text-[var(--error)]"></span>
) : completed ? (
<span className="text-3xl text-[var(--success)]"></span>
) : (
<div className="w-8 h-8 border-2 border-[var(--accent)] border-t-transparent rounded-full animate-spin" />
)}
</div>
{/* Progress message */}
<p className="text-xs text-[var(--text-secondary)] min-h-[1.25rem] mb-4">
{error
? <span className="text-[var(--error)]">{error}</span>
: completed
? "Done!"
: progressMsg ?? `${operationLabels[operation]}...`}
</p>
{/* Buttons */}
<div className="flex justify-center gap-2">
{inProgress && (
<button
onClick={(e) => { e.stopPropagation(); onForceStop(); }}
className="px-3 py-1.5 text-xs text-[var(--error)] border border-[var(--error)]/30 rounded hover:bg-[var(--error)]/10 transition-colors"
>
Force Stop
</button>
)}
{(completed || error) && (
<button
onClick={(e) => { e.stopPropagation(); onClose(); }}
className="px-3 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] border border-[var(--border-color)] rounded transition-colors"
>
Close
</button>
)}
</div>
</div>
</div>
);
}
@@ -0,0 +1,93 @@
import { useEffect, useState } from "react";
import type { EnvVar } from "../../lib/types";
import Button from "../ui/Button";
import { monoInputClass } from "../ui/Field";
interface Props {
envVars: EnvVar[];
disabled: boolean;
disabledReason?: string;
onSave: (vars: EnvVar[]) => Promise<unknown>;
}
/** Env-var table. Used inline in Project Home → Config and in global Settings. */
export default function EnvVarsEditor({
envVars: initial,
disabled,
disabledReason,
onSave,
}: Props) {
const [vars, setVars] = useState<EnvVar[]>(initial);
useEffect(() => {
setVars(initial);
}, [initial]);
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
const updated = [...vars];
updated[index] = { ...updated[index], [field]: value };
setVars(updated);
};
return (
<div className="space-y-2">
{disabled && disabledReason && (
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
{disabledReason}
</p>
)}
{vars.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">
No environment variables configured.
</p>
)}
{vars.map((ev, i) => (
<div key={i} className="flex gap-2 items-center">
<input
value={ev.key}
onChange={(e) => updateVar(i, "key", e.target.value)}
onBlur={() => onSave(vars)}
placeholder="KEY"
aria-label={`Environment variable ${i + 1} name`}
disabled={disabled}
className={`w-2/5 ${monoInputClass}`}
/>
<input
value={ev.value}
onChange={(e) => updateVar(i, "value", e.target.value)}
onBlur={() => onSave(vars)}
placeholder="value"
aria-label={`Environment variable ${i + 1} value`}
disabled={disabled}
className={`flex-1 ${monoInputClass}`}
/>
<Button
variant="danger"
disabled={disabled}
aria-label={`Remove environment variable ${ev.key || i + 1}`}
onClick={() => {
const updated = vars.filter((_, j) => j !== i);
setVars(updated);
onSave(updated);
}}
>
Remove
</Button>
</div>
))}
<Button
disabled={disabled}
onClick={() => {
const updated = [...vars, { key: "", value: "" }];
setVars(updated);
onSave(updated);
}}
>
+ Add variable
</Button>
</div>
);
}
+23 -111
View File
@@ -1,5 +1,7 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { EnvVar } from "../../lib/types";
import Modal from "../ui/Modal";
import Button from "../ui/Button";
import EnvVarsEditor from "./EnvVarsEditor";
interface Props {
envVars: EnvVar[];
@@ -8,117 +10,27 @@ interface Props {
onClose: () => void;
}
export default function EnvVarsModal({ envVars: initial, disabled, onSave, onClose }: Props) {
const [vars, setVars] = useState<EnvVar[]>(initial);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const updateVar = (index: number, field: keyof EnvVar, value: string) => {
const updated = [...vars];
updated[index] = { ...updated[index], [field]: value };
setVars(updated);
};
const removeVar = async (index: number) => {
const updated = vars.filter((_, i) => i !== index);
setVars(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to remove environment variable:", err);
}
};
const addVar = async () => {
const updated = [...vars, { key: "", value: "" }];
setVars(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to add environment variable:", err);
}
};
const handleBlur = async () => {
try { await onSave(vars); } catch (err) {
console.error("Failed to update environment variables:", err);
}
};
/** Global env vars (Settings). Per-project vars live inline in Config → Access. */
export default function EnvVarsModal({ envVars, disabled, onSave, onClose }: Props) {
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
<Modal
title="Environment Variables"
onClose={onClose}
widthClassName="w-[36rem]"
footer={<Button onClick={onClose}>Close</Button>}
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-4">Environment Variables</h2>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change environment variables.
</div>
)}
<div className="space-y-2 mb-4">
{vars.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No environment variables configured.</p>
)}
{vars.map((ev, i) => (
<div key={i} className="flex gap-2 items-center">
<input
value={ev.key}
onChange={(e) => updateVar(i, "key", e.target.value)}
onBlur={handleBlur}
placeholder="KEY"
disabled={disabled}
className="w-2/5 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
value={ev.value}
onChange={(e) => updateVar(i, "value", e.target.value)}
onBlur={handleBlur}
placeholder="value"
disabled={disabled}
className="flex-1 px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<button
onClick={() => removeVar(i)}
disabled={disabled}
className="px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors"
>
x
</button>
</div>
))}
</div>
<div className="flex justify-between items-center">
<button
onClick={addVar}
disabled={disabled}
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add variable
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
<EnvVarsEditor
envVars={envVars}
disabled={disabled}
disabledReason="Container must be stopped to change environment variables."
onSave={async (vars) => {
try {
await onSave(vars);
} catch (err) {
console.error("Failed to update environment variables:", err);
}
}}
/>
</Modal>
);
}
@@ -1,197 +0,0 @@
import { useEffect, useRef, useCallback } from "react";
import { useFileManager } from "../../hooks/useFileManager";
interface Props {
projectId: string;
projectName: string;
onClose: () => void;
}
function formatSize(bytes: number): string {
if (bytes < 1024) return `${bytes} B`;
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(1)} KB`;
if (bytes < 1024 * 1024 * 1024) return `${(bytes / (1024 * 1024)).toFixed(1)} MB`;
return `${(bytes / (1024 * 1024 * 1024)).toFixed(1)} GB`;
}
export default function FileManagerModal({ projectId, projectName, onClose }: Props) {
const {
currentPath,
entries,
loading,
error,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
} = useFileManager(projectId);
const overlayRef = useRef<HTMLDivElement>(null);
// Load initial directory
useEffect(() => {
navigate("/workspace");
}, [navigate]);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
// Build breadcrumbs from current path
const breadcrumbs = currentPath === "/"
? [{ label: "/", path: "/" }]
: currentPath.split("/").reduce<{ label: string; path: string }[]>((acc, part, i) => {
if (i === 0) {
acc.push({ label: "/", path: "/" });
} else if (part) {
const parentPath = acc[acc.length - 1].path;
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
acc.push({ label: part, path: fullPath });
}
return acc;
}, []);
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg shadow-xl w-[36rem] max-h-[80vh] flex flex-col">
{/* Header */}
<div className="flex items-center justify-between px-4 py-3 border-b border-[var(--border-color)]">
<h2 className="text-sm font-semibold">Files {projectName}</h2>
<button
onClick={onClose}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
×
</button>
</div>
{/* Path bar */}
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-1">
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
<button
onClick={() => navigate(crumb.path)}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap"
>
{crumb.label}
</button>
</span>
))}
<div className="flex-1" />
<button
onClick={refresh}
disabled={loading}
className="text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors disabled:opacity-50 px-1"
title="Refresh"
>
</button>
</div>
{/* Content */}
<div className="flex-1 overflow-y-auto min-h-0">
{error && (
<div className="px-4 py-2 text-xs text-[var(--error)]">{error}</div>
)}
{loading && entries.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
Loading...
</div>
) : (
<table className="w-full text-xs">
<tbody>
{/* Go up entry */}
{currentPath !== "/" && (
<tr
onClick={() => goUp()}
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
>
<td className="px-4 py-1.5 text-[var(--text-primary)]">..</td>
<td></td>
<td></td>
<td></td>
</tr>
)}
{entries.map((entry) => (
<tr
key={entry.name}
onClick={() => entry.is_directory && navigate(entry.path)}
className={`${
entry.is_directory ? "cursor-pointer" : ""
} hover:bg-[var(--bg-tertiary)] transition-colors`}
>
<td className="px-4 py-1.5">
<span className={entry.is_directory ? "text-[var(--accent)]" : "text-[var(--text-primary)]"}>
{entry.is_directory ? "📁 " : ""}{entry.name}
</span>
</td>
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap">
{!entry.is_directory && formatSize(entry.size)}
</td>
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
{entry.modified}
</td>
<td className="px-2 py-1.5 text-right">
{!entry.is_directory && (
<button
onClick={(e) => {
e.stopPropagation();
downloadFile(entry);
}}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors px-1"
title="Download"
>
</button>
)}
</td>
</tr>
))}
{entries.length === 0 && !loading && (
<tr>
<td colSpan={4} className="px-4 py-8 text-center text-[var(--text-secondary)]">
Empty directory
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
{/* Footer */}
<div className="flex items-center justify-between px-4 py-3 border-t border-[var(--border-color)]">
<button
onClick={uploadFile}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
Upload file
</button>
<button
onClick={onClose}
className="px-4 py-1.5 text-xs text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,142 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import PermissionModeControl, {
effectivePermissionMode,
permissionModePatch,
} from "./PermissionModeControl";
import type { Project } from "../../lib/types";
const baseProject: Project = {
id: "p1",
name: "api-server",
paths: [{ host_path: "/src/api", mount_name: "api" }],
container_id: null,
status: "running",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: null,
ssh_key_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
describe("effectivePermissionMode", () => {
it("falls back to the legacy boolean when permission_mode is null", () => {
expect(effectivePermissionMode(baseProject)).toBe("default");
expect(
effectivePermissionMode({ ...baseProject, full_permissions: true }),
).toBe("bypass");
});
it("prefers permission_mode when it is set", () => {
expect(
effectivePermissionMode({
...baseProject,
permission_mode: "plan",
full_permissions: true,
}),
).toBe("plan");
});
});
describe("permissionModePatch", () => {
it("keeps the legacy full_permissions flag in sync", () => {
expect(permissionModePatch("bypass")).toEqual({
permission_mode: "bypass",
full_permissions: true,
});
expect(permissionModePatch("acceptEdits")).toEqual({
permission_mode: "acceptEdits",
full_permissions: false,
});
});
});
describe("PermissionModeControl", () => {
const onChange = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
});
it("renders all four modes as a radio group with the effective one checked", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
const group = screen.getByRole("radiogroup", { name: "Permission mode" });
expect(group).toBeInTheDocument();
expect(screen.getAllByRole("radio")).toHaveLength(4);
expect(screen.getByRole("radio", { name: "Default" })).toHaveAttribute(
"aria-checked",
"true",
);
});
it("reports the picked mode", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
fireEvent.click(screen.getByRole("radio", { name: "Accept Edits" }));
expect(onChange).toHaveBeenCalledWith("acceptEdits");
});
it("moves selection with the arrow keys", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
fireEvent.keyDown(screen.getByRole("radiogroup", { name: "Permission mode" }), {
key: "ArrowRight",
});
expect(onChange).toHaveBeenCalledWith("acceptEdits");
});
it("shows sandbox state beside the control", () => {
render(<PermissionModeControl project={baseProject} onChange={onChange} />);
expect(screen.getByTestId("sandbox-state")).toHaveTextContent(
/Sandbox\s*ON/,
);
});
it("does not paint Bypass as dangerous while the sandbox contains it", () => {
render(
<PermissionModeControl
project={{ ...baseProject, permission_mode: "bypass" }}
onChange={onChange}
/>,
);
const bypass = screen.getByRole("radio", { name: "Bypass" });
expect(bypass.className).toContain("--accent-emphasis");
expect(bypass.className).not.toContain("--warning-emphasis");
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
/contained by the sandbox/i,
);
});
it("uses caution colour only when Bypass runs with the sandbox off", () => {
render(
<PermissionModeControl
project={{
...baseProject,
permission_mode: "bypass",
sandbox_mode_enabled: false,
}}
onChange={onChange}
/>,
);
const bypass = screen.getByRole("radio", { name: "Bypass" });
expect(bypass.className).toContain("--warning-emphasis");
expect(screen.getByTestId("permission-mode-hint")).toHaveTextContent(
/Caution/i,
);
});
});
@@ -0,0 +1,116 @@
import type { PermissionMode, Project } from "../../lib/types";
import SegmentedControl, { type Segment } from "../ui/SegmentedControl";
export const PERMISSION_MODES: Segment<PermissionMode>[] = [
{ value: "plan", label: "Plan", hint: "Claude proposes a plan and makes no changes." },
{ value: "default", label: "Default", hint: "Claude asks before each tool call." },
{
value: "acceptEdits",
label: "Accept Edits",
hint: "File edits are auto-approved; other tools still prompt.",
},
{
value: "bypass",
label: "Bypass",
hint: "Every tool call is auto-approved (--dangerously-skip-permissions).",
},
];
/**
* `permission_mode` is nullable for projects saved before it existed; fall back
* to the legacy boolean.
*/
export function effectivePermissionMode(project: Project): PermissionMode {
return project.permission_mode ?? (project.full_permissions ? "bypass" : "default");
}
/**
* The patch to apply when the user picks a mode. `full_permissions` is kept in
* sync so anything still reading the legacy field cannot drift.
*/
export function permissionModePatch(mode: PermissionMode): Partial<Project> {
return { permission_mode: mode, full_permissions: mode === "bypass" };
}
interface Props {
project: Project;
onChange: (mode: PermissionMode) => void;
disabled?: boolean;
/** Explanation of why the control is disabled, shown beneath it. */
disabledReason?: string;
}
/**
* The hero control. Per §B3.3: Bypass is only painted as caution when the
* sandbox is OFF with the sandbox ON, bypassing prompts is contained.
*/
export default function PermissionModeControl({
project,
onChange,
disabled = false,
disabledReason,
}: Props) {
const mode = effectivePermissionMode(project);
const sandboxOn = project.sandbox_mode_enabled;
const uncontainedBypass = mode === "bypass" && !sandboxOn;
const segments = PERMISSION_MODES.map((segment) =>
segment.value === "bypass" ? { ...segment, caution: !sandboxOn } : segment,
);
const active = PERMISSION_MODES.find((s) => s.value === mode);
return (
<div className="space-y-2">
<div className="flex flex-wrap items-center gap-x-4 gap-y-2">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
Permission mode
</span>
<SegmentedControl
label="Permission mode"
segments={segments}
value={mode}
onChange={onChange}
disabled={disabled}
/>
<span
className="text-xs text-[var(--text-secondary)]"
data-testid="sandbox-state"
>
Sandbox{" "}
<span
className={
sandboxOn ? "text-[var(--success)] font-semibold" : "text-[var(--warning)] font-semibold"
}
>
{sandboxOn ? "ON" : "OFF"}
</span>
{sandboxOn ? " — bubblewrap isolation" : " — no filesystem/network isolation"}
</span>
</div>
<p
className={`text-xs leading-snug ${
uncontainedBypass ? "text-[var(--warning)]" : "text-[var(--text-secondary)]"
}`}
data-testid="permission-mode-hint"
>
{uncontainedBypass
? "Caution: every tool call is auto-approved and the sandbox is off, so nothing contains what Claude runs."
: mode === "bypass"
? "Every tool call is auto-approved — contained by the sandbox."
: (active?.hint ?? "")}
</p>
{project.status === "running" && (
<p className="text-xs text-[var(--text-disabled)]">
Applies to terminals opened from now on.
</p>
)}
{disabled && disabledReason && (
<p className="text-xs text-[var(--text-disabled)]">{disabledReason}</p>
)}
</div>
);
}
@@ -0,0 +1,128 @@
import { useEffect, useState } from "react";
import type { PortMapping } from "../../lib/types";
import Button from "../ui/Button";
import { monoInputClass, selectClass } from "../ui/Field";
interface Props {
portMappings: PortMapping[];
disabled: boolean;
disabledReason?: string;
onSave: (mappings: PortMapping[]) => Promise<unknown>;
}
export default function PortMappingsEditor({
portMappings: initial,
disabled,
disabledReason,
onSave,
}: Props) {
const [mappings, setMappings] = useState<PortMapping[]>(initial);
useEffect(() => {
setMappings(initial);
}, [initial]);
const updatePort = (
index: number,
field: "host_port" | "container_port",
value: string,
) => {
const updated = [...mappings];
const num = parseInt(value, 10);
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
setMappings(updated);
};
return (
<div className="space-y-2">
{disabled && disabledReason && (
<p className="px-2 py-1.5 bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)] text-xs text-[var(--warning)]">
{disabledReason}
</p>
)}
{mappings.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
)}
{mappings.length > 0 && (
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
<span className="w-[28%]">Host port</span>
<span className="w-[28%]">Container port</span>
<span className="w-[22%]">Protocol</span>
<span className="flex-1" />
</div>
)}
{mappings.map((pm, i) => (
<div key={i} className="flex gap-2 items-center">
<input
type="number"
min="1"
max="65535"
value={pm.host_port || ""}
onChange={(e) => updatePort(i, "host_port", e.target.value)}
onBlur={() => onSave(mappings)}
placeholder="8080"
aria-label={`Host port ${i + 1}`}
disabled={disabled}
className={`w-[28%] ${monoInputClass}`}
/>
<input
type="number"
min="1"
max="65535"
value={pm.container_port || ""}
onChange={(e) => updatePort(i, "container_port", e.target.value)}
onBlur={() => onSave(mappings)}
placeholder="8080"
aria-label={`Container port ${i + 1}`}
disabled={disabled}
className={`w-[28%] ${monoInputClass}`}
/>
<select
value={pm.protocol}
aria-label={`Protocol ${i + 1}`}
onChange={(e) => {
const updated = [...mappings];
updated[i] = { ...updated[i], protocol: e.target.value };
setMappings(updated);
onSave(updated);
}}
disabled={disabled}
className={`w-[22%] ${selectClass}`}
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<Button
variant="danger"
disabled={disabled}
aria-label={`Remove port mapping ${i + 1}`}
onClick={() => {
const updated = mappings.filter((_, j) => j !== i);
setMappings(updated);
onSave(updated);
}}
>
Remove
</Button>
</div>
))}
<Button
disabled={disabled}
onClick={() => {
const updated = [
...mappings,
{ host_port: 0, container_port: 0, protocol: "tcp" },
];
setMappings(updated);
onSave(updated);
}}
>
+ Add port mapping
</Button>
</div>
);
}
@@ -1,157 +0,0 @@
import { useState, useEffect, useRef, useCallback } from "react";
import type { PortMapping } from "../../lib/types";
interface Props {
portMappings: PortMapping[];
disabled: boolean;
onSave: (mappings: PortMapping[]) => Promise<void>;
onClose: () => void;
}
export default function PortMappingsModal({ portMappings: initial, disabled, onSave, onClose }: Props) {
const [mappings, setMappings] = useState<PortMapping[]>(initial);
const overlayRef = useRef<HTMLDivElement>(null);
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if (e.key === "Escape") onClose();
};
document.addEventListener("keydown", handleKeyDown);
return () => document.removeEventListener("keydown", handleKeyDown);
}, [onClose]);
const handleOverlayClick = useCallback(
(e: React.MouseEvent<HTMLDivElement>) => {
if (e.target === overlayRef.current) onClose();
},
[onClose],
);
const updatePort = (index: number, field: "host_port" | "container_port", value: string) => {
const updated = [...mappings];
const num = parseInt(value, 10);
updated[index] = { ...updated[index], [field]: isNaN(num) ? 0 : num };
setMappings(updated);
};
const updateProtocol = (index: number, value: string) => {
const updated = [...mappings];
updated[index] = { ...updated[index], protocol: value };
setMappings(updated);
};
const removeMapping = async (index: number) => {
const updated = mappings.filter((_, i) => i !== index);
setMappings(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to remove port mapping:", err);
}
};
const addMapping = async () => {
const updated = [...mappings, { host_port: 0, container_port: 0, protocol: "tcp" }];
setMappings(updated);
try { await onSave(updated); } catch (err) {
console.error("Failed to add port mapping:", err);
}
};
const handleBlur = async () => {
try { await onSave(mappings); } catch (err) {
console.error("Failed to update port mappings:", err);
}
};
return (
<div
ref={overlayRef}
onClick={handleOverlayClick}
className="fixed inset-0 bg-black/50 flex items-center justify-center z-50"
>
<div className="bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-lg p-6 w-[36rem] shadow-xl max-h-[80vh] overflow-y-auto">
<h2 className="text-lg font-semibold mb-2">Port Mappings</h2>
<p className="text-xs text-[var(--text-secondary)] mb-4">
Map host ports to container ports. Services can be started after the container is running.
</p>
{disabled && (
<div className="px-2 py-1.5 mb-3 bg-[var(--warning)]/15 border border-[var(--warning)]/30 rounded text-xs text-[var(--warning)]">
Container must be stopped to change port mappings.
</div>
)}
<div className="space-y-2 mb-4">
{mappings.length === 0 && (
<p className="text-xs text-[var(--text-secondary)]">No port mappings configured.</p>
)}
{mappings.length > 0 && (
<div className="flex gap-2 items-center text-xs text-[var(--text-secondary)] px-0.5">
<span className="w-[30%]">Host Port</span>
<span className="w-[30%]">Container Port</span>
<span className="w-[25%]">Protocol</span>
<span className="w-[15%]" />
</div>
)}
{mappings.map((pm, i) => (
<div key={i} className="flex gap-2 items-center">
<input
type="number"
min="1"
max="65535"
value={pm.host_port || ""}
onChange={(e) => updatePort(i, "host_port", e.target.value)}
onBlur={handleBlur}
placeholder="8080"
disabled={disabled}
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<input
type="number"
min="1"
max="65535"
value={pm.container_port || ""}
onChange={(e) => updatePort(i, "container_port", e.target.value)}
onBlur={handleBlur}
placeholder="8080"
disabled={disabled}
className="w-[30%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50 font-mono"
/>
<select
value={pm.protocol}
onChange={(e) => { updateProtocol(i, e.target.value); handleBlur(); }}
disabled={disabled}
className="w-[25%] px-2 py-1.5 bg-[var(--bg-primary)] border border-[var(--border-color)] rounded text-sm text-[var(--text-primary)] focus:outline-none focus:border-[var(--accent)] disabled:opacity-50"
>
<option value="tcp">TCP</option>
<option value="udp">UDP</option>
</select>
<button
onClick={() => removeMapping(i)}
disabled={disabled}
className="w-[15%] px-2 py-1.5 text-sm text-[var(--error)] hover:bg-[var(--bg-primary)] rounded disabled:opacity-50 transition-colors text-center"
>
x
</button>
</div>
))}
</div>
<div className="flex justify-between items-center">
<button
onClick={addMapping}
disabled={disabled}
className="text-sm text-[var(--accent)] hover:text-[var(--accent-hover)] disabled:opacity-50 transition-colors"
>
+ Add port mapping
</button>
<button
onClick={onClose}
className="px-4 py-2 text-sm text-[var(--text-secondary)] hover:text-[var(--text-primary)] transition-colors"
>
Close
</button>
</div>
</div>
</div>
);
}
@@ -1,140 +0,0 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent, act } from "@testing-library/react";
import ProjectCard from "./ProjectCard";
import type { Project } from "../../lib/types";
// Mock Tauri dialog plugin
vi.mock("@tauri-apps/plugin-dialog", () => ({
open: vi.fn(),
}));
// Mock hooks
const mockUpdate = vi.fn();
const mockStart = vi.fn();
const mockStop = vi.fn();
const mockRebuild = vi.fn();
const mockRemove = vi.fn();
vi.mock("../../hooks/useProjects", () => ({
useProjects: () => ({
start: mockStart,
stop: mockStop,
rebuild: mockRebuild,
remove: mockRemove,
update: mockUpdate,
}),
}));
vi.mock("../../hooks/useTerminal", () => ({
useTerminal: () => ({
open: vi.fn(),
}),
}));
vi.mock("../../hooks/useMcpServers", () => ({
useMcpServers: () => ({
mcpServers: [],
refresh: vi.fn(),
add: vi.fn(),
update: vi.fn(),
remove: vi.fn(),
}),
}));
let mockSelectedProjectId: string | null = null;
vi.mock("../../store/appState", () => ({
useAppState: vi.fn((selector) =>
selector({
selectedProjectId: mockSelectedProjectId,
setSelectedProject: vi.fn(),
})
),
}));
const mockProject: Project = {
id: "test-1",
name: "Test Project",
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
container_id: null,
status: "stopped",
auth_mode: "anthropic",
bedrock_config: null,
allow_docker_access: false,
ssh_key_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
enabled_mcp_servers: [],
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
describe("ProjectCard", () => {
beforeEach(() => {
vi.clearAllMocks();
mockSelectedProjectId = null;
});
it("renders project name and path", () => {
render(<ProjectCard project={mockProject} />);
expect(screen.getByText("Test Project")).toBeInTheDocument();
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
});
it("card root has min-w-0 and overflow-hidden to contain content", () => {
const { container } = render(<ProjectCard project={mockProject} />);
const card = container.firstElementChild;
expect(card).not.toBeNull();
expect(card!.className).toContain("min-w-0");
expect(card!.className).toContain("overflow-hidden");
});
describe("when selected and showing config", () => {
beforeEach(() => {
mockSelectedProjectId = "test-1";
});
it("expanded area has min-w-0 and overflow-hidden", () => {
const { container } = render(<ProjectCard project={mockProject} />);
// The expanded section (mt-2 ml-4) contains the auth/action/config controls
const expandedSection = container.querySelector(".ml-4.mt-2");
expect(expandedSection).not.toBeNull();
expect(expandedSection!.className).toContain("min-w-0");
expect(expandedSection!.className).toContain("overflow-hidden");
});
it("folder path inputs use min-w-0 to allow shrinking", async () => {
const { container } = render(<ProjectCard project={mockProject} />);
// Click Config button to show config panel
await act(async () => {
fireEvent.click(screen.getByText("Config"));
});
// After config is shown, check the folder host_path input has min-w-0
const hostPathInputs = container.querySelectorAll('input[placeholder="/path/to/folder"]');
expect(hostPathInputs.length).toBeGreaterThan(0);
expect(hostPathInputs[0].className).toContain("min-w-0");
});
it("config panel container has overflow-hidden", async () => {
const { container } = render(<ProjectCard project={mockProject} />);
// Click Config button
await act(async () => {
fireEvent.click(screen.getByText("Config"));
});
// The config panel has border-t and overflow containment classes
const allDivs = container.querySelectorAll("div");
const configPanel = Array.from(allDivs).find(
(div) => div.className.includes("border-t") && div.className.includes("min-w-0")
);
expect(configPanel).toBeDefined();
expect(configPanel!.className).toContain("overflow-hidden");
});
});
});
File diff suppressed because it is too large Load Diff
+12 -15
View File
@@ -1,35 +1,32 @@
import { useState } from "react";
import { useProjects } from "../../hooks/useProjects";
import ProjectCard from "./ProjectCard";
import ProjectRow from "./ProjectRow";
import AddProjectDialog from "./AddProjectDialog";
import Button from "../ui/Button";
export default function ProjectList() {
const { projects } = useProjects();
const [showAdd, setShowAdd] = useState(false);
return (
<div className="p-3">
<div className="flex items-center justify-between px-2 py-1 mb-2">
<span className="text-xs font-semibold uppercase text-[var(--text-secondary)]">
<div className="p-2">
<div className="flex items-center justify-between px-1 py-1 mb-1.5">
<span className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
Projects
</span>
<button
onClick={() => setShowAdd(true)}
className="text-lg leading-none text-[var(--text-secondary)] hover:text-[var(--accent)] transition-colors"
title="Add project"
>
+
</button>
<Button onClick={() => setShowAdd(true)} aria-label="Add project">
+ Add
</Button>
</div>
{projects.length === 0 ? (
<p className="px-2 text-sm text-[var(--text-secondary)]">
No projects yet. Click + to add one.
<p className="px-1 text-xs text-[var(--text-secondary)]">
No projects yet use + Add to create one.
</p>
) : (
<div className="flex flex-col gap-1">
<div className="flex flex-col gap-0.5">
{projects.map((project) => (
<ProjectCard key={project.id} project={project} />
<ProjectRow key={project.id} project={project} />
))}
</div>
)}
@@ -0,0 +1,148 @@
import { describe, it, expect, vi, beforeEach } from "vitest";
import { render, screen, fireEvent } from "@testing-library/react";
import ProjectRow from "./ProjectRow";
import type { Project } from "../../lib/types";
const mockStart = vi.fn();
const mockStop = vi.fn();
const mockOpenClaudeTerminal = vi.fn();
vi.mock("../../hooks/useProjectActions", () => ({
useProjectActions: () => ({
busy: false,
backingUp: false,
handleStart: mockStart,
handleStop: mockStop,
handleReset: vi.fn(),
handleBackup: vi.fn(),
openClaudeTerminal: mockOpenClaudeTerminal,
openShell: vi.fn(),
openTerminalWithCommand: vi.fn(),
}),
}));
const mockOpenProjectHome = vi.fn();
let storeState: Record<string, unknown> = {};
vi.mock("../../store/appState", async () => {
const actual = await vi.importActual<typeof import("../../store/appState")>(
"../../store/appState",
);
return {
...actual,
useAppState: vi.fn((selector: (s: unknown) => unknown) => selector(storeState)),
};
});
const baseProject: Project = {
id: "test-1",
name: "Test Project",
paths: [{ host_path: "/home/user/project", mount_name: "project" }],
container_id: null,
status: "stopped",
backend: "anthropic",
bedrock_config: null,
ollama_config: null,
openai_compatible_config: null,
allow_docker_access: false,
sandbox_mode_enabled: true,
mission_control_enabled: false,
auth_bridge_enabled: false,
use_shared_auth_token: true,
full_permissions: false,
permission_mode: null,
ssh_key_path: null,
git_token: null,
git_user_name: null,
git_user_email: null,
custom_env_vars: [],
port_mappings: [],
claude_instructions: null,
claude_code_settings: null,
renamed_session_names: {},
created_at: "2026-01-01T00:00:00Z",
updated_at: "2026-01-01T00:00:00Z",
};
function setStore(overrides: Record<string, unknown> = {}) {
storeState = {
activeTabKey: null,
selectedProjectId: null,
openProjectHome: mockOpenProjectHome,
containerProgress: {},
...overrides,
};
}
describe("ProjectRow", () => {
beforeEach(() => {
vi.clearAllMocks();
setStore();
});
it("renders project name and mount path", () => {
render(<ProjectRow project={baseProject} />);
expect(screen.getByText("Test Project")).toBeInTheDocument();
expect(screen.getByText("/workspace/project")).toBeInTheDocument();
});
it("row root has min-w-0 and overflow-hidden to contain content", () => {
const { container } = render(<ProjectRow project={baseProject} />);
const row = container.firstElementChild;
expect(row).not.toBeNull();
expect(row!.className).toContain("min-w-0");
expect(row!.className).toContain("overflow-hidden");
});
it("communicates status with a word, not colour alone", () => {
render(<ProjectRow project={baseProject} />);
expect(screen.getAllByText("Stopped").length).toBeGreaterThan(0);
render(<ProjectRow project={{ ...baseProject, status: "error" }} />);
expect(screen.getAllByText("Error").length).toBeGreaterThan(0);
});
it("selecting the row opens that project's home tab instead of expanding in place", () => {
render(<ProjectRow project={baseProject} />);
fireEvent.click(screen.getByText("Test Project"));
expect(mockOpenProjectHome).toHaveBeenCalledWith("test-1");
// No config form is rendered in the sidebar any more.
expect(screen.queryByPlaceholderText("/path/to/folder")).toBeNull();
});
it("offers start when stopped and stop when running", () => {
const { unmount } = render(<ProjectRow project={baseProject} />);
fireEvent.click(screen.getByRole("button", { name: "Start Test Project" }));
expect(mockStart).toHaveBeenCalled();
unmount();
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
fireEvent.click(screen.getByRole("button", { name: "Stop Test Project" }));
expect(mockStop).toHaveBeenCalled();
});
it("only allows opening a terminal while the container runs", () => {
const { unmount } = render(<ProjectRow project={baseProject} />);
expect(
screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
}),
).toBeDisabled();
unmount();
render(<ProjectRow project={{ ...baseProject, status: "running" }} />);
fireEvent.click(
screen.getByRole("button", {
name: "Open a Claude terminal for Test Project",
}),
);
expect(mockOpenClaudeTerminal).toHaveBeenCalled();
});
it("shows container progress inline rather than in a blocking modal", () => {
setStore({ containerProgress: { "test-1": "Pulling image…" } });
render(<ProjectRow project={{ ...baseProject, status: "starting" }} />);
expect(screen.getByText("Pulling image…")).toBeInTheDocument();
expect(screen.queryByRole("dialog")).toBeNull();
});
});
+140
View File
@@ -0,0 +1,140 @@
import { useShallow } from "zustand/react/shallow";
import type { Project } from "../../lib/types";
import { useAppState, homeTabKey } from "../../store/appState";
import { useProjectActions } from "../../hooks/useProjectActions";
import { ProjectStatusIndicator } from "../ui/StatusIndicator";
interface Props {
project: Project;
}
/**
* Sidebar rows are select-only: name, paths, status, and hover controls.
* Clicking a row opens (or focuses) that project's Project Home tab the
* settings form no longer lives in a 280px accordion.
*/
export default function ProjectRow({ project }: Props) {
const { activeTabKey, selectedProjectId, openProjectHome, progress } = useAppState(
useShallow((s) => ({
activeTabKey: s.activeTabKey,
selectedProjectId: s.selectedProjectId,
openProjectHome: s.openProjectHome,
progress: s.containerProgress[project.id],
})),
);
const { busy, handleStart, handleStop, openClaudeTerminal } =
useProjectActions(project);
const isSelected =
activeTabKey === homeTabKey(project.id) || selectedProjectId === project.id;
const isRunning = project.status === "running";
const isTransitioning =
project.status === "starting" || project.status === "stopping";
return (
<div
className={`group relative px-2 py-1.5 rounded-[var(--radius-control)] transition-colors min-w-0 overflow-hidden ${
isSelected
? "bg-[var(--bg-tertiary)]"
: "hover:bg-[var(--bg-tertiary)]"
}`}
>
<button
type="button"
onClick={() => openProjectHome(project.id)}
aria-current={isSelected ? "true" : undefined}
className="w-full text-left min-w-0"
>
<div className="flex items-center gap-2 min-w-0">
<ProjectStatusIndicator status={project.status} iconOnly />
<span className="text-[13px] font-medium truncate flex-1 text-[var(--text-primary)]">
{project.name}
</span>
{/* Space reserved for the hover controls so the name never jumps. */}
<span className="w-[3.75rem] flex-shrink-0" aria-hidden="true" />
</div>
<div className="mt-0.5 ml-4 space-y-0.5 min-w-0">
{project.paths.map((pp, i) => (
<div
key={i}
className="text-xs text-[var(--text-secondary)] truncate font-mono"
>
/workspace/{pp.mount_name}
</div>
))}
<div className="text-xs">
{isTransitioning ? (
<span className="text-[var(--warning)] truncate block">
{progress ?? `${project.status}`}
</span>
) : (
<ProjectStatusIndicator
status={project.status}
className="text-xs"
/>
)}
</div>
</div>
</button>
{/* Hover / focus-within controls */}
<div className="absolute top-1.5 right-2 flex items-center gap-0.5 opacity-0 group-hover:opacity-100 group-focus-within:opacity-100 transition-opacity">
<button
type="button"
disabled={busy}
// While a container is mid-transition this stays live so it can act
// as the force-stop that the old progress modal used to offer.
onClick={() => (isRunning || isTransitioning ? handleStop() : handleStart())}
title={
isTransitioning
? `Force stop ${project.name}`
: isRunning
? `Stop ${project.name}`
: `Start ${project.name}`
}
aria-label={
isTransitioning
? `Force stop ${project.name}`
: isRunning
? `Stop ${project.name}`
: `Start ${project.name}`
}
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
>
{isRunning || isTransitioning ? (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<rect x="6" y="6" width="12" height="12" rx="1.5" />
</svg>
) : (
<svg className="w-3.5 h-3.5" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
<path d="M8 5.5v13l11-6.5z" />
</svg>
)}
</button>
<button
type="button"
disabled={!isRunning}
onClick={() => openClaudeTerminal()}
title={`Open a Claude terminal for ${project.name}`}
aria-label={`Open a Claude terminal for ${project.name}`}
className="w-6 h-6 flex items-center justify-center rounded-[var(--radius-control)] text-[var(--text-secondary)] hover:text-[var(--text-primary)] hover:bg-[var(--bg-primary)] disabled:text-[var(--text-disabled)] transition-colors"
>
<svg
className="w-3.5 h-3.5"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2"
strokeLinecap="round"
strokeLinejoin="round"
aria-hidden="true"
>
<rect x="3" y="4" width="18" height="16" rx="2" />
<polyline points="7 9 10 12 7 15" />
<line x1="13" y1="15" x2="17" y2="15" />
</svg>
</button>
</div>
</div>
);
}
@@ -0,0 +1,287 @@
import { useCallback, useEffect, useState } from "react";
import type { Project, ScheduledTask, SchedulerNotification } from "../../../lib/types";
import {
clearSchedulerNotifications,
getScheduledTaskLog,
getSchedulerNotifications,
listScheduledTasks,
removeScheduledTask,
runScheduledTaskNow,
setScheduledTaskEnabled,
} from "../../../lib/tauri-commands";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import Toggle from "../../ui/Toggle";
import Modal from "../../ui/Modal";
import StatusIndicator from "../../ui/StatusIndicator";
import TaskEditorModal from "./TaskEditorModal";
import { formatAge } from "./format";
interface Props {
project: Project;
}
/**
* UI for `triple-c-scheduler`, which ships in every container and until now
* had no interface beyond a CLAUDE.md paragraph.
*/
export default function AutomationTab({ project }: Props) {
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
const [notifications, setNotifications] = useState<SchedulerNotification[]>([]);
const [loading, setLoading] = useState(false);
const [busyTaskId, setBusyTaskId] = useState<string | null>(null);
const [log, setLog] = useState<{ task: ScheduledTask; text: string } | null>(null);
const [confirmRemoveId, setConfirmRemoveId] = useState<string | null>(null);
/** `undefined` = closed, `null` = creating, a task = editing it. */
const [editing, setEditing] = useState<ScheduledTask | null | undefined>(undefined);
const pushToast = useAppState((s) => s.pushToast);
const running = project.status === "running";
const load = useCallback(() => {
if (!running) {
setTasks([]);
setNotifications([]);
return;
}
setLoading(true);
Promise.all([
listScheduledTasks(project.id).catch(() => [] as ScheduledTask[]),
getSchedulerNotifications(project.id).catch(
() => [] as SchedulerNotification[],
),
])
.then(([t, n]) => {
setTasks(t);
setNotifications(n);
})
.finally(() => setLoading(false));
}, [project.id, running]);
useEffect(load, [load]);
const withTask = async (taskId: string, label: string, fn: () => Promise<unknown>) => {
setBusyTaskId(taskId);
try {
await fn();
load();
} catch (e) {
pushToast({ kind: "error", message: `${label} failed`, detail: String(e) });
} finally {
setBusyTaskId(null);
}
};
const openLog = async (task: ScheduledTask) => {
setBusyTaskId(task.id);
try {
const text = await getScheduledTaskLog(project.id, task.id, 200);
setLog({ task, text });
} catch (e) {
pushToast({
kind: "error",
message: `Could not read the log for “${task.name}`,
detail: String(e),
});
} finally {
setBusyTaskId(null);
}
};
const removing = tasks.find((t) => t.id === confirmRemoveId) ?? null;
return (
<div className="p-4 space-y-6 max-w-4xl">
{/* Notifications */}
{notifications.length > 0 && (
<section className="border border-[var(--accent)]/40 bg-[var(--accent-muted)] rounded-[var(--radius-panel)]">
<header className="flex items-center justify-between px-3 py-2 border-b border-[var(--border-color)]">
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--accent)]">
{notifications.length} notification
{notifications.length === 1 ? "" : "s"}
</h2>
<Button
onClick={async () => {
try {
await clearSchedulerNotifications(project.id);
setNotifications([]);
} catch (e) {
pushToast({
kind: "error",
message: "Could not clear notifications",
detail: String(e),
});
}
}}
>
Clear all
</Button>
</header>
<ul className="divide-y divide-[var(--border-color)]">
{notifications.map((n, i) => (
<li key={`${n.task_id}-${i}`} className="px-3 py-2">
<div className="flex items-center gap-2 text-xs">
<span className="font-medium text-[var(--text-primary)]">
{n.task_name ?? n.task_id}
</span>
{n.status && (
<StatusIndicator
tone={n.status.toLowerCase() === "success" ? "ok" : "error"}
label={n.status}
/>
)}
<span className="text-[var(--text-secondary)] ml-auto">
{formatAge(n.created_at) ?? n.time ?? ""}
</span>
</div>
<p className="mt-0.5 text-xs text-[var(--text-secondary)] whitespace-pre-wrap break-words">
{n.summary ?? n.body}
</p>
</li>
))}
</ul>
</section>
)}
<section>
<div className="flex items-center justify-between mb-3">
<p className="text-xs text-[var(--text-secondary)]">
Recurring Claude Code runs managed by{" "}
<code className="font-mono text-[var(--text-primary)]">
triple-c-scheduler
</code>{" "}
inside the container.
</p>
<div className="flex items-center gap-2">
<Button onClick={load} disabled={!running || loading}>
{loading ? "Refreshing…" : "Refresh"}
</Button>
<Button variant="primary" disabled={!running} onClick={() => setEditing(null)}>
New task
</Button>
</div>
</div>
{!running ? (
<p className="text-[13px] text-[var(--text-secondary)]">
Start the container to list its scheduled tasks.
</p>
) : tasks.length === 0 && !loading ? (
<p className="text-[13px] text-[var(--text-secondary)]">
No scheduled tasks yet. Use <strong>New task</strong>, or ask Claude to add one with{" "}
<code className="font-mono">triple-c-scheduler add</code>.
</p>
) : (
<ul className="space-y-1">
{tasks.map((task) => (
<li
key={task.id}
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
>
<div className="flex-1 min-w-0">
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--text-primary)] truncate">
{task.name}
</span>
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--bg-tertiary)] text-[var(--text-secondary)]">
{task.task_type}
</span>
</div>
<div className="text-xs text-[var(--text-secondary)] font-mono truncate">
{task.at ?? task.schedule}
{task.last_run ? ` · last run ${formatAge(task.last_run) ?? task.last_run}` : ""}
</div>
</div>
<Toggle
label={`${task.name} enabled`}
checked={task.enabled}
disabled={busyTaskId === task.id}
onChange={(v) =>
withTask(task.id, "Toggle task", () =>
setScheduledTaskEnabled(project.id, task.id, v),
)
}
/>
<Button
disabled={busyTaskId === task.id}
onClick={() =>
withTask(task.id, "Run now", () =>
runScheduledTaskNow(project.id, task.id),
)
}
>
Run now
</Button>
<Button disabled={busyTaskId === task.id} onClick={() => setEditing(task)}>
Edit
</Button>
<Button disabled={busyTaskId === task.id} onClick={() => openLog(task)}>
Log
</Button>
<Button
variant="danger"
disabled={busyTaskId === task.id}
onClick={() => setConfirmRemoveId(task.id)}
>
Remove
</Button>
</li>
))}
</ul>
)}
</section>
{editing !== undefined && (
<TaskEditorModal
project={project}
task={editing}
onClose={() => setEditing(undefined)}
onSaved={load}
/>
)}
{log && (
<Modal
title={`Log — ${log.task.name}`}
onClose={() => setLog(null)}
widthClassName="w-[46rem]"
footer={<Button onClick={() => setLog(null)}>Close</Button>}
>
<pre className="whitespace-pre-wrap break-words font-mono text-xs text-[var(--text-secondary)]">
{log.text.trim() || "(empty log)"}
</pre>
</Modal>
)}
{removing && (
<Modal
title="Remove scheduled task"
onClose={() => setConfirmRemoveId(null)}
widthClassName="w-[26rem]"
footer={
<>
<Button variant="ghost" onClick={() => setConfirmRemoveId(null)}>
Cancel
</Button>
<Button
className="bg-[var(--error-emphasis)] text-white border border-transparent hover:opacity-90"
onClick={() => {
setConfirmRemoveId(null);
withTask(removing.id, "Remove task", () =>
removeScheduledTask(project.id, removing.id),
);
}}
>
Remove
</Button>
</>
}
>
<p className="text-[13px] text-[var(--text-secondary)]">
Remove <strong className="text-[var(--text-primary)]">{removing.name}</strong>{" "}
from this container&rsquo;s scheduler?
</p>
</Modal>
)}
</div>
);
}
@@ -0,0 +1,166 @@
import { useEffect, useState } from "react";
import type {
CapabilityGroup,
ContainerCapabilities,
Project,
} from "../../../lib/types";
import { listContainerCapabilities } from "../../../lib/tauri-commands";
import Modal from "../../ui/Modal";
import Button from "../../ui/Button";
/**
* Read-only inventory of what Claude Code can do inside this container.
* Triple-C surfaces counts and launches the real editors in the terminal
* it does not rebuild `/agents`, `/hooks`, or `/plugins` as forms.
*/
const GROUPS: { key: keyof ContainerCapabilities; label: string }[] = [
{ key: "skills", label: "Skills" },
{ key: "agents", label: "Agents" },
{ key: "commands", label: "Commands" },
{ key: "hooks", label: "Hooks" },
{ key: "plugins", label: "Plugins" },
{ key: "mcp_servers", label: "MCP servers" },
];
const SLASH_HINT: Partial<Record<keyof ContainerCapabilities, string>> = {
agents: "/agents",
hooks: "/hooks",
plugins: "/plugins",
mcp_servers: "/mcp",
};
interface Props {
project: Project;
onManageInTerminal: (command: string) => void;
}
export default function CapabilityTiles({ project, onManageInTerminal }: Props) {
const [capabilities, setCapabilities] = useState<ContainerCapabilities | null>(null);
const [loading, setLoading] = useState(false);
const [open, setOpen] = useState<keyof ContainerCapabilities | null>(null);
const running = project.status === "running";
useEffect(() => {
if (!running) {
setCapabilities(null);
return;
}
let cancelled = false;
setLoading(true);
listContainerCapabilities(project.id)
.then((c) => {
if (!cancelled) setCapabilities(c);
})
// Introspection degrades to "nothing found" when the container is
// unreachable — that is an empty state, not an error banner.
.catch(() => {
if (!cancelled) setCapabilities(null);
})
.finally(() => {
if (!cancelled) setLoading(false);
});
return () => {
cancelled = true;
};
}, [project.id, running, project.container_id]);
const openGroup: CapabilityGroup | null =
open && capabilities ? capabilities[open] : null;
const openLabel = GROUPS.find((g) => g.key === open)?.label ?? "";
return (
<section>
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)] mb-2">
Capabilities
</h2>
{!running ? (
<p className="text-xs text-[var(--text-secondary)]">
Start the container to read its skills, agents, commands, hooks and plugins.
</p>
) : loading && !capabilities ? (
<p className="text-xs text-[var(--text-secondary)]">Reading container volume</p>
) : (
<div className="flex flex-wrap gap-2">
{GROUPS.map(({ key, label }) => {
const count = capabilities?.[key].count ?? 0;
return (
<button
key={key}
type="button"
disabled={count === 0}
onClick={() => setOpen(key)}
className="flex items-baseline gap-2 px-3 py-2 min-w-[7.5rem] text-left bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-panel)] hover:border-[var(--accent)] disabled:hover:border-[var(--border-color)] disabled:cursor-default transition-colors"
>
<span
className={`text-lg font-semibold tabular-nums ${
count === 0 ? "text-[var(--text-disabled)]" : "text-[var(--text-primary)]"
}`}
>
{count}
</span>
<span className="text-xs text-[var(--text-secondary)]">{label}</span>
</button>
);
})}
</div>
)}
{open && openGroup && (
<Modal
title={`${openLabel}${project.name}`}
description={
SLASH_HINT[open]
? `Claude Code manages these with ${SLASH_HINT[open]}.`
: undefined
}
onClose={() => setOpen(null)}
widthClassName="w-[34rem]"
footer={
<>
<Button
variant="primary"
onClick={() => {
setOpen(null);
// Claude Code owns the editors; we just deep-link into them.
onManageInTerminal("claude");
}}
>
Manage in terminal
</Button>
<Button onClick={() => setOpen(null)}>Close</Button>
</>
}
>
{openGroup.items.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">Nothing configured.</p>
) : (
<ul className="space-y-2">
{openGroup.items.map((item, i) => (
<li
key={`${item.name}-${i}`}
className="pb-2 border-b border-[var(--border-color)] last:border-b-0"
>
<div className="flex items-center gap-2">
<span className="text-[13px] font-medium text-[var(--text-primary)] font-mono">
{item.name}
</span>
<span className="text-[10px] uppercase tracking-wide px-1.5 py-0.5 rounded-[var(--radius-control)] bg-[var(--accent-muted)] text-[var(--accent)]">
{item.scope}
</span>
</div>
{item.description && (
<p className="mt-0.5 text-xs text-[var(--text-secondary)]">
{item.description}
</p>
)}
</li>
))}
</ul>
)}
</Modal>
)}
</section>
);
}
@@ -0,0 +1,57 @@
import type { Project } from "../../../lib/types";
import type { SaveState } from "../../../hooks/useSaveState";
import SaveIndicator from "../../ui/SaveIndicator";
import WorkspaceSection from "./config/WorkspaceSection";
import ModelSection from "./config/ModelSection";
import AccessSection from "./config/AccessSection";
import RuntimeSection from "./config/RuntimeSection";
interface Props {
project: Project;
save: (patch: Partial<Project>) => Promise<boolean>;
saveState: SaveState;
}
const STOPPED_ONLY =
"Container must be stopped to change this setting.";
/**
* Everything the seven config modals used to hold, full-width and grouped.
* Saves happen on blur; the indicator in the header reports the outcome.
*/
export default function ConfigTab({ project, save, saveState }: Props) {
const isStopped = project.status === "stopped" || project.status === "error";
const disabled = !isStopped;
return (
<div className="p-4 space-y-4 max-w-4xl">
<div className="flex items-center justify-between gap-4 min-h-[1.5rem]">
{disabled ? (
<p className="px-2 py-1 text-xs text-[var(--warning)] bg-[var(--warning-muted)] border border-[var(--warning)]/30 rounded-[var(--radius-control)]">
Container is {project.status} stop it to change these settings.
</p>
) : (
<p className="text-xs text-[var(--text-secondary)]">
Changes save when a field loses focus.
</p>
)}
<SaveIndicator state={saveState} />
</div>
<WorkspaceSection project={project} save={save} disabled={disabled} />
<ModelSection project={project} save={save} disabled={disabled} />
<AccessSection
project={project}
save={save}
disabled={disabled}
disabledReason={STOPPED_ONLY}
/>
<RuntimeSection
project={project}
save={save}
disabled={disabled}
disabledReason={STOPPED_ONLY}
/>
</div>
);
}
@@ -0,0 +1,162 @@
import { useEffect } from "react";
import type { Project } from "../../../lib/types";
import { useFileManager } from "../../../hooks/useFileManager";
import Button from "../../ui/Button";
import { formatBytes } from "./format";
interface Props {
project: Project;
}
/** The old 42rem FileManager popup, now a main-area section. */
export default function FilesTab({ project }: Props) {
const {
currentPath,
entries,
loading,
error,
navigate,
goUp,
refresh,
downloadFile,
uploadFile,
} = useFileManager(project.id);
const running = project.status === "running";
useEffect(() => {
if (running) navigate("/workspace");
// Re-list when the container comes up.
}, [navigate, running]);
const breadcrumbs =
currentPath === "/"
? [{ label: "/", path: "/" }]
: currentPath
.split("/")
.reduce<{ label: string; path: string }[]>((acc, part, i) => {
if (i === 0) {
acc.push({ label: "/", path: "/" });
} else if (part) {
const parentPath = acc[acc.length - 1].path;
const fullPath = parentPath === "/" ? `/${part}` : `${parentPath}/${part}`;
acc.push({ label: part, path: fullPath });
}
return acc;
}, []);
if (!running) {
return (
<div className="p-4">
<p className="text-[13px] text-[var(--text-secondary)]">
Start the container to browse its files.
</p>
</div>
);
}
return (
<div className="flex flex-col h-full min-h-0">
<div className="flex items-center gap-1 px-4 py-2 border-b border-[var(--border-color)] text-xs overflow-x-auto flex-shrink-0">
<nav aria-label="Path" className="flex items-center gap-1">
{breadcrumbs.map((crumb, i) => (
<span key={crumb.path} className="flex items-center gap-1">
{i > 0 && <span className="text-[var(--text-secondary)]">/</span>}
<button
type="button"
onClick={() => navigate(crumb.path)}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors whitespace-nowrap font-mono"
>
{crumb.label}
</button>
</span>
))}
</nav>
<div className="flex-1" />
<Button onClick={uploadFile}>Upload file</Button>
<Button onClick={refresh} disabled={loading} className="ml-1">
Refresh
</Button>
</div>
<div className="flex-1 overflow-y-auto min-h-0">
{error && (
<div role="alert" className="px-4 py-2 text-xs text-[var(--error)]">
{error}
</div>
)}
{loading && entries.length === 0 ? (
<div className="px-4 py-8 text-center text-xs text-[var(--text-secondary)]">
Loading
</div>
) : (
<table className="w-full text-xs">
<tbody>
{currentPath !== "/" && (
<tr
onClick={goUp}
className="cursor-pointer hover:bg-[var(--bg-tertiary)] transition-colors"
>
<td className="px-4 py-1.5 text-[var(--text-primary)] font-mono">..</td>
<td colSpan={3} />
</tr>
)}
{entries.map((entry) => (
<tr
key={entry.name}
onClick={() => entry.is_directory && navigate(entry.path)}
className={`${
entry.is_directory ? "cursor-pointer" : ""
} hover:bg-[var(--bg-tertiary)] transition-colors`}
>
<td className="px-4 py-1.5">
<span
className={`font-mono ${
entry.is_directory
? "text-[var(--accent)]"
: "text-[var(--text-primary)]"
}`}
>
{entry.is_directory ? "📁 " : ""}
{entry.name}
</span>
</td>
<td className="px-2 py-1.5 text-[var(--text-secondary)] text-right whitespace-nowrap tabular-nums">
{!entry.is_directory && formatBytes(entry.size)}
</td>
<td className="px-2 py-1.5 text-[var(--text-secondary)] whitespace-nowrap">
{entry.modified}
</td>
<td className="px-2 py-1.5 text-right">
{!entry.is_directory && (
<Button
aria-label={`Download ${entry.name}`}
onClick={(e) => {
e.stopPropagation();
downloadFile(entry);
}}
>
Download
</Button>
)}
</td>
</tr>
))}
{entries.length === 0 && !loading && (
<tr>
<td
colSpan={4}
className="px-4 py-8 text-center text-[var(--text-secondary)]"
>
Empty directory
</td>
</tr>
)}
</tbody>
</table>
)}
</div>
</div>
);
}
@@ -0,0 +1,218 @@
import { useEffect, useState } from "react";
import type { ClaudeSession, Project, ScheduledTask } from "../../../lib/types";
import {
listClaudeSessions,
listScheduledTasks,
getSchedulerNotifications,
resumeSessionCommand,
} from "../../../lib/tauri-commands";
import type { useProjectActions } from "../../../hooks/useProjectActions";
import type { SaveState } from "../../../hooks/useSaveState";
import PermissionModeControl, {
permissionModePatch,
} from "../PermissionModeControl";
import CapabilityTiles from "./CapabilityTiles";
import SaveIndicator from "../../ui/SaveIndicator";
import Button from "../../ui/Button";
import { formatAge } from "./format";
import type { ProjectHomeTabId } from "./ProjectHome";
const BACKEND_LABEL: Record<Project["backend"], string> = {
anthropic: "Anthropic",
bedrock: "AWS Bedrock",
ollama: "Ollama",
open_ai_compatible: "OpenAI Compatible",
};
interface Props {
project: Project;
save: (patch: Partial<Project>) => Promise<boolean>;
saveState: SaveState;
actions: ReturnType<typeof useProjectActions>;
onOpenTab: (tab: ProjectHomeTabId) => void;
}
export default function OverviewTab({
project,
save,
saveState,
actions,
onOpenTab,
}: Props) {
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
const [tasks, setTasks] = useState<ScheduledTask[]>([]);
const [notificationCount, setNotificationCount] = useState(0);
const running = project.status === "running";
useEffect(() => {
if (!running) {
setSessions([]);
setTasks([]);
setNotificationCount(0);
return;
}
let cancelled = false;
// All three degrade to empty when the container is unreachable.
listClaudeSessions(project.id)
.then((s) => !cancelled && setSessions(s.slice(0, 4)))
.catch(() => !cancelled && setSessions([]));
listScheduledTasks(project.id)
.then((t) => !cancelled && setTasks(t))
.catch(() => !cancelled && setTasks([]));
getSchedulerNotifications(project.id)
.then((n) => !cancelled && setNotificationCount(n.length))
.catch(() => !cancelled && setNotificationCount(0));
return () => {
cancelled = true;
};
}, [project.id, running, project.container_id]);
const handleResume = async (session: ClaudeSession) => {
try {
const command = await resumeSessionCommand(project.id, session.id);
await actions.openTerminalWithCommand(command, session.name ?? "resume");
} catch (e) {
console.error("Failed to build the resume command:", e);
}
};
return (
<div className="p-4 space-y-6 max-w-4xl">
{/* Permission mode — the hero control */}
<section className="p-3 border border-[var(--border-color)] rounded-[var(--radius-panel)] bg-[var(--bg-secondary)]">
<div className="flex items-start justify-between gap-4">
<div className="flex-1 min-w-0">
<PermissionModeControl
project={project}
disabled={!running && project.status !== "stopped" && project.status !== "error"}
onChange={(mode) => save(permissionModePatch(mode))}
/>
</div>
<SaveIndicator state={saveState} />
</div>
<div className="mt-3 pt-3 border-t border-[var(--border-color)] flex flex-wrap gap-x-6 gap-y-1 text-xs">
<span className="text-[var(--text-secondary)]">
Backend{" "}
<span className="text-[var(--text-primary)] font-medium">
{BACKEND_LABEL[project.backend]}
</span>
</span>
<span className="text-[var(--text-secondary)]">
Docker access{" "}
<span className="text-[var(--text-primary)] font-medium">
{project.allow_docker_access ? "ON" : "OFF"}
</span>
</span>
<span className="text-[var(--text-secondary)]">
Mission Control{" "}
<span className="text-[var(--text-primary)] font-medium">
{project.mission_control_enabled ? "ON" : "OFF"}
</span>
</span>
<button
type="button"
onClick={() => onOpenTab("config")}
className="text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
Edit configuration
</button>
</div>
</section>
<CapabilityTiles
project={project}
onManageInTerminal={(command) => actions.openTerminalWithCommand(command)}
/>
<div className="grid gap-6 md:grid-cols-2">
{/* Recent sessions */}
<section>
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
Recent sessions
</h2>
<button
type="button"
onClick={() => onOpenTab("sessions")}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
All sessions
</button>
</div>
{!running ? (
<p className="text-xs text-[var(--text-secondary)]">
Start the container to list saved conversations.
</p>
) : sessions.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">No sessions yet.</p>
) : (
<ul className="space-y-1">
{sessions.map((session) => (
<li
key={session.id}
className="flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
>
<span className="flex-1 min-w-0 text-xs text-[var(--text-primary)] truncate">
{session.name ?? session.summary ?? session.id}
</span>
<span className="text-xs text-[var(--text-secondary)] flex-shrink-0">
{formatAge(session.last_modified) ?? ""}
</span>
<Button onClick={() => handleResume(session)}>Resume</Button>
</li>
))}
</ul>
)}
</section>
{/* Scheduled tasks */}
<section>
<div className="flex items-baseline justify-between mb-2">
<h2 className="text-[11px] font-semibold uppercase tracking-wide text-[var(--text-secondary)]">
Scheduled tasks
</h2>
<button
type="button"
onClick={() => onOpenTab("automation")}
className="text-xs text-[var(--accent)] hover:text-[var(--accent-hover)] transition-colors"
>
Automation
</button>
</div>
{!running ? (
<p className="text-xs text-[var(--text-secondary)]">
Start the container to list scheduled tasks.
</p>
) : tasks.length === 0 ? (
<p className="text-xs text-[var(--text-secondary)]">No scheduled tasks.</p>
) : (
<ul className="space-y-1">
{tasks.slice(0, 4).map((task) => (
<li
key={task.id}
className="flex items-center gap-2 px-2 py-1.5 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
>
<span className="flex-1 min-w-0 text-xs text-[var(--text-primary)] truncate">
{task.name}
</span>
<span className="text-xs text-[var(--text-secondary)] font-mono flex-shrink-0">
{task.at ?? task.schedule}
</span>
</li>
))}
</ul>
)}
{notificationCount > 0 && (
<button
type="button"
onClick={() => onOpenTab("automation")}
className="mt-2 inline-flex items-center gap-1.5 px-2 py-1 text-xs rounded-[var(--radius-control)] bg-[var(--accent-muted)] text-[var(--accent)] hover:bg-[var(--bg-tertiary)] transition-colors"
>
{notificationCount} notification{notificationCount === 1 ? "" : "s"}
</button>
)}
</section>
</div>
</div>
);
}
@@ -0,0 +1,241 @@
import { useEffect, useMemo, useState } from "react";
import { useShallow } from "zustand/react/shallow";
import { useAppState } from "../../../store/appState";
import { useProjectActions } from "../../../hooks/useProjectActions";
import { useProjects } from "../../../hooks/useProjects";
import { useProjectSave } from "../../../hooks/useSaveState";
import { ProjectStatusIndicator } from "../../ui/StatusIndicator";
import Button from "../../ui/Button";
import OverflowMenu from "../../ui/OverflowMenu";
import ConfirmRemoveModal from "../ConfirmRemoveModal";
import ConfirmResetModal from "../ConfirmResetModal";
import OverviewTab from "./OverviewTab";
import SessionsTab from "./SessionsTab";
import AutomationTab from "./AutomationTab";
import ConfigTab from "./ConfigTab";
import FilesTab from "./FilesTab";
import { formatUptime } from "./format";
const TABS = [
{ id: "overview", label: "Overview" },
{ id: "sessions", label: "Sessions" },
{ id: "automation", label: "Automation" },
{ id: "config", label: "Config" },
{ id: "files", label: "Files" },
] as const;
export type ProjectHomeTabId = (typeof TABS)[number]["id"];
interface Props {
projectId: string;
active: boolean;
}
/**
* The project promoted from a sidebar card to a first-class main-area view.
* Everything that used to spray out of `ProjectCard` as a modal lives here.
*/
export default function ProjectHome({ projectId, active }: Props) {
const { projects, remove } = useProjects();
const project = projects.find((p) => p.id === projectId);
const [tab, setTab] = useState<ProjectHomeTabId>("overview");
const [confirmRemove, setConfirmRemove] = useState(false);
const [confirmReset, setConfirmReset] = useState(false);
const { runningSince, progress } = useAppState(
useShallow((s) => ({
runningSince: s.runningSince[projectId],
progress: s.containerProgress[projectId],
})),
);
// Re-render once a minute so the uptime line stays honest.
const [, setTick] = useState(0);
useEffect(() => {
if (!active || runningSince === undefined) return;
const timer = setInterval(() => setTick((t) => t + 1), 60_000);
return () => clearInterval(timer);
}, [active, runningSince]);
const actions = useProjectActions(
project ?? ({ id: projectId, name: "", container_id: null } as never),
);
const { save, saveState } = useProjectSave(
project ?? ({ id: projectId, name: "" } as never),
);
const uptime = useMemo(() => formatUptime(runningSince), [runningSince]);
if (!project) {
return (
<div className={`h-full flex items-center justify-center ${active ? "" : "hidden"}`}>
<p className="text-[13px] text-[var(--text-secondary)]">
This project is no longer available.
</p>
</div>
);
}
const isRunning = project.status === "running";
const isTransitioning =
project.status === "starting" || project.status === "stopping";
const isStopped = project.status === "stopped" || project.status === "error";
return (
<div className={`flex flex-col h-full min-h-0 ${active ? "" : "hidden"}`}>
{/* Header */}
<header className="flex-shrink-0 px-4 pt-3 pb-2 border-b border-[var(--border-color)]">
<div className="flex items-start justify-between gap-4 flex-wrap">
<div className="min-w-0">
<h1 className="text-base font-semibold text-[var(--text-primary)] truncate">
{project.name}
</h1>
<div className="mt-0.5 flex items-center gap-2 text-xs">
<ProjectStatusIndicator status={project.status} />
{isRunning && uptime && (
<span className="text-[var(--text-secondary)]">· {uptime}</span>
)}
{isTransitioning && progress && (
<span className="text-[var(--warning)] truncate">· {progress}</span>
)}
</div>
</div>
<div className="flex items-center gap-1.5 flex-wrap">
{isRunning ? (
<Button
size="md"
variant="primary"
disabled={actions.busy}
onClick={actions.openClaudeTerminal}
>
Open Claude Terminal
</Button>
) : (
<Button
size="md"
variant="primary"
disabled={actions.busy || isTransitioning}
onClick={actions.handleStart}
>
Start
</Button>
)}
{isRunning && (
<>
<Button size="md" onClick={actions.openShell}>
Shell
</Button>
<Button size="md" onClick={() => setTab("files")}>
Files
</Button>
<Button size="md" disabled={actions.busy} onClick={actions.handleStop}>
Stop
</Button>
</>
)}
{isTransitioning && (
<Button size="md" variant="danger" onClick={actions.handleStop}>
Force stop
</Button>
)}
<OverflowMenu
items={[
{
label: actions.backingUp ? "Backing up…" : "Back up container",
onSelect: actions.handleBackup,
disabled: actions.backingUp || !project.container_id,
},
{
label: "Reset container…",
onSelect: () => setConfirmReset(true),
disabled: !isStopped || actions.busy,
danger: true,
},
{
label: "Remove project…",
onSelect: () => setConfirmRemove(true),
danger: true,
},
]}
/>
</div>
</div>
{/* Tabs */}
<div role="tablist" aria-label="Project sections" className="flex gap-1 mt-3 -mb-2">
{TABS.map((t) => (
<button
key={t.id}
type="button"
role="tab"
id={`project-tab-${projectId}-${t.id}`}
aria-selected={tab === t.id}
aria-controls={`project-panel-${projectId}-${t.id}`}
onClick={() => setTab(t.id)}
className={`px-3 h-8 text-[13px] font-medium rounded-t-[var(--radius-control)] border-b-2 transition-colors ${
tab === t.id
? "text-[var(--text-primary)] border-[var(--accent)]"
: "text-[var(--text-secondary)] border-transparent hover:text-[var(--text-primary)]"
}`}
>
{t.label}
</button>
))}
</div>
</header>
{/* Panel */}
<div
role="tabpanel"
id={`project-panel-${projectId}-${tab}`}
aria-labelledby={`project-tab-${projectId}-${tab}`}
className="flex-1 min-h-0 overflow-y-auto"
>
{tab === "overview" && (
<OverviewTab
project={project}
save={save}
saveState={saveState}
actions={actions}
onOpenTab={setTab}
/>
)}
{tab === "sessions" && <SessionsTab project={project} actions={actions} />}
{tab === "automation" && <AutomationTab project={project} />}
{tab === "config" && (
<ConfigTab project={project} save={save} saveState={saveState} />
)}
{tab === "files" && <FilesTab project={project} />}
</div>
{confirmReset && (
<ConfirmResetModal
projectName={project.name}
onCancel={() => setConfirmReset(false)}
onConfirm={() => {
setConfirmReset(false);
actions.handleReset();
}}
/>
)}
{confirmRemove && (
<ConfirmRemoveModal
projectName={project.name}
onCancel={() => setConfirmRemove(false)}
onConfirm={async () => {
setConfirmRemove(false);
try {
await remove(project.id);
} catch (e) {
useAppState.getState().pushToast({
kind: "error",
message: `Could not remove “${project.name}`,
detail: String(e),
});
}
}}
/>
)}
</div>
);
}
@@ -0,0 +1,102 @@
import { useCallback, useEffect, useState } from "react";
import type { ClaudeSession, Project } from "../../../lib/types";
import { listClaudeSessions, resumeSessionCommand } from "../../../lib/tauri-commands";
import type { useProjectActions } from "../../../hooks/useProjectActions";
import { useAppState } from "../../../store/appState";
import Button from "../../ui/Button";
import { formatAge, formatBytes } from "./format";
interface Props {
project: Project;
actions: ReturnType<typeof useProjectActions>;
}
/**
* The stop/start container model buries "which conversation was I in?" in the
* config volume. This lists it and makes [Resume] one click.
*/
export default function SessionsTab({ project, actions }: Props) {
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
const [loading, setLoading] = useState(false);
const pushToast = useAppState((s) => s.pushToast);
const running = project.status === "running";
const load = useCallback(() => {
if (!running) {
setSessions([]);
return;
}
setLoading(true);
listClaudeSessions(project.id)
.then(setSessions)
.catch(() => setSessions([]))
.finally(() => setLoading(false));
}, [project.id, running]);
useEffect(load, [load]);
const resume = async (session: ClaudeSession) => {
try {
const command = await resumeSessionCommand(project.id, session.id);
await actions.openTerminalWithCommand(command, session.name ?? "resume");
} catch (e) {
pushToast({
kind: "error",
message: "Could not resume that session",
detail: String(e),
});
}
};
return (
<div className="p-4 max-w-4xl">
<div className="flex items-center justify-between mb-3">
<p className="text-xs text-[var(--text-secondary)]">
Conversations stored on this project&rsquo;s config volume. Resume opens a
terminal running the resume command.
</p>
<Button onClick={load} disabled={!running || loading}>
{loading ? "Refreshing…" : "Refresh"}
</Button>
</div>
{!running ? (
<p className="text-[13px] text-[var(--text-secondary)]">
Start the container to read its saved sessions.
</p>
) : sessions.length === 0 && !loading ? (
<p className="text-[13px] text-[var(--text-secondary)]">
No sessions recorded yet. Open a Claude terminal to start one.
</p>
) : (
<ul className="space-y-1">
{sessions.map((session) => (
<li
key={session.id}
className="flex items-center gap-3 px-3 py-2 bg-[var(--bg-secondary)] border border-[var(--border-color)] rounded-[var(--radius-control)]"
>
<div className="flex-1 min-w-0">
<div className="text-[13px] text-[var(--text-primary)] truncate">
{session.name ?? session.summary ?? "(untitled session)"}
</div>
<div className="text-xs text-[var(--text-secondary)] truncate font-mono">
{session.id}
{session.cwd ? ` · ${session.cwd}` : ""}
</div>
</div>
<div className="flex-shrink-0 text-right text-xs text-[var(--text-secondary)] tabular-nums">
<div>{formatAge(session.last_modified) ?? "—"}</div>
<div>
{formatBytes(session.size_bytes)} · {session.message_count} msg
</div>
</div>
<Button variant="primary" onClick={() => resume(session)}>
Resume
</Button>
</li>
))}
</ul>
)}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More