Compare commits
15
Commits
v0.4.34-mac
...
v0.4.41
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3ec100e06c | ||
|
|
a19643e0e8 | ||
|
|
32a4d345f2 | ||
|
|
229dc2cefc | ||
|
|
41010a9c50 | ||
|
|
737a608dd5 | ||
|
|
209b8855a4 | ||
|
|
3a4e298849 | ||
|
|
1118cfa458 | ||
|
|
d4a14d2ded | ||
|
|
4532412869 | ||
|
|
e5b73d8fa5 | ||
|
|
1fc1f44c35 | ||
|
|
e223a3f64b | ||
|
|
af59dc8bec |
No files matched your search
@@ -61,6 +61,13 @@ name: Build App (Preview)
|
||||
# for real releases. See triple-c#32.)
|
||||
|
||||
env:
|
||||
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
|
||||
# any apt-get install stops at an interactive "Daemons using outdated
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
# How many preview releases survive a run, newest first — including the one
|
||||
@@ -262,7 +269,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -273,10 +280,14 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-pipewire \
|
||||
gstreamer1.0-pulseaudio \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
@@ -344,7 +355,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -367,10 +378,14 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-pipewire \
|
||||
gstreamer1.0-pulseaudio \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
@@ -655,7 +670,7 @@ jobs:
|
||||
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"
|
||||
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.23.4/node-v22.23.4-x64.msi"
|
||||
msiexec /i node-install.msi /quiet /norestart
|
||||
del node-install.msi
|
||||
)
|
||||
|
||||
@@ -17,6 +17,13 @@ on:
|
||||
# was unreachable. Previews now carry the PR check; this workflow is releases.
|
||||
|
||||
env:
|
||||
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
|
||||
# any apt-get install stops at an interactive "Daemons using outdated
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
|
||||
@@ -118,7 +125,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
echo "Node.js at: $(which node)"
|
||||
@@ -140,10 +147,14 @@ jobs:
|
||||
|
||||
- name: Install system dependencies
|
||||
run: |
|
||||
sudo apt-get update
|
||||
sudo apt-get install -y \
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \
|
||||
libgtk-3-dev \
|
||||
libwebkit2gtk-4.1-dev \
|
||||
gstreamer1.0-plugins-base \
|
||||
gstreamer1.0-plugins-good \
|
||||
gstreamer1.0-pipewire \
|
||||
gstreamer1.0-pulseaudio \
|
||||
libayatana-appindicator3-dev \
|
||||
librsvg2-dev \
|
||||
libsoup-3.0-dev \
|
||||
@@ -596,7 +607,7 @@ jobs:
|
||||
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"
|
||||
curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.23.4/node-v22.23.4-x64.msi"
|
||||
msiexec /i node-install.msi /quiet /norestart
|
||||
del node-install.msi
|
||||
)
|
||||
|
||||
@@ -55,5 +55,10 @@ jobs:
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-stt:latest
|
||||
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
|
||||
# The gha cache is served by the runner that picks the job up, and
|
||||
# is unreachable from the job on some of ours (the home box's
|
||||
# firewalled per-job bridge; ephemeral runners). Import failures
|
||||
# only warn, but an export failure fails the build unless told not
|
||||
# to -- the same setting build.yml already carries.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
@@ -11,6 +11,11 @@ on:
|
||||
paths:
|
||||
- "container/**"
|
||||
- ".gitea/workflows/build.yml"
|
||||
# Weekly rebuild with no commit needed, so OS security fixes published since
|
||||
# the last push reach the image (see APT_REFRESH in container/Dockerfile).
|
||||
# Scheduled runs use the default branch, i.e. main.
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
@@ -64,13 +69,23 @@ jobs:
|
||||
username: shadowdao
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
|
||||
# Changes once a week, which invalidates the cached `apt-get upgrade`
|
||||
# layer (container/Dockerfile) so later security fixes are picked up.
|
||||
- name: Compute the apt refresh stamp
|
||||
id: apt
|
||||
run: echo "stamp=$(date -u +%G-W%V)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push container image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./container
|
||||
file: ./container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ gitea.event_name == 'push' }}
|
||||
# Publish from main pushes and the weekly scheduled rebuild (which
|
||||
# runs on main); a pull_request run only validates.
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
APT_REFRESH=${{ steps.apt.outputs.stamp }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
|
||||
@@ -73,6 +73,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`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
|
||||
- **Copy paths** (`copyCleanup.ts`, `osc52.ts`). OSC 52 base64 is UTF-8 *bytes* — never hand
|
||||
`atob()`'s output to the clipboard (that was the `’` mojibake); OSC 52 text then gets only
|
||||
`sanitizeInvisibles`. Ctrl+Shift+C / "Copy trimmed" run `cleanSelection`, which also undoes
|
||||
Claude Code's hard wraps (it wraps its own output and moves the cursor, so rows are never
|
||||
`isWrapped`) using `term.cols` and the selection's start column. Ctrl+Shift+Alt+C stays raw.
|
||||
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` →
|
||||
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
|
||||
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
|
||||
@@ -83,6 +88,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
|
||||
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
|
||||
in `file_viewer/mod.rs` is the only thing pinning this.
|
||||
**Claude Code wraps its own output, so xterm's `isWrapped` misses most wrapped paths.** A word
|
||||
longer than the line (a `/tmp/claude-<uid>/…` scratchpad path) is broken mid-word and the rest
|
||||
drawn on the next row with cursor moves. `lib/xtermLineJoin.ts` bridges that when given `cols`
|
||||
(row runs to the edge, next row continues with path characters, and the two runs could not
|
||||
have fit on one line — the last test is what keeps prose from being glued together).
|
||||
- **`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
|
||||
@@ -172,7 +182,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
|
||||
`auth_token_commands.rs`.
|
||||
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`
|
||||
(`~/p` is `/home/claude/p`; a relative miss gets a second exec at `/home/claude/<p>`, `/tmp/<p>`),
|
||||
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
|
||||
user (spec §5 says why the archive API never writes to the target directory). Commands take
|
||||
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
|
||||
@@ -253,8 +264,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`docker/`** — Docker API layer using bollard:
|
||||
- `client.rs` — Singleton Docker connection via `OnceLock`
|
||||
- `container.rs` — Container lifecycle (create, start, stop, remove, inspect)
|
||||
- `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.
|
||||
- `exec.rs` — Docker's implementation of everything `host::ContainerTarget` does inside a
|
||||
container. `create_attached_exec_spec()` is the **single** place an attached exec is opened.
|
||||
- `image.rs` — Image build/pull with progress streaming
|
||||
- `gateway.rs` — Optional LiteLLM sibling container giving Claude Code an Anthropic-format
|
||||
front end for providers that only speak OpenAI (see `gateway-container/`). Mirrors `stt.rs`.
|
||||
@@ -271,6 +282,15 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- `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.
|
||||
- **`host/`** — Where a project's container runs, and the only way into it. `ContainerTarget`
|
||||
(one `Docker` variant today; AWS planned — `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
|
||||
wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`.
|
||||
**Container-interior code must go through `ContainerTarget`, never `docker::exec`, bollard or
|
||||
`get_docker()`** — `host::tests::no_container_interior_bollard_outside_the_host_layer` fails
|
||||
the build on it. Its allowlist is Docker-only code (migration) plus lifecycle/`die` events,
|
||||
which phase 1b moves; shrink it, never grow it. Prefer `ContainerTarget::for_project` or a
|
||||
target a session already holds (`ExecSessionManager::get_target`) over
|
||||
`ContainerTarget::docker(id)`, which hard-codes the host.
|
||||
- **`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
|
||||
@@ -302,7 +322,13 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
approach converges on the worst state: a 400 MB browser present with its libraries gone.
|
||||
- The layer sits immediately after Node (npx is its only prerequisite) and well above the shim
|
||||
`COPY`s, so editing a shim does not re-run a multi-hundred-megabyte apt install.
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then `sleep infinity`
|
||||
- **`entrypoint.sh`** — UID/GID remapping to match host user, SSH key setup (`sync_host_ssh` *merges* the host dir into `~/.ssh`; never wipe it — container-made keys live there on the home volume), git config, docker socket permissions, Claude Code settings.json injection, then stays resident **as root** waiting on a root-owned
|
||||
`triple-c-keepalive` sleep (respawned if killed; SIGTERM trap so `docker stop` is immediate).
|
||||
**Never hand the container's lifetime to a process the `claude` user owns** — it used to end in
|
||||
`exec su … claude -c "exec sleep infinity"`, and any `pkill sleep` / `kill -9 -1` a Claude
|
||||
session ran killed the whole container. `container_watch.rs` (one Docker `die`-event
|
||||
subscription) is what flips a project whose container exits on its own to Stopped and emits
|
||||
`project-container-exited` with a `reason` (`exited` toasts; `found_stopped`, the first look of a run, is silent); `useProjectStatusEvents` applies it
|
||||
- **`triple-c-scheduler`** — Bash-based scheduled task system for recurring Claude Code invocations
|
||||
|
||||
**`/home/claude` in the image is seed-only.** It is the mount point of the named volume
|
||||
@@ -568,6 +594,24 @@ security update. Migration is the non-destructive way out; Reset is the destruct
|
||||
the last commit. Migration diffs two filesystem manifests instead, filtered through dpkg
|
||||
ownership and presence-in-the-new-base. Measured on a real project, that turns 8,677 raw path
|
||||
differences into 2 genuinely user-authored ones.
|
||||
- **`/workspace` is reported two levels deep, never file by file** (`SUMMARISED_ROOTS`). One
|
||||
`find -xdev` pass is piped through `awk`, which turns each depth-2 directory into one `S` entry
|
||||
sized as the sum of the files beneath it (the same number the per-file sum gave the disk gate),
|
||||
or a plain `d` if it holds no file at any depth (so empty scaffolds stay empty to the filter). A
|
||||
user's worktrees and `node_modules` made a flat walk 8.6 MB — past the exec cap, so every
|
||||
staleness check failed on start and stop — and pruning `node_modules`/`.git` by name only
|
||||
reached 6.3 MB; now the whole manifest is ~1 MB. Summaries lose nothing: below `/workspace/<x>`
|
||||
every per-path filter is vacuous, and `tar` copies a directory recursively. Three rules:
|
||||
- **A failed `awk` is an error, never an empty `/workspace`.** The pipeline prints
|
||||
`###SUMMARISED-OK` only on success and `check_manifest_complete` refuses a manifest without
|
||||
it — otherwise the swap would destroy what the walk failed to see.
|
||||
- **A summary is exempt from presence-in-the-base**, because it hides which paths are inside;
|
||||
`--skip-old-files` on restore is the per-file answer. That rests on the base shipping nothing
|
||||
under `/workspace` — pinned by the ignored live test
|
||||
`the_base_ships_nothing_under_a_summarised_root`, and logged as a warning if a base breaks it.
|
||||
- **Only `/workspace`.** The other roots stay per file: dpkg ownership and presence-in-the-base
|
||||
are exactly what make their delta honest (`/usr/local/lib/node_modules`, base content in
|
||||
`/opt`), and they are under 1 MB together.
|
||||
|
||||
### Authentication
|
||||
|
||||
@@ -661,8 +705,9 @@ Anthropic and Bedrock deliberately keep Claude Code's own defaults.
|
||||
now", the host builds the project's effective set (`global − disabled ∪ project`), then uploads
|
||||
`payload.tar` **and the app-embedded script `src/marketplace/sync.sh`** (`include_str!`, not a
|
||||
file in `container/`) to `~/.claude/triple-c/marketplace/incoming/` and runs it as `claude`,
|
||||
once the entrypoint has finished (`pgrep -x -f 'su -s /bin/bash claude -c exec sleep
|
||||
infinity'`). The script is re-uploaded on every sync rather than baked into the image, so every
|
||||
once the entrypoint has finished (`READY_PROBE`: a root `triple-c-keepalive infinity` process
|
||||
on current images, `su -s /bin/bash claude -c exec sleep infinity` on older ones — keep both).
|
||||
The script is re-uploaded on every sync rather than baked into the image, so every
|
||||
existing project always gets the version that matches the running app — `container/` is never
|
||||
touched for this feature. The script only removes files and hook entries it recorded in
|
||||
`~/.claude/triple-c/marketplace/state.json`; it must never overwrite or delete user-created
|
||||
|
||||
+3
-3
@@ -137,7 +137,7 @@ Implementation gotchas for the terminal view and its global controls (merged in
|
||||
│ │
|
||||
│ User: claude (UID/GID remapped to match host) │
|
||||
│ Entrypoint: UID/GID remap → SSH setup → git config → │
|
||||
│ docker socket perms → sleep infinity │
|
||||
│ docker socket perms → root keepalive wait │
|
||||
└──────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
@@ -164,7 +164,7 @@ 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, 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.
|
||||
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 stays resident as root waiting on a root-owned `triple-c-keepalive` sleep — never a process the `claude` user can kill, since any `pkill sleep` a Claude session runs would otherwise stop the container.
|
||||
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 — 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.
|
||||
@@ -348,7 +348,7 @@ The entrypoint solves this by:
|
||||
3. Relocating any existing system user/group that conflicts with the target UID/GID.
|
||||
4. Fixing ownership of `/home/claude` after the change.
|
||||
|
||||
This runs as root in the entrypoint, then the final `exec su -s /bin/bash claude -c "exec sleep infinity"` drops to the remapped user.
|
||||
This runs as root in the entrypoint. The entrypoint itself then stays running as root to keep the container alive; sessions reach the remapped `claude` user through `docker exec --user`. (Older images ended in `exec su -s /bin/bash claude -c "exec sleep infinity"`, which let any `claude` process stop the container by killing that sleep.)
|
||||
|
||||
### SSH Key Handling
|
||||
|
||||
|
||||
Generated
+207
-190
@@ -124,13 +124,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -139,9 +139,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -149,21 +149,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -180,14 +180,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz",
|
||||
"integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.12",
|
||||
"@babel/types": "^7.29.11",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -197,14 +197,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -214,9 +214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -224,29 +224,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -266,9 +266,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz",
|
||||
"integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -276,9 +276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -286,9 +286,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -296,27 +296,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz",
|
||||
"integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
|
||||
"integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.11"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -368,33 +368,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz",
|
||||
"integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.12",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.12",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.11",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -402,14 +402,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
|
||||
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.11",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2526,31 +2526,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"chai": "^6.2.1",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -2559,7 +2559,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
@@ -2571,26 +2571,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -2598,13 +2598,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -2613,9 +2614,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2623,14 +2624,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -2759,9 +2761,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"version": "2.11.34",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz",
|
||||
"integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2782,9 +2784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"version": "4.29.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz",
|
||||
"integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2802,11 +2804,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
"baseline-browser-mapping": "^2.11.33",
|
||||
"caniuse-lite": "^1.0.30001819",
|
||||
"electron-to-chromium": "^1.5.464",
|
||||
"node-releases": "^2.0.60",
|
||||
"update-browserslist-db": "^1.3.4"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -2816,9 +2818,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001774",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
|
||||
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
|
||||
"version": "1.0.30001820",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz",
|
||||
"integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2837,9 +2839,9 @@
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2981,9 +2983,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||
"version": "1.5.472",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz",
|
||||
"integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -3015,9 +3017,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3616,9 +3618,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3635,11 +3637,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"version": "2.0.63",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz",
|
||||
"integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
@@ -3680,9 +3685,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3693,9 +3698,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3713,9 +3718,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -3899,9 +3904,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -3916,9 +3921,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4004,9 +4009,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4074,9 +4079,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"version": "7.30.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
|
||||
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4084,9 +4089,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
|
||||
"integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4115,9 +4120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz",
|
||||
"integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4190,31 +4195,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"@vitest/runner": "4.0.18",
|
||||
"@vitest/snapshot": "4.0.18",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"expect-type": "^1.2.2",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^3.10.0",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4230,12 +4235,15 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/browser-preview": "4.0.18",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
@@ -4256,6 +4264,12 @@
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -4264,6 +4278,9 @@
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+116
-56
@@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -84,9 +84,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
@@ -105,7 +105,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@@ -674,6 +674,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -816,6 +827,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -1367,16 +1387,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -1768,11 +1787,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1782,10 +1799,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3742,9 +3762,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
@@ -4303,7 +4323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||
dependencies = [
|
||||
"phf_shared 0.10.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4313,7 +4333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4433,9 +4453,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
@@ -4491,7 +4511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -4661,9 +4681,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -4690,15 +4710,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg 0.10.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -4721,7 +4742,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4750,14 +4771,14 @@ dependencies = [
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
"rand_pcg",
|
||||
"rand_pcg 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -4766,14 +4787,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -4831,6 +4863,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -4849,6 +4887,15 @@ dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -5071,14 +5118,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -5129,7 +5176,7 @@ dependencies = [
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5140,9 +5187,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -5517,7 +5564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -5534,7 +5581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -5608,12 +5655,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5764,6 +5811,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -5850,9 +5908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6169,10 +6227,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6294,9 +6352,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
version = "1.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -6311,13 +6369,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6560,6 +6618,7 @@ dependencies = [
|
||||
"axum",
|
||||
"base64 0.22.1",
|
||||
"bollard",
|
||||
"bytes",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"fern",
|
||||
@@ -6570,7 +6629,7 @@ dependencies = [
|
||||
"keyring",
|
||||
"local-ip-address",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
@@ -6586,6 +6645,7 @@ dependencies = [
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6606,7 +6666,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
@@ -7045,18 +7105,18 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "webpki-root-certs"
|
||||
version = "1.0.9"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b"
|
||||
checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "webpki-roots"
|
||||
version = "1.0.6"
|
||||
version = "1.0.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed"
|
||||
checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171"
|
||||
dependencies = [
|
||||
"rustls-pki-types",
|
||||
]
|
||||
@@ -7119,7 +7179,7 @@ version = "0.1.11"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22"
|
||||
dependencies = [
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
|
||||
@@ -21,6 +21,7 @@ bollard = "0.18"
|
||||
keyring = { version = "3", features = ["apple-native", "windows-native", "linux-native"] }
|
||||
tokio = { version = "1", features = ["full"] }
|
||||
futures-util = "0.3"
|
||||
bytes = "1"
|
||||
uuid = { version = "1", features = ["v4"] }
|
||||
chrono = { version = "0.4", features = ["serde"] }
|
||||
dirs = "6"
|
||||
@@ -48,6 +49,12 @@ similar = "2"
|
||||
# Blocking client + rustls: no git binary or OpenSSL needed on the host.
|
||||
gix = { version = "0.88", default-features = false, features = ["blocking-network-client", "blocking-http-transport-reqwest-rust-tls", "credentials", "sha1"] }
|
||||
|
||||
# Linux only: WebKitGTK refuses getUserMedia until the embedder enables media
|
||||
# streams and answers the permission request (see src/linux_media.rs). Same
|
||||
# version tauri/wry already resolve, so this adds no second copy.
|
||||
[target.'cfg(target_os = "linux")'.dependencies]
|
||||
webkit2gtk = "2.0.2"
|
||||
|
||||
[dev-dependencies]
|
||||
# `test-util` (not part of tokio's `full`) lets the auto-start retry tests run
|
||||
# their backoff schedule under a paused clock instead of in real seconds.
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"allow-get-gateway-auth-token",
|
||||
"allow-regenerate-gateway-auth-token",
|
||||
"allow-list-claude-sessions",
|
||||
"allow-resume-session-command",
|
||||
"allow-list-container-capabilities",
|
||||
"allow-list-scheduled-tasks",
|
||||
"allow-add-scheduled-task",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
@@ -896,12 +896,6 @@
|
||||
"const": "allow-rename-container-path",
|
||||
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the resume_session_command command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-resume-session-command",
|
||||
"markdownDescription": "Enables the resume_session_command command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1712,12 +1706,6 @@
|
||||
"const": "deny-rename-container-path",
|
||||
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the resume_session_command command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-resume-session-command",
|
||||
"markdownDescription": "Denies the resume_session_command command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -896,12 +896,6 @@
|
||||
"const": "allow-rename-container-path",
|
||||
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the resume_session_command command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-resume-session-command",
|
||||
"markdownDescription": "Enables the resume_session_command command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1712,12 +1706,6 @@
|
||||
"const": "deny-rename-container-path",
|
||||
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the resume_session_command command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-resume-session-command",
|
||||
"markdownDescription": "Denies the resume_session_command command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -32,7 +32,7 @@
|
||||
//! |---|---|
|
||||
//! | 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 |
|
||||
//! | Container stopped/died another way | poller's own `is_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`] |
|
||||
@@ -55,8 +55,7 @@ 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_limited, PROC_NET_OUTPUT_LIMIT};
|
||||
use crate::host::{ContainerTarget, PROC_NET_OUTPUT_LIMIT};
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use proc_net::PortFamily;
|
||||
@@ -205,7 +204,7 @@ impl AuthBridgeManager {
|
||||
|
||||
let poller = tokio::spawn(poll_loop(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
ContainerTarget::docker(container_id),
|
||||
epoch,
|
||||
app,
|
||||
store,
|
||||
@@ -276,7 +275,7 @@ impl AuthBridgeManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn poll_loop(
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
epoch: u64,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
@@ -301,7 +300,7 @@ async fn poll_loop(
|
||||
log::info!("Auth bridge: disabled for project {} — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
log::info!(
|
||||
"Auth bridge: container for project {} is no longer running — tearing down",
|
||||
project_id
|
||||
@@ -327,7 +326,9 @@ async fn poll_loop(
|
||||
// bridge or stopping the container doesn't wait out an in-flight poll.
|
||||
let discovery = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = exec_oneshot_limited(&container_id, cmd, PROC_NET_OUTPUT_LIMIT) => res,
|
||||
res = container.exec_oneshot_limited("claude", cmd, PROC_NET_OUTPUT_LIMIT) => {
|
||||
res.map(|(out, _)| out)
|
||||
}
|
||||
};
|
||||
|
||||
match discovery {
|
||||
@@ -338,7 +339,7 @@ async fn poll_loop(
|
||||
// gateway/STT/web-terminal ports can be re-pointed while the
|
||||
// bridge is running, and a stale reservation set is a hole.
|
||||
let skip = skipped_ports(&project, &store.list(), &app_settings(&app));
|
||||
if reconcile(&container_id, &discovered, &skip, &state).await {
|
||||
if reconcile(&container, &discovered, &skip, &state).await {
|
||||
emit_status(&app, &project_id, &state, true).await;
|
||||
}
|
||||
}
|
||||
@@ -528,7 +529,7 @@ const MAX_CONFLICTS: usize = 32;
|
||||
/// 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,
|
||||
container: &ContainerTarget,
|
||||
discovered: &BTreeMap<u16, PortFamily>,
|
||||
skip: &HashSet<u16>,
|
||||
state: &Arc<Mutex<BridgeState>>,
|
||||
@@ -581,7 +582,7 @@ async fn reconcile(
|
||||
);
|
||||
continue;
|
||||
}
|
||||
match PortForward::bind(container_id.to_string(), port, family).await {
|
||||
match PortForward::bind(container.clone(), port, family).await {
|
||||
Ok(forward) => {
|
||||
if st.conflicts.remove(&port).is_some() {
|
||||
log::info!("Auth bridge: host port {} became available", port);
|
||||
@@ -831,7 +832,13 @@ mod tests {
|
||||
(45000u16..45200).map(|p| (p, PortFamily::V4)).collect();
|
||||
let state = Arc::new(Mutex::new(BridgeState::default()));
|
||||
|
||||
reconcile("no-such-container", &discovered, &HashSet::new(), &state).await;
|
||||
reconcile(
|
||||
&ContainerTarget::docker("no-such-container"),
|
||||
&discovered,
|
||||
&HashSet::new(),
|
||||
&state,
|
||||
)
|
||||
.await;
|
||||
|
||||
let mut st = state.lock().await;
|
||||
assert!(
|
||||
|
||||
@@ -11,7 +11,7 @@
|
||||
//! 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
|
||||
//! [`crate::host::ContainerTarget::attach_exec`], the same helper the
|
||||
//! interactive terminal sessions are built on.
|
||||
//!
|
||||
//! ## What the host listener is, and is not
|
||||
@@ -56,14 +56,12 @@
|
||||
use std::net::{Ipv4Addr, Ipv6Addr, SocketAddr};
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecChunk};
|
||||
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
|
||||
@@ -143,13 +141,13 @@ impl Drop for PortForward {
|
||||
}
|
||||
|
||||
impl PortForward {
|
||||
/// Bind `port` on the host loopback and start proxying into `container_id`.
|
||||
/// Bind `port` on the host loopback and start proxying into `container`.
|
||||
///
|
||||
/// 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,
|
||||
container: ContainerTarget,
|
||||
port: u16,
|
||||
family: PortFamily,
|
||||
) -> Result<Self, std::io::Error> {
|
||||
@@ -196,7 +194,7 @@ impl PortForward {
|
||||
};
|
||||
|
||||
let target = family.socat_target(port);
|
||||
let task = tokio::spawn(accept_loop(container_id, port, target, v4, v6));
|
||||
let task = tokio::spawn(accept_loop(container, port, target, v4, v6));
|
||||
|
||||
Ok(Self {
|
||||
port,
|
||||
@@ -219,7 +217,7 @@ impl PortForward {
|
||||
/// 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,
|
||||
container: ContainerTarget,
|
||||
port: u16,
|
||||
target: String,
|
||||
v4: TcpListener,
|
||||
@@ -258,7 +256,7 @@ async fn accept_loop(
|
||||
log::debug!("Auth bridge: connection from {} to bridged port {}", peer, port);
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(tunnel_connection(
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
target.clone(),
|
||||
stream,
|
||||
port,
|
||||
@@ -288,7 +286,12 @@ async fn accept_optional(
|
||||
///
|
||||
/// Nothing is forwarded until that decision is made, so a refused request never
|
||||
/// reaches the container at all — not even a `docker exec`.
|
||||
async fn tunnel_connection(container_id: String, target: String, mut stream: TcpStream, port: u16) {
|
||||
async fn tunnel_connection(
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
mut stream: TcpStream,
|
||||
port: u16,
|
||||
) {
|
||||
let head = match read_leading_bytes(&mut stream).await {
|
||||
Ok(head) => head,
|
||||
Err(e) => {
|
||||
@@ -319,7 +322,7 @@ async fn tunnel_connection(container_id: String, target: String, mut stream: Tcp
|
||||
}
|
||||
|
||||
// The bytes already off the socket go back on the wire first, byte-exact.
|
||||
tunnel_connection_with_prelude(container_id, target, stream, port, head.into_buffer()).await
|
||||
tunnel_into_with_prelude(container, target, stream, port, head.into_buffer()).await
|
||||
}
|
||||
|
||||
/// What the first bytes of an accepted connection turned out to be.
|
||||
@@ -511,12 +514,34 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||
/// head off the socket to check a token, and then has to put those same bytes
|
||||
/// back on the wire. Passing them here keeps the byte stream exact, rather than
|
||||
/// re-serialising a parsed request.
|
||||
///
|
||||
/// Takes a Docker container id because that is what the browser-view proxy
|
||||
/// holds; [`tunnel_into_with_prelude`] is the same thing for a
|
||||
/// [`ContainerTarget`] already in hand.
|
||||
pub async fn tunnel_connection_with_prelude(
|
||||
container_id: String,
|
||||
target: String,
|
||||
stream: TcpStream,
|
||||
port: u16,
|
||||
prelude: Vec<u8>,
|
||||
) {
|
||||
tunnel_into_with_prelude(
|
||||
ContainerTarget::docker(container_id),
|
||||
target,
|
||||
stream,
|
||||
port,
|
||||
prelude,
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// [`tunnel_connection_with_prelude`] for a [`ContainerTarget`].
|
||||
pub async fn tunnel_into_with_prelude(
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
stream: TcpStream,
|
||||
port: u16,
|
||||
prelude: Vec<u8>,
|
||||
) {
|
||||
let cmd = vec!["socat".to_string(), "-".to_string(), target.clone()];
|
||||
|
||||
@@ -524,7 +549,7 @@ pub async fn tunnel_connection_with_prelude(
|
||||
mut output,
|
||||
mut input,
|
||||
..
|
||||
} = match create_attached_exec(&container_id, cmd, false).await {
|
||||
} = match container.attach_exec(cmd, false).await {
|
||||
Ok(e) => e,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
@@ -587,12 +612,12 @@ pub async fn tunnel_connection_with_prelude(
|
||||
// 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 }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if host_tx.write_all(&message).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
log::debug!(
|
||||
"Auth bridge: socat stderr for port {}: {}",
|
||||
port,
|
||||
|
||||
@@ -36,7 +36,7 @@
|
||||
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
/// Marks the JSON payload in the probe's stdout, so unrelated chatter on the
|
||||
/// same stream (npm notices, Node warnings) can't be mistaken for the result.
|
||||
@@ -278,11 +278,18 @@ impl PlaywrightDetection {
|
||||
/// `npx @playwright/mcp`, is found as readily as one in
|
||||
/// `/workspace/node_modules`.
|
||||
pub async fn detect(container_id: &str) -> Result<PlaywrightDetection, String> {
|
||||
let output = exec_oneshot(
|
||||
container_id,
|
||||
vec!["node".to_string(), "-e".to_string(), PROBE.to_string()],
|
||||
)
|
||||
.await?;
|
||||
detect_in(&ContainerTarget::docker(container_id)).await
|
||||
}
|
||||
|
||||
/// [`detect`] against a target already in hand.
|
||||
pub async fn detect_in(container: &ContainerTarget) -> Result<PlaywrightDetection, String> {
|
||||
let output = container
|
||||
.exec_oneshot(vec![
|
||||
"node".to_string(),
|
||||
"-e".to_string(),
|
||||
PROBE.to_string(),
|
||||
])
|
||||
.await?;
|
||||
|
||||
parse_probe_output(&output)
|
||||
}
|
||||
|
||||
@@ -67,9 +67,7 @@ use serde::Serialize;
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::exec::{
|
||||
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
|
||||
};
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecSpec};
|
||||
|
||||
use super::detect::{self, PlaywrightDetection};
|
||||
|
||||
@@ -242,13 +240,14 @@ pub async fn install_packages(
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let container = ContainerTarget::docker(container_id);
|
||||
emit_progress(
|
||||
app,
|
||||
project_id,
|
||||
&format!("Installing @playwright/cli into {}/node_modules…", INSTALL_DIR),
|
||||
);
|
||||
|
||||
let mut step = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE]).await?;
|
||||
let mut step = npm_install(app, project_id, &container, &[VIEWER_PACKAGE]).await?;
|
||||
if step.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install the viewer package in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -265,9 +264,9 @@ pub async fn install_packages(
|
||||
// see the note on `npm_install`. The pin can only be read after the first
|
||||
// install has written the manifest, which is why this stays two commands
|
||||
// rather than one.
|
||||
let spec = pinned_playwright_spec(container_id).await;
|
||||
let spec = pinned_playwright_spec(&container).await;
|
||||
emit_progress(app, project_id, &format!("Installing {}…", spec));
|
||||
let second = npm_install(app, project_id, container_id, &[VIEWER_PACKAGE, &spec]).await?;
|
||||
let second = npm_install(app, project_id, &container, &[VIEWER_PACKAGE, &spec]).await?;
|
||||
if second.exit_code != 0 {
|
||||
return Err(format!(
|
||||
"npm couldn't install {} in this container (exit {}).\n\nnpm said:\n{}",
|
||||
@@ -279,7 +278,7 @@ pub async fn install_packages(
|
||||
step.log = merge_logs(step.log, second.log);
|
||||
|
||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||
let detection = detect::detect(container_id).await?;
|
||||
let detection = detect::detect_in(&container).await?;
|
||||
|
||||
// A probe that still finds something missing after a successful npm run is
|
||||
// the interesting case, so it is surfaced rather than swallowed. And a
|
||||
@@ -331,7 +330,7 @@ pub async fn install_packages(
|
||||
async fn npm_install(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
specs: &[&str],
|
||||
) -> Result<StepResult, String> {
|
||||
let mut cmd = vec![
|
||||
@@ -348,7 +347,7 @@ async fn npm_install(
|
||||
run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
"claude",
|
||||
INSTALL_DIR,
|
||||
cmd,
|
||||
@@ -363,20 +362,20 @@ async fn npm_install(
|
||||
/// Read from the manifest npm just wrote rather than guessed, and falling back
|
||||
/// to `@latest` when it can't be read — an unreadable manifest is a reason to
|
||||
/// install something, not nothing.
|
||||
async fn pinned_playwright_spec(container_id: &str) -> String {
|
||||
async fn pinned_playwright_spec(container: &ContainerTarget) -> String {
|
||||
let script = format!(
|
||||
"try{{const d=require('{}/node_modules/@playwright/cli/package.json').dependencies||{{}};\
|
||||
process.stdout.write(d.playwright||'');}}catch(e){{}}",
|
||||
INSTALL_DIR
|
||||
);
|
||||
let (out, _code) = exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let (out, _code) = container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
|
||||
let version: &str = out.trim();
|
||||
// A version, not a range or a URL: anything else goes to the fallback
|
||||
@@ -414,7 +413,8 @@ pub async fn install_browser(
|
||||
container_id: &str,
|
||||
target: BrowserTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let before = detect::detect(container_id).await?;
|
||||
let container = ContainerTarget::docker(container_id);
|
||||
let before = detect::detect_in(&container).await?;
|
||||
let Some(cli) = before.playwright_cli.clone() else {
|
||||
return Err(
|
||||
"Playwright isn't installed in this container yet, so there is nothing to install a \
|
||||
@@ -458,7 +458,7 @@ pub async fn install_browser(
|
||||
project_id,
|
||||
"Step 1/3 — checking whether this container already has the browser system libraries…",
|
||||
);
|
||||
let state = check_libraries(container_id, &cli, target).await;
|
||||
let state = check_libraries(&container, &cli, target).await;
|
||||
match &state {
|
||||
LibraryState::Present => {
|
||||
emit_progress(
|
||||
@@ -482,7 +482,7 @@ pub async fn install_browser(
|
||||
let deps = run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
&container,
|
||||
"root",
|
||||
"/tmp",
|
||||
vec![
|
||||
@@ -530,7 +530,7 @@ pub async fn install_browser(
|
||||
let dl = run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
&container,
|
||||
user,
|
||||
workdir,
|
||||
vec![
|
||||
@@ -559,7 +559,7 @@ pub async fn install_browser(
|
||||
project_id,
|
||||
&format!("Step 3/3 — checking that {} actually launches…", target.cli_name()),
|
||||
);
|
||||
let verdict = verify_launch(container_id, &before, target).await;
|
||||
let verdict = verify_launch(&container, &before, target).await;
|
||||
push_section(&mut log, &verdict.detail);
|
||||
if !verdict.ok {
|
||||
warning = merge(
|
||||
@@ -590,7 +590,7 @@ pub async fn install_browser(
|
||||
}
|
||||
|
||||
emit_progress(app, project_id, "Re-checking what the container has…");
|
||||
let detection = detect::detect(container_id).await?;
|
||||
let detection = detect::detect_in(&container).await?;
|
||||
|
||||
Ok(BrowserSetupOutcome {
|
||||
detection,
|
||||
@@ -642,9 +642,12 @@ impl LibraryState {
|
||||
/// dpkg's status file. A *missing* package on such an image is simply not in
|
||||
/// any index, so apt fails, Playwright reports the failure, and this returns
|
||||
/// [`LibraryState::Unknown`] — which installs, which is the right answer.
|
||||
async fn check_libraries(container_id: &str, cli: &str, target: BrowserTarget) -> LibraryState {
|
||||
let run = exec_oneshot_as(
|
||||
container_id,
|
||||
async fn check_libraries(
|
||||
container: &ContainerTarget,
|
||||
cli: &str,
|
||||
target: BrowserTarget,
|
||||
) -> LibraryState {
|
||||
let run = container.exec_oneshot_as(
|
||||
"root",
|
||||
vec![
|
||||
"node".to_string(),
|
||||
@@ -708,7 +711,7 @@ struct LaunchVerdict {
|
||||
/// Launch the browser headless, load one page, and close it. Seconds, and it is
|
||||
/// the only thing that distinguishes "downloaded" from "usable".
|
||||
async fn verify_launch(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
detection: &PlaywrightDetection,
|
||||
target: BrowserTarget,
|
||||
) -> LaunchVerdict {
|
||||
@@ -724,8 +727,7 @@ async fn verify_launch(
|
||||
};
|
||||
let dir = manifest.trim_end_matches("package.json").trim_end_matches('/');
|
||||
|
||||
let run = exec_oneshot_as(
|
||||
container_id,
|
||||
let run = container.exec_oneshot_as(
|
||||
"claude",
|
||||
vec![
|
||||
"node".to_string(),
|
||||
@@ -884,13 +886,13 @@ impl StepResult {
|
||||
/// Run one command in the container, streaming every line it prints to the pane
|
||||
/// as `container-progress` and keeping the tail for the result.
|
||||
///
|
||||
/// Uses `create_attached_exec_as` — the single attached-exec path — rather than
|
||||
/// Uses [`ContainerTarget::attach`] — the single attached-exec path — rather than
|
||||
/// `exec_oneshot`, because these commands run for minutes and the point is that
|
||||
/// the user can watch them.
|
||||
async fn run_step(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
user: &str,
|
||||
workdir: &str,
|
||||
cmd: Vec<String>,
|
||||
@@ -900,7 +902,14 @@ async fn run_step(
|
||||
exec_id,
|
||||
mut output,
|
||||
input,
|
||||
} = create_attached_exec_as(container_id, cmd, false, user, workdir).await?;
|
||||
} = container
|
||||
.attach(
|
||||
ExecSpec::new(cmd)
|
||||
.tty(false)
|
||||
.user(user)
|
||||
.working_dir(Some(workdir)),
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Nothing is ever written to this exec. Closing stdin means anything that
|
||||
// would prompt sees EOF and gives up, instead of waiting for a person who
|
||||
@@ -944,7 +953,7 @@ async fn run_step(
|
||||
)),
|
||||
Ok(Err(e)) => Err(e),
|
||||
Ok(Ok(())) => Ok(StepResult {
|
||||
exit_code: wait_for_exec_exit(&exec_id).await.unwrap_or(0),
|
||||
exit_code: container.wait_exec_exit(&exec_id).await.unwrap_or(0),
|
||||
log,
|
||||
}),
|
||||
}
|
||||
|
||||
@@ -50,7 +50,7 @@
|
||||
//! | Trigger | Path |
|
||||
//! |---|---|
|
||||
//! | Turned off in the UI | `set_browser_view_enabled(false)` → persist `false`, then [`BrowserViewManager::stop`] |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_container_running` check |
|
||||
//! | Container stopped, by the UI or otherwise | supervisor's `is_running` check |
|
||||
//! | Project deleted | supervisor's `store.get()` check |
|
||||
//! | Container rebuilt | old container stops → supervisor exits; the new one is not auto-started |
|
||||
//! | Viewer died in the container | supervisor's periodic HTTP liveness probe |
|
||||
@@ -90,8 +90,7 @@ use tokio::sync::{watch, Mutex};
|
||||
use tokio::task::JoinHandle;
|
||||
|
||||
use crate::auth_bridge::proc_net::{self, PortFamily};
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::exec_oneshot;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
|
||||
use detect::PlaywrightDetection;
|
||||
@@ -300,7 +299,8 @@ impl BrowserViewManager {
|
||||
return Ok(self.status(&project_id, true).await);
|
||||
}
|
||||
|
||||
let detection = detect::detect(&container_id).await?;
|
||||
let container = ContainerTarget::docker(container_id.as_str());
|
||||
let detection = detect::detect_in(&container).await?;
|
||||
if !detection.is_usable() {
|
||||
let blocker = detection.blocker().unwrap_or_else(|| {
|
||||
"Playwright is present but incomplete in this container.".to_string()
|
||||
@@ -320,9 +320,9 @@ impl BrowserViewManager {
|
||||
// Doing this before choosing a port also frees the one a previous
|
||||
// session was using, so sessions don't walk up the range. Best-effort:
|
||||
// a container with no dashboard makes this a no-op.
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
|
||||
let (container_port, entry_path) = start_viewer(&container_id, &cli_entry).await?;
|
||||
let (container_port, entry_path) = start_viewer(&container, &cli_entry).await?;
|
||||
|
||||
let token = generate_token();
|
||||
// `--host 127.0.0.1` is ours to set, so the family is known and there is
|
||||
@@ -337,7 +337,7 @@ impl BrowserViewManager {
|
||||
{
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
return Err(e);
|
||||
}
|
||||
};
|
||||
@@ -354,7 +354,7 @@ impl BrowserViewManager {
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let supervisor = tokio::spawn(supervise(
|
||||
project_id.clone(),
|
||||
container_id.clone(),
|
||||
container,
|
||||
cli_entry,
|
||||
container_port,
|
||||
epoch,
|
||||
@@ -427,7 +427,7 @@ impl BrowserViewManager {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn supervise(
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
cli_entry: String,
|
||||
container_port: u16,
|
||||
epoch: u64,
|
||||
@@ -443,7 +443,7 @@ async fn supervise(
|
||||
log::info!("Browser view: project {} is gone — tearing down", project_id);
|
||||
break;
|
||||
}
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
log::info!(
|
||||
"Browser view: container for project {} is no longer running — tearing down",
|
||||
project_id
|
||||
@@ -459,7 +459,7 @@ async fn supervise(
|
||||
// view never waits out an in-flight exec.
|
||||
let alive = tokio::select! {
|
||||
_ = cancel.changed() => break,
|
||||
res = probe_entry_path(&container_id, container_port) => res.is_ok(),
|
||||
res = probe_entry_path(&container, container_port) => res.is_ok(),
|
||||
};
|
||||
if !alive {
|
||||
log::warn!(
|
||||
@@ -477,7 +477,7 @@ async fn supervise(
|
||||
}
|
||||
|
||||
proxy.shutdown().await;
|
||||
let _ = kill_dashboard(&container_id, &cli_entry).await;
|
||||
let _ = kill_dashboard(&container, &cli_entry).await;
|
||||
|
||||
// Deregister, unless a newer session has already taken this project's slot.
|
||||
let superseded = {
|
||||
@@ -538,7 +538,11 @@ const VIEWER_LOG: &str = "/tmp/triple-c-browser-view.log";
|
||||
/// redirected to [`VIEWER_LOG`] (both so `exec_oneshot` can return immediately
|
||||
/// rather than waiting on an inherited stdout, and so a failure has a trail),
|
||||
/// and readiness is established by [`wait_until_ready`] instead.
|
||||
async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result<(), String> {
|
||||
async fn launch_viewer(
|
||||
container: &ContainerTarget,
|
||||
cli_entry: &str,
|
||||
port: u16,
|
||||
) -> Result<(), String> {
|
||||
// `NO_UPDATE_NOTIFIER` stops the CLI phoning registry.npmjs.org on every
|
||||
// launch; the container may have no egress, and we don't want to wait out a
|
||||
// DNS timeout before the dashboard binds.
|
||||
@@ -549,13 +553,11 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
|
||||
port,
|
||||
VIEWER_LOG
|
||||
);
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script],
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
|
||||
container
|
||||
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not start the Playwright viewer: {}", e))
|
||||
}
|
||||
|
||||
/// The dashboard singleton is keyed on a hash of the working directory, so
|
||||
@@ -567,17 +569,15 @@ async fn launch_viewer(container_id: &str, cli_entry: &str, port: u16) -> Result
|
||||
const WORKDIR_PREFIX: &str = "cd /workspace 2>/dev/null || true";
|
||||
|
||||
/// Stop the dashboard daemon. Verified to free the port and stop answering.
|
||||
async fn kill_dashboard(container_id: &str, cli_entry: &str) -> Result<String, String> {
|
||||
async fn kill_dashboard(container: &ContainerTarget, cli_entry: &str) -> Result<String, String> {
|
||||
let script = format!(
|
||||
"{}; NO_UPDATE_NOTIFIER=1 node {} show --kill",
|
||||
WORKDIR_PREFIX,
|
||||
shell_quote(cli_entry)
|
||||
);
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script],
|
||||
)
|
||||
.await
|
||||
container
|
||||
.exec_oneshot(vec!["sh".to_string(), "-c".to_string(), script])
|
||||
.await
|
||||
}
|
||||
|
||||
/// Turn a failed start into something the user can act on.
|
||||
@@ -604,13 +604,16 @@ fn explain_start_failure(err: &str, log: &str) -> String {
|
||||
}
|
||||
|
||||
/// Tail of the viewer's own output, for a start that didn't come up.
|
||||
async fn read_viewer_log(container_id: &str) -> String {
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["tail".to_string(), "-n".to_string(), "40".to_string(), VIEWER_LOG.to_string()],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
async fn read_viewer_log(container: &ContainerTarget) -> String {
|
||||
container
|
||||
.exec_oneshot(vec![
|
||||
"tail".to_string(),
|
||||
"-n".to_string(),
|
||||
"40".to_string(),
|
||||
VIEWER_LOG.to_string(),
|
||||
])
|
||||
.await
|
||||
.unwrap_or_default()
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -645,14 +648,17 @@ const MAX_PORT_ATTEMPTS: usize = 3;
|
||||
/// this layer, and the pane would then front it. What contains that is
|
||||
/// downstream — the host proxy is loopback-only and token-gated, and the pane's
|
||||
/// iframe is sandboxed — not this function.
|
||||
async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, String), String> {
|
||||
async fn start_viewer(
|
||||
container: &ContainerTarget,
|
||||
cli_entry: &str,
|
||||
) -> Result<(u16, String), String> {
|
||||
let mut tried: Vec<u16> = Vec::new();
|
||||
let mut last: Option<String> = None;
|
||||
|
||||
for _ in 0..MAX_PORT_ATTEMPTS {
|
||||
// Re-read the listener snapshot each attempt: the port that was free a
|
||||
// moment ago is exactly the one we may have just lost.
|
||||
let port = match pick_viewer_port(container_id, &tried).await {
|
||||
let port = match pick_viewer_port(container, &tried).await {
|
||||
Ok(p) => p,
|
||||
Err(e) => {
|
||||
// Report why the *attempts* failed, not just "nothing free":
|
||||
@@ -666,21 +672,21 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
|
||||
};
|
||||
tried.push(port);
|
||||
|
||||
launch_viewer(container_id, cli_entry, port).await?;
|
||||
launch_viewer(container, cli_entry, port).await?;
|
||||
|
||||
// Wait for it to actually answer, and learn the entry URL while we're
|
||||
// there — see `probe_entry_path` for why that matters. This, not the
|
||||
// launcher's stdout, is the readiness signal: verified that the
|
||||
// "Listening on …" line is printed only on the very first start.
|
||||
match wait_until_ready(container_id, port).await {
|
||||
match wait_until_ready(container, port).await {
|
||||
Ok(path) => return Ok((port, path)),
|
||||
Err(e) => {
|
||||
let log = read_viewer_log(container_id).await;
|
||||
let log = read_viewer_log(container).await;
|
||||
// Always kill before retrying: the dashboard is a singleton, so
|
||||
// a launcher that came up on some *other* port would otherwise
|
||||
// make every further attempt a no-op that silently ignores the
|
||||
// port we asked for.
|
||||
let _ = kill_dashboard(container_id, cli_entry).await;
|
||||
let _ = kill_dashboard(container, cli_entry).await;
|
||||
last = Some(explain_start_failure(&e, &log));
|
||||
}
|
||||
}
|
||||
@@ -691,10 +697,9 @@ async fn start_viewer(container_id: &str, cli_entry: &str) -> Result<(u16, Strin
|
||||
|
||||
/// First port in [`VIEWER_PORTS`] that nothing in the container is listening on
|
||||
/// and that this start has not already tried.
|
||||
async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = exec_oneshot(
|
||||
container_id,
|
||||
vec![
|
||||
async fn pick_viewer_port(container: &ContainerTarget, tried: &[u16]) -> Result<u16, String> {
|
||||
let text = container
|
||||
.exec_oneshot(vec![
|
||||
// Absolute path, deliberately, for the same reason the auth bridge
|
||||
// uses one: `container/Dockerfile` puts a container-writable
|
||||
// directory first on `PATH`, so a bare `cat` is a name the container
|
||||
@@ -704,10 +709,9 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
|
||||
"/usr/bin/cat".to_string(),
|
||||
"/proc/net/tcp".to_string(),
|
||||
"/proc/net/tcp6".to_string(),
|
||||
],
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
])
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
let taken = proc_net::parse_loopback_listeners(&text);
|
||||
VIEWER_PORTS
|
||||
.clone()
|
||||
@@ -722,10 +726,10 @@ async fn pick_viewer_port(container_id: &str, tried: &[u16]) -> Result<u16, Stri
|
||||
}
|
||||
|
||||
/// Poll the viewer until it answers, and return the path the pane should load.
|
||||
async fn wait_until_ready(container_id: &str, port: u16) -> Result<String, String> {
|
||||
async fn wait_until_ready(container: &ContainerTarget, port: u16) -> Result<String, String> {
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let last = match probe_entry_path(container_id, port).await {
|
||||
let last = match probe_entry_path(container, port).await {
|
||||
Ok(path) => return Ok(path),
|
||||
Err(e) => e,
|
||||
};
|
||||
@@ -751,7 +755,7 @@ const PROBE_MARKER: &str = "__TRIPLE_C_BV_PATH__";
|
||||
/// a redirect — which matters, because a redirect drops the `?token=` the proxy
|
||||
/// gate wants and would leave a fresh connection to be authorised with nothing.
|
||||
/// A `200` (no redirect) is fine too; then the entry point is just `/`.
|
||||
async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, String> {
|
||||
async fn probe_entry_path(container: &ContainerTarget, port: u16) -> Result<String, String> {
|
||||
// The request is bounded on both sides. Verified: the dashboard answers a
|
||||
// bad WebSocket path by holding the socket open forever rather than
|
||||
// erroring, so "no reply" is a state this probe has to be able to leave —
|
||||
@@ -766,10 +770,7 @@ async fn probe_entry_path(container_id: &str, port: u16) -> Result<String, Strin
|
||||
);
|
||||
let out = tokio::time::timeout(
|
||||
PROBE_TIMEOUT * 2,
|
||||
exec_oneshot(
|
||||
container_id,
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
),
|
||||
container.exec_oneshot(vec!["node".to_string(), "-e".to_string(), script]),
|
||||
)
|
||||
.await
|
||||
.map_err(|_| "the viewer probe did not return".to_string())??;
|
||||
|
||||
@@ -37,7 +37,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tauri::AppHandle;
|
||||
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::exec::exec_oneshot_as;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
use super::detect::PlaywrightDetection;
|
||||
|
||||
@@ -150,14 +150,14 @@ pub async fn open(
|
||||
shell_quote(&script),
|
||||
HELPER_LOG
|
||||
);
|
||||
exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||
ContainerTarget::docker(container_id)
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), launcher],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Could not start the browser helper: {}", e))?;
|
||||
|
||||
emit_progress(app, project_id, "Waiting for the page to load…");
|
||||
wait_until_ready(container_id).await
|
||||
@@ -189,13 +189,13 @@ pub async fn state(container_id: &str) -> PageState {
|
||||
"try{{process.stdout.write(require('fs').readFileSync('{}','utf8'));}}catch(e){{}}",
|
||||
STATE_PATH
|
||||
);
|
||||
let Ok((out, _)) = exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
let Ok((out, _)) = ContainerTarget::docker(container_id)
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return PageState::default();
|
||||
};
|
||||
@@ -209,15 +209,15 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||
"require('fs').writeFileSync('{}',process.argv[1]);",
|
||||
CONTROL_PATH
|
||||
);
|
||||
exec_oneshot_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||
ContainerTarget::docker(container_id)
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script, json],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
.map(|_| ())
|
||||
.map_err(|e| format!("Could not reach the browser helper: {}", e))
|
||||
}
|
||||
|
||||
/// Wait for a *running* helper to report the URL we just asked it for.
|
||||
|
||||
@@ -76,8 +76,7 @@ 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::host::{AttachedExec, ContainerTarget};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -867,7 +866,7 @@ async fn type_code_then_enter<W: tokio::io::AsyncWrite + Unpin>(
|
||||
async fn run_setup_token(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
mut input_rx: mpsc::UnboundedReceiver<Vec<u8>>,
|
||||
mut cancel_rx: oneshot::Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
@@ -877,16 +876,16 @@ async fn run_setup_token(
|
||||
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?;
|
||||
} = target
|
||||
.attach_exec(
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
SETUP_TOKEN_SCRIPT.to_string(),
|
||||
],
|
||||
true,
|
||||
)
|
||||
.await?;
|
||||
|
||||
let mut stripper = AnsiStripper::default();
|
||||
let mut redactor = SecretRedactor::default();
|
||||
@@ -1041,7 +1040,7 @@ async fn run_setup_token(
|
||||
// Falling through to the token parse is right either way — a run that
|
||||
// printed a token succeeded whatever Docker says about it — but it is worth
|
||||
// saying so rather than silently calling it a clean exit.
|
||||
match wait_for_exec_exit(&exec_id).await {
|
||||
match target.wait_exec_exit(&exec_id).await {
|
||||
Some(0) => {}
|
||||
Some(code) => {
|
||||
return Err(format!(
|
||||
@@ -1091,13 +1090,13 @@ pub async fn acquire_claude_token(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project.container_id.clone().ok_or_else(|| {
|
||||
let target = ContainerTarget::for_project(&project).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) {
|
||||
if !target.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"The container for '{}' is not running. Start it, then run authentication again.",
|
||||
project.name
|
||||
@@ -1133,8 +1132,7 @@ pub async fn acquire_claude_token(
|
||||
"Running `claude setup-token` — sign in at the URL below, then submit the code it gives you.",
|
||||
);
|
||||
|
||||
let result =
|
||||
run_setup_token(&app_handle, &project_id, &container_id, input_rx, cancel_rx).await;
|
||||
let result = run_setup_token(&app_handle, &project_id, &target, input_rx, cancel_rx).await;
|
||||
|
||||
// Release the flow. Nothing else needs unwinding: this command changes no
|
||||
// persisted state until the token itself is stored, which is the point.
|
||||
|
||||
@@ -4,14 +4,11 @@ use std::sync::Arc;
|
||||
|
||||
use base64::engine::general_purpose::STANDARD as BASE64;
|
||||
use base64::Engine as _;
|
||||
use bollard::container::{DownloadFromContainerOptions, LogOutput};
|
||||
use bollard::exec::{CreateExecOptions, StartExecResults};
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, OUTPUT_LIMIT_MARKER};
|
||||
use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER};
|
||||
use crate::AppState;
|
||||
|
||||
#[derive(Debug, PartialEq, Serialize)]
|
||||
@@ -63,9 +60,7 @@ pub async fn list_container_files(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
// `exec_oneshot` discards the exit code, which is how a `find` that listed
|
||||
@@ -78,7 +73,8 @@ pub async fn list_container_files(
|
||||
// are the error message, its `-printf` records are the listing, and the
|
||||
// parser should never be handed the former.
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", list_argv(&path), Vec::new())
|
||||
target
|
||||
.exec_oneshot_streams_as("claude", list_argv(&path), Vec::new())
|
||||
.await
|
||||
.map_err(|e| describe_listing_failure(&path, e))?;
|
||||
|
||||
@@ -156,7 +152,7 @@ fn list_argv(path: &str) -> Vec<String> {
|
||||
/// folder can act on.
|
||||
///
|
||||
/// One case is worth naming: a directory with more entries than
|
||||
/// [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
|
||||
/// [`crate::host::MAX_ONESHOT_OUTPUT`] will hold. Roughly 100k names is
|
||||
/// the point where a `find` record set passes 8 MiB, and what the panel showed
|
||||
/// was "Command output exceeded 8388608 bytes and was abandoned" — a true
|
||||
/// statement about a buffer, and no help at all about a directory.
|
||||
@@ -430,7 +426,11 @@ pub(crate) fn validate_container_write_path(what: &str, path: &str) -> Result<()
|
||||
/// and the lexical answer stands: failing every write closed would break the
|
||||
/// panel outright for a risk the container user does not need this code path to
|
||||
/// take.
|
||||
async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Result<(), String> {
|
||||
async fn resolve_container_dir(
|
||||
target: &ContainerTarget,
|
||||
what: &str,
|
||||
dir: &str,
|
||||
) -> Result<(), String> {
|
||||
validate_container_write_path(what, dir)?;
|
||||
|
||||
// Split streams, not the combined buffer: `realpath`'s answer is a *path*
|
||||
@@ -438,18 +438,18 @@ async fn resolve_container_dir(container_id: &str, what: &str, dir: &str) -> Res
|
||||
// hazard the listing above took apart for `find`. A warning on stderr —
|
||||
// and there is one whenever a component is unreadable — used to be spliced
|
||||
// into the string this then compared against the write roots.
|
||||
let (stdout, diagnostics, code) = exec_oneshot_streams_as(
|
||||
container_id,
|
||||
"claude",
|
||||
vec![
|
||||
"realpath".to_string(),
|
||||
"-m".to_string(),
|
||||
"--".to_string(),
|
||||
dir.to_string(),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
let (stdout, diagnostics, code) = target
|
||||
.exec_oneshot_streams_as(
|
||||
"claude",
|
||||
vec![
|
||||
"realpath".to_string(),
|
||||
"-m".to_string(),
|
||||
"--".to_string(),
|
||||
dir.to_string(),
|
||||
],
|
||||
Vec::new(),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let resolved = stdout.trim();
|
||||
if code != 0 || resolved.is_empty() {
|
||||
@@ -1188,7 +1188,7 @@ pub(crate) struct FetchedFile {
|
||||
|
||||
/// Fetch a single regular file from a container as exact bytes.
|
||||
///
|
||||
/// The viewer's read. It deliberately goes through Docker's archive endpoint rather than
|
||||
/// The viewer's read. It deliberately goes through the archive endpoint rather than
|
||||
/// `exec_oneshot`: that reader runs every chunk through `String::from_utf8_lossy`
|
||||
/// and merges stderr into stdout, so it would both corrupt any non-UTF-8 file
|
||||
/// and be able to splice diagnostics into what the caller believes is content.
|
||||
@@ -1202,19 +1202,15 @@ pub(crate) struct FetchedFile {
|
||||
/// file — or the whole *directory tree*, since the type check happens after the
|
||||
/// read — landed in host RAM twice. This function buffers, so every caller of
|
||||
/// it must name a ceiling.
|
||||
///
|
||||
/// Takes a container id rather than a [`ContainerTarget`] because the file
|
||||
/// viewer's commands call it with the id they already hold.
|
||||
pub(crate) async fn fetch_container_file(
|
||||
container_id: &str,
|
||||
container_path: &str,
|
||||
max_bytes: u64,
|
||||
) -> Result<FetchedFile, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let mut stream = docker.download_from_container(
|
||||
container_id,
|
||||
Some(DownloadFromContainerOptions {
|
||||
path: container_path.to_string(),
|
||||
}),
|
||||
);
|
||||
let mut stream = ContainerTarget::docker(container_id).download_archive(container_path);
|
||||
|
||||
// A tar member is a 512-byte header plus payload padded to 512. 8 KiB of
|
||||
// slack past the payload cap guarantees the header and the whole capped
|
||||
@@ -1297,13 +1293,11 @@ pub async fn read_container_file(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cap = max_bytes.unwrap_or(MAX_READ_BYTES).min(MAX_READ_BYTES);
|
||||
let fetched = fetch_container_file(container_id, &path, cap).await?;
|
||||
let fetched = fetch_container_file(target.id(), &path, cap).await?;
|
||||
|
||||
Ok(FileContents {
|
||||
contents_base64: BASE64.encode(&fetched.bytes),
|
||||
@@ -1333,9 +1327,7 @@ pub async fn rename_container_path(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
// The name is checked by `validate_entry_name`; the path it is applied to
|
||||
@@ -1349,7 +1341,7 @@ pub async fn rename_container_path(
|
||||
// directory component to resolve.
|
||||
let parent = parent_dir(&from_path);
|
||||
if parent != "/" {
|
||||
resolve_container_dir(container_id, "Item", &parent).await?;
|
||||
resolve_container_dir(&target, "Item", &parent).await?;
|
||||
}
|
||||
|
||||
let new_name = to_path.trim();
|
||||
@@ -1364,8 +1356,7 @@ pub async fn rename_container_path(
|
||||
// and exits 0 — so `-n` on its own would report a rename that never
|
||||
// happened. The existence check is what turns it into an error the user
|
||||
// sees; `-n` stays as the belt-and-braces against the race between them.
|
||||
let (_, exists) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (_, exists) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["test".to_string(), "-e".to_string(), dest.clone()],
|
||||
Vec::new(),
|
||||
@@ -1375,8 +1366,7 @@ pub async fn rename_container_path(
|
||||
return Err(format!("\"{}\" already exists in this folder", new_name));
|
||||
}
|
||||
|
||||
let (output, code) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (output, code) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec![
|
||||
"mv".to_string(),
|
||||
@@ -1417,19 +1407,16 @@ pub async fn create_container_directory(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
resolve_container_dir(container_id, "Folder", &parent_path).await?;
|
||||
resolve_container_dir(&target, "Folder", &parent_path).await?;
|
||||
|
||||
let name = name.trim();
|
||||
validate_entry_name(name)?;
|
||||
let dest = join_path(&parent_path, name);
|
||||
|
||||
let (output, code) = exec_oneshot_as(
|
||||
container_id,
|
||||
let (output, code) = target.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["mkdir".to_string(), "--".to_string(), dest.clone()],
|
||||
Vec::new(),
|
||||
@@ -1459,20 +1446,19 @@ pub(crate) fn not_running_message(action: &str, why: &str) -> String {
|
||||
/// Refuse, in a sentence, before a Docker error has to speak for us.
|
||||
///
|
||||
/// Both file transfers and the backup run through `docker exec`, which needs a
|
||||
/// running container. Without this the failure surfaces as bollard's
|
||||
/// running container. Without this the failure surfaces as Docker's
|
||||
/// `is not running` wrapped in whatever the caller was doing, and for the
|
||||
/// upload it surfaces even less usefully: `resolve_container_dir`'s `realpath`
|
||||
/// is the first thing to touch the container, so a stopped project fails inside
|
||||
/// path *validation* and reads like the path was the problem.
|
||||
///
|
||||
/// Takes a container id rather than a [`ContainerTarget`] because the file
|
||||
/// viewer's commands call it with the id they already hold.
|
||||
pub(crate) async fn require_running(container_id: &str, action: &str) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
let running = docker
|
||||
.inspect_container(container_id, None)
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|info| info.state)
|
||||
.and_then(|s| s.running)
|
||||
.unwrap_or(false);
|
||||
// An unreachable daemon is an error; an inspect failure (container gone)
|
||||
// reads as "not running" — `is_running`'s contract, and what this did
|
||||
// when it inspected the container itself.
|
||||
let running = ContainerTarget::docker(container_id).is_running().await?;
|
||||
if running {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1543,12 +1529,10 @@ pub async fn download_container_file(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
require_running(container_id, "saving a file to the host").await?;
|
||||
require_running(target.id(), "saving a file to the host").await?;
|
||||
|
||||
let Some(chosen) = pick_save_path(&window, &suggested_save_name(&container_path)).await else {
|
||||
// Dismissed. Not an error, and deliberately distinguishable from one:
|
||||
@@ -1564,8 +1548,6 @@ pub async fn download_container_file(
|
||||
// costs. See the note on `pick_save_path` about which ones those are.
|
||||
let dest = resolve_host_path(&chosen, HostPathUse::WriteChosenName).await?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
// `$TC_SRC`, never argv interpolation: the path reaches the shell as an
|
||||
// environment value, so a name containing a quote or a `$` is data.
|
||||
//
|
||||
@@ -1627,30 +1609,19 @@ esac
|
||||
echo "TC_SIZE=$SZ" >&2
|
||||
dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
cmd: Some(vec!["sh".to_string(), "-c".to_string(), script.to_string()]),
|
||||
env: Some(vec![format!("TC_SRC={}", container_path)]),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
// As `claude`, no TTY, no stdin and no working directory (the image's
|
||||
// default) — the options this exec has always been created with.
|
||||
let exec = target
|
||||
.attach(
|
||||
ExecSpec::new(vec!["sh".to_string(), "-c".to_string(), script.to_string()])
|
||||
.env(vec![format!("TC_SRC={}", container_path)])
|
||||
.working_dir(None)
|
||||
.no_stdin(),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
let result = docker
|
||||
.start_exec(&exec.id, None)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start download: {}", e))?;
|
||||
|
||||
let mut output = match result {
|
||||
StartExecResults::Attached { output, .. } => output,
|
||||
StartExecResults::Detached => return Err("Download exec started detached".to_string()),
|
||||
};
|
||||
let exec_id = exec.exec_id;
|
||||
let mut output = exec.output;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let partial = partial_download_path(&dest)?;
|
||||
@@ -1696,7 +1667,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write {}: {}", dest.display(), e));
|
||||
break;
|
||||
@@ -1727,7 +1698,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
push_capped(&mut stderr_text, &message);
|
||||
if declared.is_none() {
|
||||
declared = parse_declared_size(&stderr_text);
|
||||
@@ -1760,7 +1731,7 @@ dd iflag=nonblock bs=64k status=none if="$TC_SRC" || exit 5"#;
|
||||
// success renames a truncated partial over the file the user already had
|
||||
// and reports the byte count as if it were the whole thing. That is the one
|
||||
// way this command could destroy data, so silence is failure.
|
||||
let exit_code = crate::docker::exec::wait_for_exec_exit(&exec.id).await;
|
||||
let exit_code = target.wait_exec_exit(&exec_id).await;
|
||||
if stream_err.is_none() && exit_code != Some(0) {
|
||||
// Framed and clipped, never verbatim.
|
||||
//
|
||||
@@ -1894,7 +1865,7 @@ pub struct UploadOutcome {
|
||||
/// * the destination goes through [`resolve_container_dir`], so it is inside
|
||||
/// [`CONTAINER_WRITE_ROOTS`] both as written and as it resolves;
|
||||
/// * the read, the size ceiling and the descriptor check are
|
||||
/// `docker::exec::upload_host_file_with_ids`', unchanged — including
|
||||
/// `ContainerTarget::upload_host_file_with_ids`', unchanged — including
|
||||
/// landing the file owned by the container user rather than root.
|
||||
///
|
||||
/// The dialog itself is opened from Rust ([`pick_files_to_upload`]), which is
|
||||
@@ -1911,15 +1882,13 @@ pub async fn upload_files_to_container(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
// Both checks before the dialog, so a project that cannot receive files
|
||||
// says so instead of asking the user to choose some first.
|
||||
require_running(container_id, "uploading files").await?;
|
||||
resolve_container_dir(container_id, "Upload", &container_dir).await?;
|
||||
require_running(target.id(), "uploading files").await?;
|
||||
resolve_container_dir(&target, "Upload", &container_dir).await?;
|
||||
|
||||
let Some(picked) = pick_files_to_upload(&window).await else {
|
||||
return Ok(None);
|
||||
@@ -1930,14 +1899,14 @@ pub async fn upload_files_to_container(
|
||||
// files; this one is the check that actually guards the extraction. A modal
|
||||
// has no time limit, and `resolve_container_dir` answers a question about
|
||||
// the container's filesystem — which the container is free to change while
|
||||
// the dialog is open. Without this, `docker::exec`'s doc comment claim that
|
||||
// the dialog is open. Without this, the uploader's doc comment claim that
|
||||
// the destination "has already been confirmed" would be true only of a
|
||||
// moment that had passed.
|
||||
resolve_container_dir(container_id, "Upload", &container_dir).await?;
|
||||
resolve_container_dir(&target, "Upload", &container_dir).await?;
|
||||
|
||||
// Once for the whole selection, not once per file — see
|
||||
// `upload_host_file_with_ids`.
|
||||
let ids = crate::docker::exec::container_user_ids(container_id).await;
|
||||
let ids = target.user_ids().await;
|
||||
|
||||
let mut outcome = UploadOutcome {
|
||||
uploaded: Vec::new(),
|
||||
@@ -1951,7 +1920,7 @@ pub async fn upload_files_to_container(
|
||||
continue;
|
||||
}
|
||||
};
|
||||
match upload_one(container_id, &path, &container_dir, ids).await {
|
||||
match upload_one(&target, &path, &container_dir, ids).await {
|
||||
Ok(dest) => {
|
||||
log::info!("Uploaded {} into project {} at {}", path, project_id, dest);
|
||||
outcome.uploaded.push(dest);
|
||||
@@ -1966,7 +1935,7 @@ pub async fn upload_files_to_container(
|
||||
/// naming the file, because the caller may be reporting several at once and
|
||||
/// "is a folder" on its own does not say which one.
|
||||
async fn upload_one(
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
host_path: &str,
|
||||
container_dir: &str,
|
||||
ids: (u64, u64),
|
||||
@@ -1990,7 +1959,7 @@ async fn upload_one(
|
||||
// The ceiling is enforced against the open descriptor inside the uploader;
|
||||
// this copy of it exists so the refusal arrives as a sentence instead of
|
||||
// after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
use crate::host::MAX_DROP_BYTES;
|
||||
if meta.len() > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"{} is too large to upload ({:.0} MB; limit {} MB). Mount it into the project instead.",
|
||||
@@ -2000,14 +1969,9 @@ async fn upload_one(
|
||||
));
|
||||
}
|
||||
|
||||
crate::docker::exec::upload_host_file_with_ids(
|
||||
container_id,
|
||||
&resolved,
|
||||
container_dir,
|
||||
&name,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
target
|
||||
.upload_host_file_with_ids(&resolved, container_dir, &name, ids)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Container-authored diagnostic text, cut down to something safe to quote.
|
||||
@@ -2274,14 +2238,10 @@ pub async fn download_container_backup(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "No container exists for this project yet — start it first".to_string())?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
|
||||
require_running(container_id, "backing up").await?;
|
||||
require_running(target.id(), "backing up").await?;
|
||||
|
||||
let path = container_path.unwrap_or_else(|| "/workspace".to_string());
|
||||
// Read-only source: `tar -C` it, so absoluteness and `..` are what matter.
|
||||
@@ -2326,33 +2286,22 @@ tar czf - --ignore-failed-read \
|
||||
|
||||
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![
|
||||
// As `claude`, no TTY, no stdin and no working directory (the image's
|
||||
// default) — the options this exec has always been created with.
|
||||
let exec = target
|
||||
.attach(
|
||||
ExecSpec::new(cmd)
|
||||
.env(vec![
|
||||
"HOME=/home/claude".to_string(),
|
||||
format!("TC_BACKUP_SRC={}", path),
|
||||
]),
|
||||
user: Some("claude".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
])
|
||||
.working_dir(None)
|
||||
.no_stdin(),
|
||||
)
|
||||
.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()),
|
||||
};
|
||||
.map_err(|e| format!("Failed to start backup: {}", e))?;
|
||||
let exec_id = exec.exec_id;
|
||||
let mut output = exec.output;
|
||||
|
||||
use tokio::io::AsyncWriteExt;
|
||||
let partial = partial_download_path(&dest)?;
|
||||
@@ -2401,14 +2350,14 @@ tar czf - --ignore-failed-read \
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::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 }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
push_capped(&mut stderr_text, &message);
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -2430,7 +2379,7 @@ tar czf - --ignore-failed-read \
|
||||
// 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;
|
||||
let exit_code = target.wait_exec_exit(&exec_id).await;
|
||||
|
||||
if stream_err.is_none() && exit_code.is_some_and(|c| c != 0) {
|
||||
stream_err = Some(format!(
|
||||
@@ -3564,10 +3513,11 @@ mod tests {
|
||||
|
||||
// `/workspace/escape` is under a write root as a string and is `/etc` as
|
||||
// a location.
|
||||
let escaped = resolve_container_dir(&name, "Folder", "/workspace/escape").await;
|
||||
let target = ContainerTarget::docker(name.clone());
|
||||
let escaped = resolve_container_dir(&target, "Folder", "/workspace/escape").await;
|
||||
println!("container escape: {:?}", escaped);
|
||||
assert!(escaped.is_err(), "a symlink out of /workspace was accepted");
|
||||
resolve_container_dir(&name, "Folder", "/workspace")
|
||||
resolve_container_dir(&target, "Folder", "/workspace")
|
||||
.await
|
||||
.expect("/workspace itself");
|
||||
|
||||
@@ -3575,7 +3525,7 @@ mod tests {
|
||||
// reporting itself empty — `-H` follows the start point, and only the
|
||||
// start point.
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace/link"), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv("/workspace/link"), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
println!("link: code={} diagnostics={:?}", code, diagnostics);
|
||||
@@ -3588,7 +3538,7 @@ mod tests {
|
||||
// The link itself is still *marked* as one in its parent's listing, and
|
||||
// still navigable.
|
||||
let (records, _, _) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv("/workspace"), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv("/workspace"), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let parent = parse_find_output("/workspace", &records);
|
||||
@@ -3607,7 +3557,7 @@ mod tests {
|
||||
for path in ["/workspace/broken", "/workspace/loop"] {
|
||||
let started = std::time::Instant::now();
|
||||
let (records, diagnostics, code) =
|
||||
exec_oneshot_streams_as(&name, "claude", list_argv(path), Vec::new())
|
||||
target.exec_oneshot_streams_as("claude", list_argv(path), Vec::new())
|
||||
.await
|
||||
.unwrap();
|
||||
let took = started.elapsed();
|
||||
|
||||
@@ -15,7 +15,7 @@ use crate::file_viewer::poll::{poll_file, ViewerPoll};
|
||||
use crate::file_viewer::registry::{
|
||||
Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState,
|
||||
};
|
||||
use crate::file_viewer::resolve::{candidate_paths, probe_candidates};
|
||||
use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates};
|
||||
use crate::file_viewer::window::open_viewer_window;
|
||||
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
|
||||
use crate::models::Project;
|
||||
@@ -159,8 +159,17 @@ pub async fn open_file_viewer(
|
||||
let container_id = running_container_of(&project, "opening files").await?;
|
||||
|
||||
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
|
||||
let candidates = candidate_paths(&path, &mounts)?;
|
||||
let matches = probe_candidates(&container_id, &candidates).await?;
|
||||
let mut candidates = candidate_paths(&path, &mounts)?;
|
||||
let mut matches = probe_candidates(&container_id, &candidates).await?;
|
||||
// Not in the project: try home and /tmp, as a second exec so a project file
|
||||
// never becomes a chooser because a namesake sits in /tmp.
|
||||
if matches.is_empty() {
|
||||
let fallbacks = fallback_candidate_paths(&path);
|
||||
if !fallbacks.is_empty() {
|
||||
matches = probe_candidates(&container_id, &fallbacks).await?;
|
||||
candidates.extend(fallbacks);
|
||||
}
|
||||
}
|
||||
let initial = Location { line, col, end_line };
|
||||
|
||||
let target_state = match matches.len() {
|
||||
|
||||
@@ -12,8 +12,9 @@
|
||||
//!
|
||||
//! ## Container access
|
||||
//!
|
||||
//! All work happens inside the container via the existing `docker exec`
|
||||
//! plumbing in [`crate::docker::exec`] — no second mechanism is introduced.
|
||||
//! All work happens inside the container via the existing exec plumbing,
|
||||
//! reached through [`crate::host::ContainerTarget`] — no second mechanism is
|
||||
//! introduced.
|
||||
//! The heavy lifting (walking dirs, grepping transcripts, parsing JSON with
|
||||
//! `jq`) runs *in* the container and only a small JSON summary crosses the
|
||||
//! wire, so multi-megabyte transcripts are never streamed back.
|
||||
@@ -56,13 +57,11 @@
|
||||
//! commands fail loudly, since they cannot do anything useful without a
|
||||
//! running container.
|
||||
|
||||
use bollard::exec::{CreateExecOptions, StartExecOptions};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use tauri::State;
|
||||
|
||||
use crate::docker::client::get_docker;
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status};
|
||||
use crate::docker::CONTAINER_HOME;
|
||||
use crate::host::{ContainerTarget, ExecSpec};
|
||||
use crate::AppState;
|
||||
|
||||
/// Newest N session transcripts to inspect. Caps the work done inside the
|
||||
@@ -72,8 +71,6 @@ const MAX_SESSIONS: usize = 50;
|
||||
/// Newest N scheduler notifications to return.
|
||||
const MAX_NOTIFICATIONS: usize = 50;
|
||||
|
||||
const CONTAINER_HOME: &str = "/home/claude";
|
||||
|
||||
/// Caps on the free-text fields of a scheduled task. They exist to keep a
|
||||
/// runaway paste out of the container's task JSON and out of the `docker exec`
|
||||
/// payload; they are generous enough for a real prompt.
|
||||
@@ -193,7 +190,7 @@ pub struct SchedulerNotification {
|
||||
// Shared helpers
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Resolve a project to a *running* container id.
|
||||
/// Resolve a project to its *running* container.
|
||||
///
|
||||
/// `Ok(None)` means "there is nothing to inspect" — no container recorded, or
|
||||
/// the container exists but is stopped. Callers that are read-only turn that
|
||||
@@ -202,19 +199,19 @@ pub struct SchedulerNotification {
|
||||
async fn running_container_for(
|
||||
project_id: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<Option<String>, String> {
|
||||
) -> Result<Option<ContainerTarget>, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = match project.container_id {
|
||||
Some(id) => id,
|
||||
let target = match ContainerTarget::for_project(&project) {
|
||||
Some(t) => t,
|
||||
None => return Ok(None),
|
||||
};
|
||||
|
||||
if is_container_running(&container_id).await.unwrap_or(false) {
|
||||
Ok(Some(container_id))
|
||||
if target.is_running().await.unwrap_or(false) {
|
||||
Ok(Some(target))
|
||||
} else {
|
||||
Ok(None)
|
||||
}
|
||||
@@ -225,7 +222,7 @@ async fn running_container_for(
|
||||
async fn require_running_container(
|
||||
project_id: &str,
|
||||
state: &State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
running_container_for(project_id, state).await?.ok_or_else(|| {
|
||||
"Container is not running — start the project first.".to_string()
|
||||
})
|
||||
@@ -241,13 +238,13 @@ fn home_env() -> Vec<String> {
|
||||
/// the first line) so the combined stream `exec_oneshot_env` returns is pure
|
||||
/// stdout and stays parseable as JSON. A non-zero exit therefore surfaces as
|
||||
/// empty output, which the callers treat as "nothing to report".
|
||||
async fn run_script(container_id: &str, script: impl Into<String>) -> Result<String, String> {
|
||||
exec_oneshot_env(
|
||||
container_id,
|
||||
vec!["sh".to_string(), "-c".to_string(), script.into()],
|
||||
home_env(),
|
||||
)
|
||||
.await
|
||||
async fn run_script(target: &ContainerTarget, script: impl Into<String>) -> Result<String, String> {
|
||||
target
|
||||
.exec_oneshot_env(
|
||||
vec!["sh".to_string(), "-c".to_string(), script.into()],
|
||||
home_env(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Parse script output as JSON, degrading to a default value (and a log line)
|
||||
@@ -301,7 +298,7 @@ fn validate_task_id(id: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Strict allowlist for session ids (Claude Code uses UUIDs).
|
||||
fn validate_session_id(id: &str) -> Result<(), String> {
|
||||
pub(crate) fn validate_session_id(id: &str) -> Result<(), String> {
|
||||
let valid = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.starts_with(|c: char| c.is_ascii_alphanumeric())
|
||||
@@ -316,11 +313,11 @@ fn validate_session_id(id: &str) -> Result<(), String> {
|
||||
/// Run `triple-c-scheduler <args…>` as a bare argv vector — no shell is
|
||||
/// involved, so caller-supplied ids cannot be interpreted as shell syntax.
|
||||
/// Returns the combined output, erroring with it on a non-zero exit.
|
||||
async fn run_scheduler(container_id: &str, args: Vec<String>) -> Result<String, String> {
|
||||
async fn run_scheduler(target: &ContainerTarget, args: Vec<String>) -> Result<String, String> {
|
||||
let mut cmd = vec!["triple-c-scheduler".to_string()];
|
||||
cmd.extend(args);
|
||||
|
||||
let (output, exit_code) = exec_oneshot_env_status(container_id, cmd, home_env()).await?;
|
||||
let (output, exit_code) = target.exec_oneshot_env_status(cmd, home_env()).await?;
|
||||
if exit_code != 0 {
|
||||
let detail = output.trim();
|
||||
return Err(if detail.is_empty() {
|
||||
@@ -396,8 +393,8 @@ pub async fn list_claude_sessions(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ClaudeSession>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
@@ -405,7 +402,7 @@ pub async fn list_claude_sessions(
|
||||
// a const usize — never from the caller.
|
||||
let script = SESSIONS_SCRIPT.replace("__MAX__", &MAX_SESSIONS.to_string());
|
||||
|
||||
let raw = run_script(&container_id, script).await?;
|
||||
let raw = run_script(&target, script).await?;
|
||||
let sessions: Vec<RawSession> = parse_or_default(&raw, "session list");
|
||||
|
||||
Ok(sessions
|
||||
@@ -422,36 +419,6 @@ pub async fn list_claude_sessions(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Build the shell command line that resumes a session, for the frontend to
|
||||
/// drop into a terminal.
|
||||
///
|
||||
/// The flag spelling was checked against the CLI in the container image:
|
||||
/// `claude --resume <session-id>` (short form `-r`).
|
||||
///
|
||||
/// The project's permission mode is folded in so the resumed session behaves
|
||||
/// like a freshly opened one. The session id is validated first, and the
|
||||
/// returned string contains only allowlisted characters.
|
||||
#[tauri::command]
|
||||
pub async fn resume_session_command(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_session_id(&session_id)?;
|
||||
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let mut parts = vec!["claude".to_string()];
|
||||
parts.extend(project.effective_permission_mode().cli_args());
|
||||
parts.push("--resume".to_string());
|
||||
parts.push(session_id);
|
||||
|
||||
Ok(parts.join(" "))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Capabilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
@@ -597,12 +564,12 @@ pub async fn list_container_capabilities(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<ContainerCapabilities, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(ContainerCapabilities::default()),
|
||||
};
|
||||
|
||||
let raw = run_script(&container_id, CAPABILITIES_SCRIPT).await?;
|
||||
let raw = run_script(&target, CAPABILITIES_SCRIPT).await?;
|
||||
Ok(parse_or_default(&raw, "container capabilities"))
|
||||
}
|
||||
|
||||
@@ -712,12 +679,12 @@ pub async fn list_scheduled_tasks(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<ScheduledTask>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let raw = run_script(&container_id, SCHEDULER_LIST_SCRIPT).await?;
|
||||
let raw = run_script(&target, SCHEDULER_LIST_SCRIPT).await?;
|
||||
let tasks: Vec<RawScheduledTask> = parse_or_default(&raw, "scheduled tasks");
|
||||
|
||||
Ok(tasks
|
||||
@@ -762,13 +729,13 @@ pub async fn get_scheduled_task_log(
|
||||
// Clamped, and an integer by type — cannot carry shell syntax.
|
||||
let tail = tail_lines.unwrap_or(200).clamp(1, 5000);
|
||||
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(String::new()),
|
||||
};
|
||||
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec![
|
||||
"logs".to_string(),
|
||||
"--id".to_string(),
|
||||
@@ -787,15 +754,15 @@ pub async fn get_scheduler_notifications(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<Vec<SchedulerNotification>, String> {
|
||||
let container_id = match running_container_for(&project_id, &state).await? {
|
||||
Some(id) => id,
|
||||
let target = match running_container_for(&project_id, &state).await? {
|
||||
Some(t) => t,
|
||||
None => return Ok(Vec::new()),
|
||||
};
|
||||
|
||||
let script =
|
||||
SCHEDULER_NOTIFICATIONS_SCRIPT.replace("__MAX__", &MAX_NOTIFICATIONS.to_string());
|
||||
|
||||
let raw = run_script(&container_id, script).await?;
|
||||
let raw = run_script(&target, script).await?;
|
||||
let notifications: Vec<RawNotification> = parse_or_default(&raw, "scheduler notifications");
|
||||
|
||||
Ok(notifications
|
||||
@@ -1171,9 +1138,9 @@ pub async fn add_scheduled_task(
|
||||
&schedule,
|
||||
working_dir.as_deref(),
|
||||
)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(&container_id, input.add_args()).await?;
|
||||
let output = run_scheduler(&target, input.add_args()).await?;
|
||||
let task_id = parse_created_task_id(&output).ok_or_else(|| {
|
||||
format!(
|
||||
"The scheduler did not report a task id. Its output was: {}",
|
||||
@@ -1231,9 +1198,9 @@ pub async fn update_scheduled_task(
|
||||
&schedule,
|
||||
working_dir.as_deref(),
|
||||
)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(&container_id, input.add_args()).await?;
|
||||
let output = run_scheduler(&target, input.add_args()).await?;
|
||||
let new_id = parse_created_task_id(&output).ok_or_else(|| {
|
||||
format!(
|
||||
"The scheduler did not report a task id, so the original task was left in place. Its output was: {}",
|
||||
@@ -1242,7 +1209,7 @@ pub async fn update_scheduled_task(
|
||||
})?;
|
||||
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["remove".to_string(), "--id".to_string(), task_id.clone()],
|
||||
)
|
||||
.await
|
||||
@@ -1255,7 +1222,7 @@ pub async fn update_scheduled_task(
|
||||
|
||||
if enabled == Some(false) {
|
||||
if let Err(e) = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["disable".to_string(), "--id".to_string(), new_id.clone()],
|
||||
)
|
||||
.await
|
||||
@@ -1284,11 +1251,11 @@ pub async fn set_scheduled_task_enabled(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let subcommand = if enabled { "enable" } else { "disable" };
|
||||
let output = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec![subcommand.to_string(), "--id".to_string(), task_id],
|
||||
)
|
||||
.await?;
|
||||
@@ -1308,39 +1275,20 @@ pub async fn run_scheduled_task_now(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let docker = get_docker()?;
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
&container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(false),
|
||||
attach_stderr: Some(false),
|
||||
// Argv vector — no shell, so `task_id` is inert as data.
|
||||
cmd: Some(vec![
|
||||
"triple-c-scheduler".to_string(),
|
||||
"run".to_string(),
|
||||
"--id".to_string(),
|
||||
task_id.clone(),
|
||||
]),
|
||||
env: Some(home_env()),
|
||||
user: Some("claude".to_string()),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
|
||||
docker
|
||||
.start_exec(
|
||||
&exec.id,
|
||||
Some(StartExecOptions {
|
||||
detach: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
// Argv vector — no shell, so `task_id` is inert as data. Stdin, stdout and
|
||||
// stderr are all left unattached; the exec is started detached.
|
||||
let spec = ExecSpec::new(vec![
|
||||
"triple-c-scheduler".to_string(),
|
||||
"run".to_string(),
|
||||
"--id".to_string(),
|
||||
task_id.clone(),
|
||||
])
|
||||
.env(home_env())
|
||||
.no_stdin();
|
||||
let exec_id = target
|
||||
.exec_detached(spec)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start task: {}", e))?;
|
||||
|
||||
@@ -1348,7 +1296,7 @@ pub async fn run_scheduled_task_now(
|
||||
"Triggered scheduler task {} in project {} (detached exec {})",
|
||||
task_id,
|
||||
project_id,
|
||||
exec.id
|
||||
exec_id
|
||||
);
|
||||
Ok(format!("Task {} started.", task_id))
|
||||
}
|
||||
@@ -1361,10 +1309,10 @@ pub async fn remove_scheduled_task(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_task_id(&task_id)?;
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
|
||||
let output = run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["remove".to_string(), "--id".to_string(), task_id],
|
||||
)
|
||||
.await?;
|
||||
@@ -1377,9 +1325,9 @@ pub async fn clear_scheduler_notifications(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = require_running_container(&project_id, &state).await?;
|
||||
let target = require_running_container(&project_id, &state).await?;
|
||||
run_scheduler(
|
||||
&container_id,
|
||||
&target,
|
||||
vec!["notifications".to_string(), "--clear".to_string()],
|
||||
)
|
||||
.await?;
|
||||
|
||||
@@ -7,7 +7,7 @@ use std::collections::{BTreeMap, HashSet};
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::docker::container::is_container_running;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::marketplace::{self as mk, auth, diff, gh_login, git, MarketplaceManager};
|
||||
use crate::models::marketplace::{
|
||||
is_valid_commit, is_valid_item_key, AccountMethod, FileDiff, InstallScope, ItemUpdate,
|
||||
@@ -944,8 +944,8 @@ pub async fn apply_marketplace_now(
|
||||
};
|
||||
let mut results = Vec::new();
|
||||
for p in projects {
|
||||
let running = match &p.container_id {
|
||||
Some(cid) => is_container_running(cid).await.unwrap_or(false),
|
||||
let running = match ContainerTarget::for_project(&p) {
|
||||
Some(target) => target.is_running().await.unwrap_or(false),
|
||||
None => false,
|
||||
};
|
||||
if !running {
|
||||
@@ -1051,13 +1051,13 @@ pub async fn start_marketplace_gh_container_login(
|
||||
return Err("Signing in through a container needs a host name without a port.".to_string());
|
||||
}
|
||||
let project = find_project(&state, &project_id)?;
|
||||
let container_id = project.container_id.clone().ok_or_else(|| {
|
||||
let target = ContainerTarget::for_project(&project).ok_or_else(|| {
|
||||
format!(
|
||||
"\"{}\" has no container yet. Start it, then try again.",
|
||||
project.name
|
||||
)
|
||||
})?;
|
||||
if !is_container_running(&container_id).await.unwrap_or(false) {
|
||||
if !target.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"\"{}\" is not running. Start it, then try again.",
|
||||
project.name
|
||||
@@ -1070,7 +1070,7 @@ pub async fn start_marketplace_gh_container_login(
|
||||
}
|
||||
let account_id = uuid::Uuid::new_v4().to_string();
|
||||
let result =
|
||||
gh_login::run_gh_container_login(&app_handle, &account_id, &container_id, &host, rx).await;
|
||||
gh_login::run_gh_container_login(&app_handle, &account_id, &target, &host, rx).await;
|
||||
// `rx` is gone now, so this frees our slot and never a newer login's.
|
||||
state.marketplace.release_gh_login().await;
|
||||
let token = result?;
|
||||
|
||||
@@ -1860,7 +1860,7 @@ async fn stage_payload(
|
||||
project_id: &str,
|
||||
verbatim: &[String],
|
||||
) -> Result<String, String> {
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::ExecChunk;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
@@ -1913,14 +1913,14 @@ async fn stage_payload(
|
||||
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdOut(message)) => {
|
||||
if let Err(e) = writer.write_all(&message).await {
|
||||
stream_err = Some(format!("Failed to write the staging file: {}", e));
|
||||
break;
|
||||
}
|
||||
total += message.len() as u64;
|
||||
}
|
||||
Ok(LogOutput::StdErr { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) => {
|
||||
stderr_text.push_str(&String::from_utf8_lossy(&message));
|
||||
}
|
||||
Ok(_) => {}
|
||||
@@ -1979,7 +1979,7 @@ async fn stage_payload(
|
||||
/// anything already present in the base, so collisions should be rare either
|
||||
/// way.)
|
||||
async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), String> {
|
||||
use bollard::container::LogOutput;
|
||||
use crate::host::ExecChunk;
|
||||
use futures_util::StreamExt;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
|
||||
@@ -2007,7 +2007,7 @@ async fn restore_payload(container_id: &str, host_path: &str) -> Result<(), Stri
|
||||
let mut text = String::new();
|
||||
while let Some(msg) = output.next().await {
|
||||
match msg {
|
||||
Ok(LogOutput::StdErr { message }) | Ok(LogOutput::StdOut { message }) => {
|
||||
Ok(ExecChunk::StdErr(message)) | Ok(ExecChunk::StdOut(message)) => {
|
||||
text.push_str(&String::from_utf8_lossy(&message))
|
||||
}
|
||||
Ok(_) => {}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
use tauri::{AppHandle, Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project};
|
||||
use crate::AppState;
|
||||
|
||||
@@ -11,12 +12,18 @@ use crate::AppState;
|
||||
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js
|
||||
/// WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||
fn build_terminal_cmd(
|
||||
project: &Project,
|
||||
state: &AppState,
|
||||
session_name: Option<&str>,
|
||||
resume_session_id: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let settings = state.settings_store.get();
|
||||
build_claude_terminal_cmd(
|
||||
project,
|
||||
settings.global_aws.aws_profile.as_deref(),
|
||||
session_name,
|
||||
resume_session_id,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,10 +60,16 @@ fn shell_quote_arg(arg: &str) -> String {
|
||||
|
||||
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
||||
/// profile rather than the whole [`AppState`].
|
||||
///
|
||||
/// `resume_session_id` adds `--resume <id>` (flag spelling checked against the
|
||||
/// CLI in the container image) and drops `-n`: the resumed conversation keeps
|
||||
/// the name it already has, and the tab label the caller passes as
|
||||
/// `session_name` — often just "resume" — must not overwrite it.
|
||||
fn build_claude_terminal_cmd(
|
||||
project: &Project,
|
||||
global_aws_profile: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
resume_session_id: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
@@ -69,12 +82,15 @@ fn build_claude_terminal_cmd(
|
||||
|
||||
// The args are interpolated into a shell script string, so single-quote
|
||||
// each one.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||
.unwrap_or_default();
|
||||
let session_flags = match resume_session_id {
|
||||
Some(id) => format!(" --resume{}", shell_quote_arg(id)),
|
||||
None => session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, session_flags);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
return vec![
|
||||
@@ -135,6 +151,7 @@ pub async fn open_terminal_session(
|
||||
session_id: String,
|
||||
session_type: Option<String>,
|
||||
session_name: Option<String>,
|
||||
resume_session_id: Option<String>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@@ -143,14 +160,24 @@ pub async fn open_terminal_session(
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let target = ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
if let Some(id) = resume_session_id.as_deref() {
|
||||
crate::commands::inspect_commands::validate_session_id(id)?;
|
||||
}
|
||||
|
||||
let cmd = match session_type.as_deref() {
|
||||
Some("bash") if resume_session_id.is_some() => {
|
||||
return Err("Only a Claude session can resume a conversation".to_string());
|
||||
}
|
||||
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
|
||||
_ => build_terminal_cmd(
|
||||
&project,
|
||||
&state,
|
||||
session_name.as_deref(),
|
||||
resume_session_id.as_deref(),
|
||||
),
|
||||
};
|
||||
|
||||
let output_event = format!("terminal-output-{}", session_id);
|
||||
@@ -161,7 +188,7 @@ pub async fn open_terminal_session(
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(
|
||||
container_id,
|
||||
&target,
|
||||
&session_id,
|
||||
cmd,
|
||||
move |data| {
|
||||
@@ -212,7 +239,7 @@ pub async fn paste_image_to_terminal(
|
||||
image_data: Vec<u8>,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
let timestamp = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
@@ -222,7 +249,7 @@ pub async fn paste_image_to_terminal(
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.write_file_to_container(&container_id, &file_name, &image_data)
|
||||
.write_file_to_container(&target, &file_name, &image_data)
|
||||
.await
|
||||
}
|
||||
|
||||
@@ -255,7 +282,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
let base = crate::commands::file_commands::host_upload_name(&host_path)?;
|
||||
let host_path = crate::commands::file_commands::resolve_host_read_path(&host_path).await?;
|
||||
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
let meta = tokio::fs::metadata(&host_path)
|
||||
.await
|
||||
@@ -285,7 +312,7 @@ pub async fn upload_host_file_to_terminal(
|
||||
// with the code that does the reading, which re-applies it to the open
|
||||
// descriptor — this check is here only so the refusal reads like a sentence
|
||||
// instead of arriving after a 300 MB read.
|
||||
use crate::docker::exec::MAX_DROP_BYTES;
|
||||
use crate::host::MAX_DROP_BYTES;
|
||||
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 instead.",
|
||||
@@ -298,20 +325,18 @@ pub async fn upload_host_file_to_terminal(
|
||||
|
||||
// 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?;
|
||||
target
|
||||
.exec_oneshot(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,
|
||||
"/tmp",
|
||||
&file_name,
|
||||
)
|
||||
.await
|
||||
target
|
||||
.upload_host_file(&host_path, "/tmp", &file_name)
|
||||
.await
|
||||
}
|
||||
|
||||
#[tauri::command]
|
||||
@@ -319,8 +344,8 @@ pub async fn start_audio_bridge(
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
// Get container_id from the terminal session
|
||||
let container_id = state.exec_manager.get_container_id(&session_id).await?;
|
||||
// Get the container from the terminal session
|
||||
let target = state.exec_manager.get_target(&session_id).await?;
|
||||
|
||||
// Create audio bridge exec session with ID "audio-{session_id}"
|
||||
// The loop handles reconnection when the FIFO reader (fake rec) is killed and restarted
|
||||
@@ -334,7 +359,7 @@ pub async fn start_audio_bridge(
|
||||
state
|
||||
.exec_manager
|
||||
.create_session_with_tty(
|
||||
&container_id,
|
||||
&target,
|
||||
&audio_session_id,
|
||||
cmd,
|
||||
false,
|
||||
@@ -414,7 +439,7 @@ mod tests {
|
||||
/// wrapper rather than a bare `claude` argv.
|
||||
#[test]
|
||||
fn build_terminal_cmd_updates_before_launching_claude() {
|
||||
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
||||
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None, None);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
assert_eq!(cmd[1], "-c");
|
||||
@@ -444,6 +469,7 @@ mod tests {
|
||||
&project("anthropic", serde_json::Value::Null),
|
||||
None,
|
||||
Some("Bob's tab; rm -rf /"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -459,7 +485,7 @@ mod tests {
|
||||
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.full_permissions = true;
|
||||
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
||||
let cmd = build_claude_terminal_cmd(&p, None, Some(""), None);
|
||||
|
||||
assert!(
|
||||
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
||||
@@ -474,7 +500,7 @@ mod tests {
|
||||
fn build_terminal_cmd_passes_auto_permission_mode() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
|
||||
let cmd = build_claude_terminal_cmd(&p, None, None);
|
||||
let cmd = build_claude_terminal_cmd(&p, None, None, None);
|
||||
|
||||
assert!(
|
||||
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
|
||||
@@ -497,6 +523,7 @@ mod tests {
|
||||
})),
|
||||
None,
|
||||
Some("it's fine"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
@@ -512,6 +539,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Resume goes through the same launch as a new session — prelude,
|
||||
/// permission flags — and passes the id instead of the tab label, so a
|
||||
/// label of "resume" does not rename the conversation.
|
||||
#[test]
|
||||
fn build_terminal_cmd_resumes_without_renaming() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.full_permissions = true;
|
||||
let cmd = build_claude_terminal_cmd(
|
||||
&p,
|
||||
None,
|
||||
Some("resume"),
|
||||
Some("e13d312d-2f38-4cf6-b0e0-1db60208a74c"),
|
||||
);
|
||||
|
||||
assert!(cmd[2].contains(UPDATE_PRELUDE), "got: {}", cmd[2]);
|
||||
assert!(
|
||||
cmd[2].contains(
|
||||
"exec claude '--dangerously-skip-permissions' --resume 'e13d312d-2f38-4cf6-b0e0-1db60208a74c'\n"
|
||||
),
|
||||
"got: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(!cmd[2].contains(" -n "), "resume must not rename: {}", cmd[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||
use crate::commands::file_commands::host_upload_name;
|
||||
|
||||
@@ -0,0 +1,396 @@
|
||||
//! Notices a project container that stopped without the app asking.
|
||||
//!
|
||||
//! Until this existed the app only ever learned a container's state from its
|
||||
//! own commands and from `reconcile_project_statuses`, which the frontend runs
|
||||
//! once, when Docker first answers. A container that died underneath a running
|
||||
//! app — the entrypoint's keepalive killed, an OOM, `docker stop` from a shell,
|
||||
//! a daemon restart — left its project at Running indefinitely: every terminal
|
||||
//! disconnected, while the sidebar, Start/Stop and Project Home all still
|
||||
//! described a live container.
|
||||
//!
|
||||
//! One subscription to Docker's event stream, filtered daemon-side to container
|
||||
//! `die` events, so an idle daemon costs nothing and the app hears about a death
|
||||
//! as it happens. No polling loop. Each `die` naming a project's container is
|
||||
//! confirmed with one inspect before anything changes (an event can be
|
||||
//! replayed, and a restart can reuse the id), and the flip itself is
|
||||
//! [`ProjectsStore::mark_exited`]'s compare-and-set, which never touches a
|
||||
//! project a command owns and refuses if the store changed while the inspect
|
||||
//! was in flight. The frontend hears it on [`CONTAINER_EXITED_EVENT`].
|
||||
//!
|
||||
//! The stream drops whenever the daemon does. Each (re)subscription asks for
|
||||
//! events since just before it, and first re-checks every Running project once —
|
||||
//! the only window that replay cannot cover is a daemon restart, which forgets
|
||||
//! its event history, and that is exactly when containers go away.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use bollard::errors::Error as DockerError;
|
||||
use bollard::models::{EventMessage, EventMessageTypeEnum};
|
||||
use bollard::system::EventsOptions;
|
||||
use bollard::Docker;
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::models::{Project, ProjectStatus};
|
||||
use crate::storage::projects_store::{ExitMark, ProjectsStore};
|
||||
|
||||
/// Emitted as a [`ContainerExited`] whenever this module flips a project to
|
||||
/// Stopped.
|
||||
pub const CONTAINER_EXITED_EVENT: &str = "project-container-exited";
|
||||
|
||||
const RETRY_MIN: Duration = Duration::from_secs(5);
|
||||
const RETRY_MAX: Duration = Duration::from_secs(60);
|
||||
/// A stream that stayed up this long (or delivered anything) was a working
|
||||
/// connection, so its loss starts the backoff over. Anything shorter — the
|
||||
/// daemon answers ping but the events call fails — keeps backing off, or it
|
||||
/// would re-sweep and re-log every five seconds forever.
|
||||
const STABLE_AFTER: Duration = Duration::from_secs(60);
|
||||
/// How often `settle` re-confirms when the store keeps changing under it.
|
||||
const SETTLE_ATTEMPTS: usize = 3;
|
||||
|
||||
/// How the app found out, which decides whether it is news to the user.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize)]
|
||||
#[serde(rename_all = "snake_case")]
|
||||
pub enum ExitReason {
|
||||
/// The container died while this app was running and watching — any
|
||||
/// sessions it had were just cut off.
|
||||
Exited,
|
||||
/// Found already stopped on the first look of this run: it went down
|
||||
/// while the app was closed (a host reboot, typically). Nothing the user
|
||||
/// can see was interrupted, so it is not news.
|
||||
FoundStopped,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize)]
|
||||
pub struct ContainerExited<'a> {
|
||||
pub project: &'a Project,
|
||||
pub reason: ExitReason,
|
||||
}
|
||||
|
||||
/// The container id a Docker event reports as having died, if it is one.
|
||||
pub(crate) fn died_container(msg: &EventMessage) -> Option<&str> {
|
||||
if msg.typ != Some(EventMessageTypeEnum::CONTAINER) || msg.action.as_deref() != Some("die") {
|
||||
return None;
|
||||
}
|
||||
msg.actor.as_ref()?.id.as_deref().filter(|id| !id.is_empty())
|
||||
}
|
||||
|
||||
/// `true` only when Docker positively says the container is not running: it
|
||||
/// inspects as stopped, or it no longer exists. Any other failure is "don't
|
||||
/// know" — `docker::is_container_running` maps every error to `false`, which
|
||||
/// is right for its callers and wrong here, where it would mark every project
|
||||
/// Stopped the moment the daemon hiccups.
|
||||
async fn has_exited(docker: &Docker, container_id: &str) -> bool {
|
||||
match docker.inspect_container(container_id, None).await {
|
||||
Ok(info) => !info.state.and_then(|s| s.running).unwrap_or(false),
|
||||
Err(DockerError::DockerResponseServerError { status_code: 404, .. }) => true,
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
fn project_busy(project_id: &str) -> bool {
|
||||
crate::project_lock::held(project_id).is_some()
|
||||
}
|
||||
|
||||
/// Confirm `container_id` really stopped and, if a project was Running on it,
|
||||
/// flip that project. Returns what changed.
|
||||
async fn settle(docker: &Docker, store: &ProjectsStore, container_id: &str) -> Option<Project> {
|
||||
for _ in 0..SETTLE_ATTEMPTS {
|
||||
// Cheap and allocation-free: most containers that die are not ours
|
||||
// (probe and scratch containers, other software on the daemon), and
|
||||
// those cost no inspect at all.
|
||||
let seen = store.running_on(container_id)?;
|
||||
if !has_exited(docker, container_id).await {
|
||||
return None;
|
||||
}
|
||||
match store.mark_exited(container_id, &seen, project_busy) {
|
||||
ExitMark::Flipped(p) => return Some(*p),
|
||||
ExitMark::NotApplicable => return None,
|
||||
// The store moved while Docker was answering — possibly a stop and
|
||||
// start of this very container. The inspect is stale; ask again.
|
||||
ExitMark::Changed => continue,
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// Re-check every Running project once. Used on each (re)subscription.
|
||||
async fn sweep(docker: &Docker, store: &ProjectsStore) -> Vec<Project> {
|
||||
let mut changed = Vec::new();
|
||||
for p in store.list() {
|
||||
if p.status != ProjectStatus::Running {
|
||||
continue;
|
||||
}
|
||||
let Some(cid) = p.container_id.as_deref() else { continue };
|
||||
if let Some(c) = settle(docker, store, cid).await {
|
||||
changed.push(c);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
fn die_filter() -> HashMap<String, Vec<String>> {
|
||||
HashMap::from([
|
||||
("type".to_string(), vec!["container".to_string()]),
|
||||
("event".to_string(), vec!["die".to_string()]),
|
||||
])
|
||||
}
|
||||
|
||||
/// The wait before connection attempt `n + 1`, after `n` consecutive
|
||||
/// unproductive ones: 5 s, 10 s, 20 s, 40 s, then 60 s for good.
|
||||
fn retry_delay(consecutive_failures: u32) -> Duration {
|
||||
let doublings = consecutive_failures.saturating_sub(1).min(8);
|
||||
(RETRY_MIN * 2u32.pow(doublings)).min(RETRY_MAX)
|
||||
}
|
||||
|
||||
/// `fut`, unless the app starts shutting down first. Every Docker call in here
|
||||
/// goes through this: a wedged daemon must not hold shutdown for its full
|
||||
/// budget.
|
||||
async fn unless_cancelled<T>(
|
||||
cancel: &mut watch::Receiver<bool>,
|
||||
fut: impl Future<Output = T>,
|
||||
) -> Option<T> {
|
||||
if *cancel.borrow() {
|
||||
return None;
|
||||
}
|
||||
tokio::select! {
|
||||
// `Err` means the sender is gone, i.e. so is the app.
|
||||
_ = cancel.changed() => None,
|
||||
v = fut => Some(v),
|
||||
}
|
||||
}
|
||||
|
||||
/// One connection: ping, sweep, then follow the event stream until it ends.
|
||||
/// `None` when cancelled; otherwise whether the connection was a working one
|
||||
/// (see [`STABLE_AFTER`]).
|
||||
async fn watch_once(
|
||||
store: &ProjectsStore,
|
||||
cancel: &mut watch::Receiver<bool>,
|
||||
sweep_reason: ExitReason,
|
||||
on_change: &impl Fn(&Project, ExitReason),
|
||||
) -> Option<bool> {
|
||||
let Ok(docker) = crate::docker::get_docker() else { return Some(false) };
|
||||
if unless_cancelled(cancel, docker.ping()).await?.is_err() {
|
||||
return Some(false);
|
||||
}
|
||||
// A second's overlap: the daemon replays from `since`, so a death during
|
||||
// the sweep below is still delivered. Duplicates are harmless — `settle`
|
||||
// confirms with an inspect every time.
|
||||
let since = (chrono::Utc::now().timestamp() - 1).to_string();
|
||||
for p in unless_cancelled(cancel, sweep(&docker, store)).await? {
|
||||
on_change(&p, sweep_reason);
|
||||
}
|
||||
let mut events = docker.events(Some(EventsOptions::<String> {
|
||||
since: Some(since),
|
||||
until: None,
|
||||
filters: die_filter(),
|
||||
}));
|
||||
let connected = Instant::now();
|
||||
let mut delivered = false;
|
||||
loop {
|
||||
match unless_cancelled(cancel, events.next()).await? {
|
||||
Some(Ok(msg)) => {
|
||||
delivered = true;
|
||||
if let Some(cid) = died_container(&msg) {
|
||||
if let Some(p) = unless_cancelled(cancel, settle(&docker, store, cid)).await? {
|
||||
on_change(&p, ExitReason::Exited);
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Err(e)) => {
|
||||
log::info!("Container watch: event stream ended ({e}) — reconnecting");
|
||||
break;
|
||||
}
|
||||
None => {
|
||||
log::info!("Container watch: event stream closed — reconnecting");
|
||||
break;
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(delivered || connected.elapsed() >= STABLE_AFTER)
|
||||
}
|
||||
|
||||
/// Watch until `cancel` flips, calling `on_change` for every project whose
|
||||
/// container is found to have stopped on its own. Separate from [`spawn`] so a
|
||||
/// test can drive it without a Tauri app.
|
||||
pub(crate) async fn run(
|
||||
store: Arc<ProjectsStore>,
|
||||
mut cancel: watch::Receiver<bool>,
|
||||
on_change: impl Fn(&Project, ExitReason),
|
||||
) {
|
||||
// Only the first look of the run finds containers that stopped while the
|
||||
// app was closed. A sweep after a reconnect finds ones that died while we
|
||||
// were running — the daemon restarting is the usual cause — and those
|
||||
// cut off live sessions just as a `die` event would.
|
||||
let mut sweep_reason = ExitReason::FoundStopped;
|
||||
let mut failures: u32 = 0;
|
||||
loop {
|
||||
let Some(healthy) = watch_once(&store, &mut cancel, sweep_reason, &on_change).await else {
|
||||
return;
|
||||
};
|
||||
sweep_reason = ExitReason::Exited;
|
||||
failures = if healthy { 1 } else { failures + 1 };
|
||||
unless_cancelled(&mut cancel, tokio::time::sleep(retry_delay(failures))).await;
|
||||
if *cancel.borrow() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start the watcher for the life of the app.
|
||||
pub fn spawn(
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
cancel: watch::Receiver<bool>,
|
||||
) -> tauri::async_runtime::JoinHandle<()> {
|
||||
tauri::async_runtime::spawn(async move {
|
||||
run(store, cancel, |p, reason| {
|
||||
log::warn!(
|
||||
"Project '{}' ({}): its container stopped without the app asking ({:?}) — marking Stopped",
|
||||
p.name,
|
||||
p.id,
|
||||
reason
|
||||
);
|
||||
let _ = app.emit(CONTAINER_EXITED_EVENT, ContainerExited { project: p, reason });
|
||||
})
|
||||
.await;
|
||||
})
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use bollard::models::EventActor;
|
||||
|
||||
fn event(typ: EventMessageTypeEnum, action: &str, id: Option<&str>) -> EventMessage {
|
||||
EventMessage {
|
||||
typ: Some(typ),
|
||||
action: Some(action.to_string()),
|
||||
actor: Some(EventActor { id: id.map(str::to_string), attributes: None }),
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_a_container_die_names_a_container() {
|
||||
let die = event(EventMessageTypeEnum::CONTAINER, "die", Some("abc"));
|
||||
assert_eq!(died_container(&die), Some("abc"));
|
||||
|
||||
for (typ, action, id) in [
|
||||
(EventMessageTypeEnum::CONTAINER, "stop", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "start", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "exec_die", Some("abc")),
|
||||
(EventMessageTypeEnum::NETWORK, "die", Some("abc")),
|
||||
(EventMessageTypeEnum::CONTAINER, "die", None),
|
||||
(EventMessageTypeEnum::CONTAINER, "die", Some("")),
|
||||
] {
|
||||
assert_eq!(died_container(&event(typ, action, id)), None, "{typ:?} {action} {id:?}");
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_subscription_is_filtered_to_container_deaths_daemon_side() {
|
||||
let f = die_filter();
|
||||
assert_eq!(f.get("type"), Some(&vec!["container".to_string()]));
|
||||
assert_eq!(f.get("event"), Some(&vec!["die".to_string()]));
|
||||
assert_eq!(f.len(), 2);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn retries_back_off_to_a_minute_and_stay_there() {
|
||||
let secs: Vec<u64> = (1..=7).map(|n| retry_delay(n).as_secs()).collect();
|
||||
assert_eq!(secs, vec![5, 10, 20, 40, 60, 60, 60]);
|
||||
assert_eq!(retry_delay(0), RETRY_MIN);
|
||||
assert_eq!(retry_delay(u32::MAX), RETRY_MAX);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_event_says_why_so_the_ui_can_tell_news_from_housekeeping() {
|
||||
let p = Project::new("demo".to_string(), Vec::new());
|
||||
let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::Exited }).unwrap();
|
||||
assert_eq!(v["reason"], "exited");
|
||||
assert_eq!(v["project"]["name"], "demo");
|
||||
let v = serde_json::to_value(ContainerExited { project: &p, reason: ExitReason::FoundStopped })
|
||||
.unwrap();
|
||||
assert_eq!(v["reason"], "found_stopped");
|
||||
assert_eq!(CONTAINER_EXITED_EVENT, "project-container-exited");
|
||||
}
|
||||
|
||||
/// The shutdown path: a Docker call that never answers (a wedged daemon)
|
||||
/// must give way to cancellation at once, not hold teardown.
|
||||
#[tokio::test]
|
||||
async fn a_hung_docker_call_yields_to_cancellation() {
|
||||
let (tx, mut rx) = watch::channel(false);
|
||||
let canceller = tokio::spawn(async move {
|
||||
tokio::time::sleep(Duration::from_millis(20)).await;
|
||||
let _ = tx.send(true);
|
||||
});
|
||||
let hung = std::future::pending::<()>();
|
||||
let out = tokio::time::timeout(Duration::from_secs(2), unless_cancelled(&mut rx, hung)).await;
|
||||
assert_eq!(out.expect("returned promptly"), None);
|
||||
canceller.await.unwrap();
|
||||
// Already cancelled: it does not even start the call.
|
||||
assert_eq!(unless_cancelled(&mut rx, async { 1 }).await, None);
|
||||
}
|
||||
|
||||
/// End to end against a real daemon: a Running project whose container is
|
||||
/// killed from outside the app is flipped to Stopped and reported as a live
|
||||
/// exit; one found already stopped on the first look is reported as such;
|
||||
/// and one whose container is still up is left alone.
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates and removes throwaway containers"]
|
||||
async fn a_container_killed_outside_the_app_flips_its_project_to_stopped() {
|
||||
use std::process::Command;
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = Command::new("docker").args(args).output().expect("docker cli");
|
||||
assert!(out.status.success(), "docker {:?}: {}", args, String::from_utf8_lossy(&out.stderr));
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
let tag = uuid::Uuid::new_v4().simple().to_string();
|
||||
let doomed = docker_cli(&["run", "-d", "--name", &format!("tc-watch-a-{tag}"), "alpine:3", "sleep", "300"]);
|
||||
let alive = docker_cli(&["run", "-d", "--name", &format!("tc-watch-b-{tag}"), "alpine:3", "sleep", "300"]);
|
||||
let already = docker_cli(&["create", "--name", &format!("tc-watch-c-{tag}"), "alpine:3", "true"]);
|
||||
|
||||
let dir = std::env::temp_dir().join(format!("triple-c-watch-{tag}"));
|
||||
std::fs::create_dir_all(&dir).unwrap();
|
||||
let running = |name: &str, cid: &str| {
|
||||
let mut p = Project::new(name.to_string(), Vec::new());
|
||||
p.container_id = Some(cid.to_string());
|
||||
p.status = ProjectStatus::Running;
|
||||
p
|
||||
};
|
||||
let (a, b, c) = (running("a", &doomed), running("b", &alive), running("c", &already));
|
||||
let (a_id, b_id, c_id) = (a.id.clone(), b.id.clone(), c.id.clone());
|
||||
let store = Arc::new(ProjectsStore::over_file_for_test(dir.join("projects.json"), vec![a, b, c]));
|
||||
|
||||
let (cancel_tx, cancel_rx) = watch::channel(false);
|
||||
let (seen_tx, mut seen_rx) = tokio::sync::mpsc::unbounded_channel::<(String, ExitReason)>();
|
||||
let watcher = tokio::spawn(run(store.clone(), cancel_rx, move |p, why| {
|
||||
let _ = seen_tx.send((p.id.clone(), why));
|
||||
}));
|
||||
let first = tokio::time::timeout(Duration::from_secs(10), seen_rx.recv()).await;
|
||||
// Subscribed by now (the sweep precedes it, and `since` overlaps);
|
||||
// kill from "outside".
|
||||
docker_cli(&["kill", &doomed]);
|
||||
let second = tokio::time::timeout(Duration::from_secs(15), seen_rx.recv()).await;
|
||||
|
||||
let t0 = Instant::now();
|
||||
let _ = cancel_tx.send(true);
|
||||
let _ = watcher.await;
|
||||
let shutdown = t0.elapsed();
|
||||
let statuses: Vec<ProjectStatus> =
|
||||
[&a_id, &b_id, &c_id].iter().map(|id| store.get(id).unwrap().status).collect();
|
||||
docker_cli(&["rm", "-f", &doomed, &alive, &already]);
|
||||
std::fs::remove_dir_all(&dir).ok();
|
||||
|
||||
assert_eq!(first.expect("sweep within 10s"), Some((c_id, ExitReason::FoundStopped)));
|
||||
assert_eq!(second.expect("die within 15s"), Some((a_id, ExitReason::Exited)));
|
||||
assert_eq!(statuses, vec![ProjectStatus::Stopped, ProjectStatus::Running, ProjectStatus::Stopped]);
|
||||
assert!(shutdown < Duration::from_secs(1), "stopped watching in {shutdown:?}");
|
||||
}
|
||||
}
|
||||
@@ -124,6 +124,16 @@ Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it
|
||||
on every container start. To turn sandbox off, stop the container in
|
||||
Triple-C, flip the "Sandbox mode" switch off, then start the container."#;
|
||||
|
||||
/// The Triple-C terminal opens a printed path on click (Shift/Option+click while
|
||||
/// a program holds the mouse — `opensOnClick` in TerminalView) by looking for it
|
||||
/// in the project, then `~` and `/tmp`. A relative name for a file elsewhere
|
||||
/// (Claude Code's scratchpad under `/tmp/claude-<uid>/...` above all) cannot
|
||||
/// be found, so ask for the whole path. Part of the instructions fingerprint,
|
||||
/// so adding or changing it recreates each container once on its next start.
|
||||
const FILE_LINK_INSTRUCTIONS: &str = r#"## Referring to files
|
||||
|
||||
The user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened."#;
|
||||
|
||||
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
|
||||
/// instructions, appending port mapping docs, and appending scheduler docs.
|
||||
/// Used by both create_container() and container_needs_recreation() to ensure
|
||||
@@ -163,6 +173,11 @@ fn build_claude_instructions(
|
||||
None => SCHEDULER_INSTRUCTIONS.to_string(),
|
||||
});
|
||||
|
||||
combined = Some(match combined {
|
||||
Some(existing) => format!("{}\n\n{}", existing, FILE_LINK_INSTRUCTIONS),
|
||||
None => FILE_LINK_INSTRUCTIONS.to_string(),
|
||||
});
|
||||
|
||||
if sandbox_enabled {
|
||||
combined = Some(match combined {
|
||||
Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS),
|
||||
@@ -4137,6 +4152,31 @@ mod tests {
|
||||
const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL;
|
||||
const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL;
|
||||
|
||||
/// The terminal can only open a file it can find: a bare name is looked
|
||||
/// for in the project, home and /tmp, but a long scratchpad path printed
|
||||
/// as `scratchpad/notes.md` is nowhere. Every container gets the ask to
|
||||
/// print the full path, whatever else is configured.
|
||||
#[test]
|
||||
fn every_container_is_asked_to_print_full_paths_for_files_outside_the_project() {
|
||||
for (global, project, sandbox) in [(None, None, false), (Some("g"), Some("p"), true)] {
|
||||
let s = build_claude_instructions(global, project, &[], false, sandbox).unwrap();
|
||||
assert!(s.contains(FILE_LINK_INSTRUCTIONS), "{:?}", (global, project, sandbox));
|
||||
}
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("full absolute path"));
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("/tmp"));
|
||||
}
|
||||
|
||||
/// The gesture is TerminalView's `opensOnClick`: a plain click, unless a
|
||||
/// program holds the mouse — then xterm's force-selection modifier, which
|
||||
/// is Shift except on macOS, where it is Option. Naming only Shift sent Mac
|
||||
/// users to a key that does nothing.
|
||||
#[test]
|
||||
fn the_file_link_instructions_name_the_gesture_for_every_platform() {
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("can click a file path"));
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("Shift+click"));
|
||||
assert!(FILE_LINK_INSTRUCTIONS.contains("Option+click on macOS"));
|
||||
}
|
||||
|
||||
fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> {
|
||||
compute_model_aliases(model, haiku)
|
||||
}
|
||||
|
||||
+133
-287
@@ -1,58 +1,44 @@
|
||||
use bollard::container::{LogOutput, UploadToContainerOptions};
|
||||
use bollard::exec::{CreateExecOptions, ResizeExecOptions, StartExecResults};
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
use futures_util::StreamExt;
|
||||
|
||||
use super::client::get_docker;
|
||||
use crate::host::{ExecChunk, ExecSpec};
|
||||
|
||||
/// 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>>,
|
||||
}
|
||||
// `AttachedExec` lives in `host` now (host-neutral output chunks); this
|
||||
// module's job is to produce one from Docker. Its consumers are
|
||||
// `host::sessions::ExecSessionManager` (terminals, audio bridge) and the auth
|
||||
// bridge's per-connection `socat` tunnels, both through `host::ContainerTarget`.
|
||||
// With `tty = false` Docker demultiplexes stdout from stderr, which the auth
|
||||
// bridge relies on: `socat`'s diagnostics must not be spliced into the proxied
|
||||
// byte stream.
|
||||
pub use crate::host::AttachedExec;
|
||||
|
||||
/// 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> {
|
||||
create_attached_exec_as(container_id, cmd, tty, "claude", "/workspace").await
|
||||
}
|
||||
|
||||
/// [`create_attached_exec`] with the user and working directory spelled out.
|
||||
/// An attached exec with the user and working directory spelled out.
|
||||
///
|
||||
/// Only base-image migration needs this: replaying `apt` and unpacking a
|
||||
/// payload tar at `/` have to run as **root**, and every other caller wants the
|
||||
/// `claude` / `/workspace` defaults that [`create_attached_exec`] supplies. It
|
||||
/// stays the single place an attached exec is opened.
|
||||
/// Only base-image migration calls this directly: replaying `apt` and unpacking
|
||||
/// a payload tar at `/` have to run as **root**. Everything else goes through
|
||||
/// `host::ContainerTarget::attach`.
|
||||
pub async fn create_attached_exec_as(
|
||||
container_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
user: &str,
|
||||
working_dir: &str,
|
||||
) -> Result<AttachedExec, String> {
|
||||
let spec = ExecSpec::new(cmd)
|
||||
.tty(tty)
|
||||
.user(user)
|
||||
.working_dir(Some(working_dir));
|
||||
create_attached_exec_spec(container_id, &spec).await
|
||||
}
|
||||
|
||||
/// The single place an attached exec is opened on Docker. Everything else —
|
||||
/// [`create_attached_exec_as`] and `host::ContainerTarget::attach` — is a thin
|
||||
/// wrapper over this.
|
||||
pub async fn create_attached_exec_spec(
|
||||
container_id: &str,
|
||||
spec: &ExecSpec,
|
||||
) -> Result<AttachedExec, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
@@ -60,13 +46,14 @@ pub async fn create_attached_exec_as(
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdin: Some(true),
|
||||
attach_stdin: Some(spec.attach_stdin),
|
||||
attach_stdout: Some(true),
|
||||
attach_stderr: Some(true),
|
||||
tty: Some(tty),
|
||||
cmd: Some(cmd),
|
||||
user: Some(user.to_string()),
|
||||
working_dir: Some(working_dir.to_string()),
|
||||
tty: Some(spec.tty),
|
||||
cmd: Some(spec.cmd.clone()),
|
||||
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
|
||||
user: Some(spec.user.clone()),
|
||||
working_dir: spec.working_dir.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
@@ -82,244 +69,87 @@ pub async fn create_attached_exec_as(
|
||||
{
|
||||
StartExecResults::Attached { output, input } => Ok(AttachedExec {
|
||||
exec_id,
|
||||
output,
|
||||
output: Box::pin(output.map(|r| {
|
||||
r.map(log_output_to_chunk)
|
||||
.map_err(|e| e.to_string())
|
||||
})),
|
||||
input,
|
||||
}),
|
||||
StartExecResults::Detached => Err("Exec started in detached mode".to_string()),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSession {
|
||||
pub exec_id: String,
|
||||
pub container_id: String,
|
||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl ExecSession {
|
||||
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
self.input_tx
|
||||
.send(data)
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
&self.exec_id,
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.try_send(());
|
||||
/// bollard's `LogOutput` → the host-neutral [`ExecChunk`]. `Console` is what
|
||||
/// Docker calls a TTY stream; it has no stderr, so it is stdout.
|
||||
pub fn log_output_to_chunk(o: LogOutput) -> ExecChunk {
|
||||
match o {
|
||||
LogOutput::StdOut { message } | LogOutput::Console { message } => ExecChunk::StdOut(message),
|
||||
LogOutput::StdErr { message } => ExecChunk::StdErr(message),
|
||||
LogOutput::StdIn { message } => ExecChunk::Other(message),
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSessionManager {
|
||||
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
|
||||
/// Start a command with nothing attached and return its exec id without
|
||||
/// waiting for it.
|
||||
pub async fn exec_detached(container_id: &str, spec: &ExecSpec) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
let exec = docker
|
||||
.create_exec(
|
||||
container_id,
|
||||
CreateExecOptions {
|
||||
attach_stdout: Some(false),
|
||||
attach_stderr: Some(false),
|
||||
tty: Some(spec.tty),
|
||||
cmd: Some(spec.cmd.clone()),
|
||||
env: if spec.env.is_empty() { None } else { Some(spec.env.clone()) },
|
||||
user: Some(spec.user.clone()),
|
||||
working_dir: spec.working_dir.clone(),
|
||||
..Default::default()
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to create exec: {}", e))?;
|
||||
docker
|
||||
.start_exec(
|
||||
&exec.id,
|
||||
Some(bollard::exec::StartExecOptions {
|
||||
detach: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
Ok(exec.id)
|
||||
}
|
||||
|
||||
impl ExecSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(container_id, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
container_id: &str,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
let AttachedExec {
|
||||
pub async fn resize_exec(exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = create_attached_exec(container_id, cmd, tty).await?;
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
log::info!("Exec session {} shutting down", 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let session = ExecSession {
|
||||
exec_id,
|
||||
container_id: container_id.to_string(),
|
||||
input_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.send_input(data).await
|
||||
}
|
||||
|
||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
// Clone the exec_id under the lock, then drop the lock before the
|
||||
// async Docker API call to avoid holding the mutex across await.
|
||||
let exec_id = {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.exec_id.clone()
|
||||
};
|
||||
let docker = get_docker()?;
|
||||
docker
|
||||
.resize_exec(
|
||||
&exec_id,
|
||||
ResizeExecOptions {
|
||||
width: cols,
|
||||
height: rows,
|
||||
},
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to resize exec: {}", e))
|
||||
}
|
||||
|
||||
pub async fn close_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(session_id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_sessions_for_container(&self, container_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let ids_to_close: Vec<String> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| s.container_id == container_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids_to_close {
|
||||
if let Some(session) = sessions.remove(&id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
for (_, session) in sessions.drain() {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn get_container_id(&self, session_id: &str) -> Result<String, String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
Ok(session.container_id.clone())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_container(
|
||||
&self,
|
||||
container_id: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let (uid, gid) = container_user_ids(container_id).await;
|
||||
let tar_buf = build_single_file_tar(file_name, data, 0o644, uid, gid, now_epoch_secs())?;
|
||||
|
||||
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/{}", file_name))
|
||||
}
|
||||
/// Docker's archive API: `path` as a tar stream. Errors are stringified so the
|
||||
/// stream is host-neutral.
|
||||
pub fn download_archive(container_id: &str, path: &str) -> crate::host::ArchiveStream {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })),
|
||||
};
|
||||
let stream = docker.download_from_container(
|
||||
container_id,
|
||||
Some(bollard::container::DownloadFromContainerOptions {
|
||||
path: path.to_string(),
|
||||
}),
|
||||
);
|
||||
Box::pin(stream.map(|r| r.map_err(|e| e.to_string())))
|
||||
}
|
||||
|
||||
/// Ceiling on one host file packed into a container upload.
|
||||
@@ -444,14 +274,22 @@ pub async fn upload_bytes_to_container(
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<String, String> {
|
||||
upload_bytes_with_ids(container_id, dest_dir, file_name, data, mode, (0, 0)).await
|
||||
}
|
||||
|
||||
/// [`upload_bytes_to_container`] with the tar header's owner set, stamped now.
|
||||
pub async fn upload_bytes_with_ids(
|
||||
container_id: &str,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Root-owned on purpose: migration's `tar -T` list is read back as root,
|
||||
// and the marketplace sync uploads into a `claude`-owned directory it
|
||||
// prepares first, so `claude` can still read and delete the files. The
|
||||
// mtime still gets stamped so the file doesn't read as 1970.
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, 0, 0, now_epoch_secs())?;
|
||||
let tar_buf = build_single_file_tar(file_name, data, mode, uid, gid, now_epoch_secs())?;
|
||||
|
||||
docker
|
||||
.upload_to_container(
|
||||
@@ -521,8 +359,9 @@ pub fn now_epoch_secs() -> u64 {
|
||||
/// container. Falls back to 1000:1000 (the image's build-time ids) if the exec
|
||||
/// fails, which is strictly better than the 0:0 a default tar header carries.
|
||||
pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||
let out = exec_oneshot_limited(
|
||||
let (out, _) = exec_oneshot_limited(
|
||||
container_id,
|
||||
"claude",
|
||||
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
|
||||
256,
|
||||
)
|
||||
@@ -543,9 +382,12 @@ pub async fn container_user_ids(container_id: &str) -> (u64, u64) {
|
||||
/// caller sees a byte, and what it is reading is *container-controlled* — the
|
||||
/// scheduler notifications reader `cat`s up to 50 files with no size cap, and
|
||||
/// the auth bridge reads `/proc/net/tcp` every two seconds. Neither has an
|
||||
/// upstream bound, so this is where the bound goes. Generous enough that no
|
||||
/// legitimate reader (the largest is a package manifest of a full image) comes
|
||||
/// close.
|
||||
/// upstream bound, so this is where the bound goes. It is the default, not a
|
||||
/// promise that every reader fits: the migration manifest of a running
|
||||
/// container did pass it (8.6 MB from a busy `/workspace`), and now runs under
|
||||
/// its own `migration::MANIFEST_OUTPUT_LIMIT` through [`exec_oneshot_limited`].
|
||||
/// A reader that can legitimately grow with the user's files should do the
|
||||
/// same rather than raise this.
|
||||
pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// The auth bridge's per-tick budget. It reads two procfs files whose rows are
|
||||
@@ -586,16 +428,20 @@ pub async fn exec_oneshot(container_id: &str, cmd: Vec<String>) -> Result<String
|
||||
exec_oneshot_env(container_id, cmd, Vec::new()).await
|
||||
}
|
||||
|
||||
/// [`exec_oneshot`] with a caller-chosen output ceiling, for readers whose
|
||||
/// input is fully container-controlled and whose legitimate output is small.
|
||||
/// A one-shot exec as `user` with a caller-chosen output ceiling, returning the
|
||||
/// combined output and the exit code.
|
||||
///
|
||||
/// For readers whose budget is not [`MAX_ONESHOT_OUTPUT`]: smaller where the
|
||||
/// input is container-controlled and the legitimate output tiny (the auth
|
||||
/// bridge's procfs poll, the uid probe), larger where it can genuinely pass
|
||||
/// 8 MiB (the migration manifest, as root).
|
||||
pub async fn exec_oneshot_limited(
|
||||
container_id: &str,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<String, String> {
|
||||
exec_oneshot_inner(container_id, "claude", cmd, Vec::new(), limit)
|
||||
.await
|
||||
.map(|(output, _)| output)
|
||||
) -> Result<(String, i64), String> {
|
||||
exec_oneshot_inner(container_id, user, cmd, Vec::new(), limit).await
|
||||
}
|
||||
|
||||
/// Like `exec_oneshot`, but passes additional environment variables to the exec
|
||||
|
||||
@@ -21,7 +21,7 @@
|
||||
//! `docker inspect`.
|
||||
|
||||
use bollard::container::{
|
||||
Config, CreateContainerOptions, ListContainersOptions, RemoveContainerOptions,
|
||||
Config, CreateContainerOptions, ListContainersOptions,
|
||||
StartContainerOptions, StopContainerOptions, UploadToContainerOptions,
|
||||
};
|
||||
use bollard::image::BuildImageOptions;
|
||||
@@ -45,11 +45,180 @@ const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config";
|
||||
/// Upstream LiteLLM, pinned to an exact release.
|
||||
///
|
||||
/// LiteLLM 1.82.7 and 1.82.8 shipped credential-harvesting malware on PyPI, so
|
||||
/// nothing here may float a tag or resolve `litellm` at build time. v1.96.0 is
|
||||
/// also above the 1.84.0 floor set by the proxy auth-bypass CVEs — see the long
|
||||
/// comment in `gateway-container/Dockerfile`, and keep the two in lockstep.
|
||||
const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.96.0";
|
||||
const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest";
|
||||
/// nothing here may float a tag or resolve `litellm` at build time. v1.106.6 is
|
||||
/// also above the floors set by the proxy auth-bypass, SSRF and salt-key CVEs —
|
||||
/// see the long comment in `gateway-container/Dockerfile`, and keep the two in
|
||||
/// lockstep.
|
||||
const GATEWAY_REGISTRY_REPO: &str = "ghcr.io/berriai/litellm";
|
||||
const GATEWAY_LITELLM_VERSION: &str = "v1.106.6";
|
||||
|
||||
/// The digest [`GATEWAY_REGISTRY_REPO`]:[`GATEWAY_LITELLM_VERSION`] is pinned to — the same one the
|
||||
/// Dockerfile's `FROM` uses. Pulls and lookups go by digest: a tag can be
|
||||
/// re-pushed upstream, a digest cannot.
|
||||
const GATEWAY_REGISTRY_DIGEST: &str =
|
||||
"sha256:37e2dafb088a7723beb998590682957386dfc83ffea1848486f8e0e5cfac0b06";
|
||||
|
||||
fn registry_image_ref() -> String {
|
||||
format!("{}@{}", GATEWAY_REGISTRY_REPO, GATEWAY_REGISTRY_DIGEST)
|
||||
}
|
||||
|
||||
/// Local builds are tagged with the pin plus a hash of the embedded build
|
||||
/// context (`triple-c-gateway:v1.106.6-1a2b3c4d`), never a floating tag. A
|
||||
/// build made by an app version with a different pin *or* a different
|
||||
/// Dockerfile/config then simply doesn't match, instead of quietly winning
|
||||
/// over the current image the way the old `triple-c-gateway:latest` tag did.
|
||||
/// Builds tagged `latest` by earlier app versions are no longer used.
|
||||
const GATEWAY_LOCAL_REPO: &str = "triple-c-gateway";
|
||||
|
||||
/// The LiteLLM release this app is pinned to — the tag of
|
||||
/// [`GATEWAY_REGISTRY_REPO`]:[`GATEWAY_LITELLM_VERSION`], e.g. `v1.106.6`.
|
||||
fn pinned_litellm_version() -> &'static str {
|
||||
GATEWAY_LITELLM_VERSION
|
||||
}
|
||||
|
||||
fn local_image_ref() -> String {
|
||||
static REF: OnceLock<String> = OnceLock::new();
|
||||
REF.get_or_init(|| {
|
||||
// config.yaml is hashed whole: a `#` line can sit inside a block
|
||||
// scalar there, and it is rarely edited.
|
||||
let context = sha256_hex(&format!(
|
||||
"{}\0{}",
|
||||
significant_lines(GATEWAY_DOCKERFILE),
|
||||
GATEWAY_DEFAULT_CONFIG
|
||||
));
|
||||
format!(
|
||||
"{}:{}-{}",
|
||||
GATEWAY_LOCAL_REPO,
|
||||
pinned_litellm_version(),
|
||||
&context[..8]
|
||||
)
|
||||
})
|
||||
.clone()
|
||||
}
|
||||
|
||||
/// The Dockerfile minus full-line comments and blank lines, so rewording a
|
||||
/// comment in a later release doesn't make every local build look outdated.
|
||||
/// Parser directives (`# syntax=`, `# escape=`, `# check=`) look like comments
|
||||
/// but change the build, so they are kept.
|
||||
fn significant_lines(text: &str) -> String {
|
||||
text.lines()
|
||||
.map(str::trim_end)
|
||||
.filter(|l| {
|
||||
let t = l.trim_start();
|
||||
if t.is_empty() {
|
||||
return false;
|
||||
}
|
||||
match t.strip_prefix('#') {
|
||||
None => true,
|
||||
Some(rest) => rest
|
||||
.split_once('=')
|
||||
.map(|(key, _)| key.trim())
|
||||
.is_some_and(|key| matches!(key, "syntax" | "escape" | "check")),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n")
|
||||
}
|
||||
|
||||
/// The image the gateway container should run, and its content id. The id goes
|
||||
/// into the container fingerprint so that a newly pulled or rebuilt image
|
||||
/// recreates the container instead of leaving the old one running.
|
||||
#[derive(Debug, Clone, PartialEq)]
|
||||
struct ChosenImage {
|
||||
reference: String,
|
||||
id: String,
|
||||
}
|
||||
|
||||
/// Local build first (same precedence as the STT container), then the pinned
|
||||
/// upstream image. Both references name the pin, so either is current.
|
||||
fn choose_gateway_image(local_id: Option<String>, registry_id: Option<String>) -> Option<ChosenImage> {
|
||||
local_id
|
||||
.map(|id| ChosenImage {
|
||||
reference: local_image_ref(),
|
||||
id,
|
||||
})
|
||||
.or_else(|| {
|
||||
registry_id.map(|id| ChosenImage {
|
||||
reference: registry_image_ref(),
|
||||
id,
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
/// The existing gateway container, as far as `ensure` and status need it.
|
||||
#[derive(Debug, Clone)]
|
||||
struct GatewayContainer {
|
||||
id: String,
|
||||
state: String,
|
||||
/// The config-fingerprint label it was created with.
|
||||
fingerprint: String,
|
||||
image_id: Option<String>,
|
||||
}
|
||||
|
||||
/// Ids of the locally present images that carry the current pin.
|
||||
#[derive(Debug, Clone, Default, PartialEq)]
|
||||
struct PinnedImages {
|
||||
local: Option<String>,
|
||||
registry: Option<String>,
|
||||
}
|
||||
|
||||
impl PinnedImages {
|
||||
fn chosen(&self) -> Option<ChosenImage> {
|
||||
choose_gateway_image(self.local.clone(), self.registry.clone())
|
||||
}
|
||||
}
|
||||
|
||||
/// A failed lookup of one image doesn't hide the other. An error is returned
|
||||
/// only when it leaves the answer unknown — so "Docker is unreachable" is not
|
||||
/// mistaken for "image missing".
|
||||
async fn pinned_images() -> Result<PinnedImages, String> {
|
||||
let (local_ref, registry_ref) = (local_image_ref(), registry_image_ref());
|
||||
let (local, registry) = tokio::join!(
|
||||
super::migration::image_id(&local_ref),
|
||||
super::migration::image_id(®istry_ref)
|
||||
);
|
||||
match (local, registry) {
|
||||
(Ok(local), Ok(registry)) => Ok(PinnedImages { local, registry }),
|
||||
// The local build is preferred, so it alone decides when present.
|
||||
(Ok(Some(local)), Err(e)) => {
|
||||
log::warn!("{}", e);
|
||||
Ok(PinnedImages {
|
||||
local: Some(local),
|
||||
registry: None,
|
||||
})
|
||||
}
|
||||
// But a failed lookup of the preferred local build leaves the answer
|
||||
// unknown even when the registry image is there: choosing the
|
||||
// registry image would swap a working local-build gateway over.
|
||||
(Err(e), _) | (_, Err(e)) => Err(e),
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the container should be moved onto `pinned` — the image half of
|
||||
/// [`needs_recreate`]. A container whose image id is unknown counts as not on
|
||||
/// it, in both places.
|
||||
fn image_switch_pending(existing_image_id: Option<&str>, pinned: &ChosenImage) -> bool {
|
||||
existing_image_id != Some(pinned.id.as_str())
|
||||
}
|
||||
|
||||
/// Status's `image_outdated`: the container exists and isn't on a pinned
|
||||
/// image. With a pinned image present this is exactly [`image_switch_pending`]
|
||||
/// — what the next start or settings save acts on. With none (`Some(None)`) it
|
||||
/// is outdated, but `ensure` keeps it as is until one is pulled or built.
|
||||
/// `None` means the image check failed: unknown is reported as not outdated
|
||||
/// rather than guessed.
|
||||
fn container_image_outdated(
|
||||
container_exists: bool,
|
||||
container_image_id: Option<&str>,
|
||||
chosen: Option<Option<&ChosenImage>>,
|
||||
) -> bool {
|
||||
match chosen {
|
||||
_ if !container_exists => false,
|
||||
Some(Some(pinned)) => image_switch_pending(container_image_id, pinned),
|
||||
Some(None) => true,
|
||||
None => false,
|
||||
}
|
||||
}
|
||||
|
||||
const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile");
|
||||
const GATEWAY_DEFAULT_CONFIG: &str = include_str!("../../../../gateway-container/config.yaml");
|
||||
@@ -200,6 +369,27 @@ async fn bridge_gateway_ip(docker: &Docker) -> Option<String> {
|
||||
.find_map(|c| c.gateway.filter(|g| !g.trim().is_empty()))
|
||||
}
|
||||
|
||||
/// Config and secret version only — the same format earlier versions wrote,
|
||||
/// so updating the app doesn't by itself recreate every gateway. Image changes
|
||||
/// are handled separately by [`needs_recreate`].
|
||||
fn gateway_fingerprint(config_shape: &str, secret_version: &str) -> String {
|
||||
sha256_hex(&format!("{}|{}", config_shape, secret_version))
|
||||
}
|
||||
|
||||
/// The one rule for replacing an existing container: its config changed, or a
|
||||
/// pinned image is present and the container runs something else. With no
|
||||
/// pinned image (`pinned: None`) the container keeps its current image.
|
||||
/// [`container_image_outdated`] reports the image half of this to the UI.
|
||||
fn needs_recreate(
|
||||
existing_fingerprint: &str,
|
||||
fingerprint: &str,
|
||||
existing_image_id: Option<&str>,
|
||||
pinned: Option<&ChosenImage>,
|
||||
) -> bool {
|
||||
existing_fingerprint != fingerprint
|
||||
|| pinned.is_some_and(|p| image_switch_pending(existing_image_id, p))
|
||||
}
|
||||
|
||||
fn sha256_hex(input: &str) -> String {
|
||||
let mut hasher = Sha256::new();
|
||||
hasher.update(input.as_bytes());
|
||||
@@ -207,23 +397,32 @@ fn sha256_hex(input: &str) -> String {
|
||||
}
|
||||
|
||||
pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewayStatus, String> {
|
||||
let image_exists = super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
|| super::image::image_exists(GATEWAY_LOCAL_IMAGE)
|
||||
.await
|
||||
.unwrap_or(false);
|
||||
|
||||
let (container_exists, running) = match find_gateway_container().await? {
|
||||
Some((_, state, _)) => (true, state == "running"),
|
||||
let (pinned, container) = tokio::join!(pinned_images(), find_gateway_container());
|
||||
let chosen = match pinned {
|
||||
Ok(p) => Some(p.chosen()),
|
||||
Err(e) => {
|
||||
log::warn!("Gateway image check failed: {}", e);
|
||||
None
|
||||
}
|
||||
};
|
||||
let image_exists = matches!(chosen, Some(Some(_)));
|
||||
let container = container?;
|
||||
let (container_exists, running) = match &container {
|
||||
Some(c) => (true, c.state == "running"),
|
||||
None => (false, false),
|
||||
};
|
||||
let image_outdated = container_image_outdated(
|
||||
container_exists,
|
||||
container.as_ref().and_then(|c| c.image_id.as_deref()),
|
||||
chosen.as_ref().map(|c| c.as_ref()),
|
||||
);
|
||||
|
||||
Ok(GatewayStatus {
|
||||
container_exists,
|
||||
running,
|
||||
port: settings.port,
|
||||
image_exists,
|
||||
image_outdated,
|
||||
model_count: settings.valid_models().len(),
|
||||
has_api_key: secure::has_gateway_api_key(),
|
||||
base_url: gateway_binding().await.base_url(settings.port),
|
||||
@@ -234,7 +433,7 @@ pub async fn get_gateway_status(settings: &GatewaySettings) -> Result<GatewaySta
|
||||
/// settings reconcile, which must not start anything the user never started.
|
||||
pub async fn gateway_container_presence() -> Result<(bool, bool), String> {
|
||||
Ok(match find_gateway_container().await? {
|
||||
Some((_, state, _)) => (true, state == "running"),
|
||||
Some(c) => (true, c.state == "running"),
|
||||
None => (false, false),
|
||||
})
|
||||
}
|
||||
@@ -251,8 +450,8 @@ fn is_gateway_container(names: Option<&Vec<String>>) -> bool {
|
||||
names.is_some_and(|names| names.iter().any(|n| n == &expected))
|
||||
}
|
||||
|
||||
/// `(id, state, config fingerprint label)` for the gateway container, if any.
|
||||
async fn find_gateway_container() -> Result<Option<(String, String, String)>, String> {
|
||||
/// The gateway container, if any.
|
||||
async fn find_gateway_container() -> Result<Option<GatewayContainer>, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
let filters: HashMap<String, Vec<String>> = HashMap::from([(
|
||||
@@ -283,7 +482,14 @@ async fn find_gateway_container() -> Result<Option<(String, String, String)>, St
|
||||
.cloned()
|
||||
.unwrap_or_default();
|
||||
|
||||
return Ok(Some((id, state, fingerprint)));
|
||||
let image_id = container.image_id.clone().filter(|s| !s.is_empty());
|
||||
|
||||
return Ok(Some(GatewayContainer {
|
||||
id,
|
||||
state,
|
||||
fingerprint,
|
||||
image_id,
|
||||
}));
|
||||
}
|
||||
|
||||
Ok(None)
|
||||
@@ -428,28 +634,11 @@ async fn upload_config(container_id: &str, config: &str) -> Result<(), String> {
|
||||
async fn create_gateway_container(
|
||||
settings: &GatewaySettings,
|
||||
binding: &GatewayBinding,
|
||||
image: &ChosenImage,
|
||||
fingerprint: &str,
|
||||
) -> Result<String, String> {
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Local build first, then the pinned upstream image — same precedence as
|
||||
// the STT container.
|
||||
let image = if super::image::image_exists(GATEWAY_LOCAL_IMAGE)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
GATEWAY_LOCAL_IMAGE.to_string()
|
||||
} else if super::image::image_exists(GATEWAY_REGISTRY_IMAGE)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
GATEWAY_REGISTRY_IMAGE.to_string()
|
||||
} else {
|
||||
return Err(
|
||||
"Gateway image not found. Please pull or build the image first.".to_string(),
|
||||
);
|
||||
};
|
||||
|
||||
let mut port_bindings = HashMap::new();
|
||||
port_bindings.insert(
|
||||
format!("{}/tcp", GATEWAY_INTERNAL_PORT),
|
||||
@@ -490,7 +679,9 @@ async fn create_gateway_container(
|
||||
);
|
||||
|
||||
let config = Config {
|
||||
image: Some(image),
|
||||
// The resolved id, not the tag: a build or pull retagging the image
|
||||
// mid-create can't make the container disagree with its fingerprint.
|
||||
image: Some(image.id.clone()),
|
||||
// The upstream entrypoint (`docker/prod_entrypoint.sh`) execs
|
||||
// `litellm "$@"`. Passed explicitly so the pulled upstream image and
|
||||
// our locally built one behave identically.
|
||||
@@ -521,6 +712,20 @@ async fn create_gateway_container(
|
||||
Ok(response.id)
|
||||
}
|
||||
|
||||
/// Stops a running container gracefully first, with Docker's default grace as
|
||||
/// before (so in-flight LiteLLM requests can drain; the shorter
|
||||
/// [`GATEWAY_STOP_GRACE_SECS`] is for app exit), then removes it, tolerating
|
||||
/// one already gone.
|
||||
async fn remove_gateway_container(docker: &Docker, id: &str, state: &str) -> Result<(), String> {
|
||||
if state == "running" {
|
||||
docker
|
||||
.stop_container(id, None::<StopContainerOptions>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
|
||||
}
|
||||
super::container::remove_container(id).await
|
||||
}
|
||||
|
||||
/// Serialises every mutation of the single fixed-name gateway container.
|
||||
///
|
||||
/// `ensure_gateway_running` is check-then-act over one container name, so two
|
||||
@@ -564,14 +769,66 @@ async fn ensure_gateway_running_locked(
|
||||
|
||||
// Rotation id, not a hash of either secret — see `storage::secure`.
|
||||
let secret_version = secure::get_gateway_secret_version()?.unwrap_or_default();
|
||||
let fingerprint = sha256_hex(&format!(
|
||||
"{}|{}",
|
||||
config_shape(settings, &binding),
|
||||
secret_version
|
||||
));
|
||||
let (existing, pinned) = tokio::join!(find_gateway_container(), pinned_images());
|
||||
let existing = existing?;
|
||||
// A failed image lookup must not fail a gateway that's fine as it is: it
|
||||
// takes the same path as "no pinned image" and keeps the current image.
|
||||
let mut lookup_error = None;
|
||||
let chosen = match pinned {
|
||||
Ok(pinned) => pinned.chosen(),
|
||||
Err(e) => {
|
||||
log::warn!("Gateway image check failed: {}", e);
|
||||
lookup_error = Some(e);
|
||||
None
|
||||
}
|
||||
};
|
||||
let on_pin = chosen.is_some();
|
||||
let image = match chosen {
|
||||
Some(image) => image,
|
||||
// No pinned image yet (after an app update moved the pin, until the
|
||||
// user pulls or builds). Never tear down a working gateway over that:
|
||||
// keep it on the image it already runs — recreated with the new
|
||||
// config if that changed — and let status report it as outdated.
|
||||
None => match existing.as_ref().and_then(|c| c.image_id.clone()) {
|
||||
Some(id) => {
|
||||
log::warn!(
|
||||
"Gateway image for LiteLLM {} not present; keeping the gateway on its current image {}",
|
||||
pinned_litellm_version(),
|
||||
id
|
||||
);
|
||||
ChosenImage {
|
||||
reference: id.clone(),
|
||||
id,
|
||||
}
|
||||
}
|
||||
None => {
|
||||
return Err(match lookup_error {
|
||||
Some(e) => e,
|
||||
None => format!(
|
||||
"Gateway image for LiteLLM {} not found. Please pull or build the image first.",
|
||||
pinned_litellm_version()
|
||||
),
|
||||
})
|
||||
}
|
||||
},
|
||||
};
|
||||
|
||||
if let Some((id, state, existing_fingerprint)) = find_gateway_container().await? {
|
||||
if existing_fingerprint == fingerprint {
|
||||
let fingerprint = gateway_fingerprint(&config_shape(settings, &binding), &secret_version);
|
||||
|
||||
if let Some(GatewayContainer {
|
||||
id,
|
||||
state,
|
||||
fingerprint: existing_fingerprint,
|
||||
image_id: existing_image_id,
|
||||
}) = existing
|
||||
{
|
||||
let pinned = on_pin.then_some(&image);
|
||||
if !needs_recreate(
|
||||
&existing_fingerprint,
|
||||
&fingerprint,
|
||||
existing_image_id.as_deref(),
|
||||
pinned,
|
||||
) {
|
||||
if state == "running" {
|
||||
return get_gateway_status(settings).await;
|
||||
}
|
||||
@@ -582,41 +839,21 @@ async fn ensure_gateway_running_locked(
|
||||
return get_gateway_status(settings).await;
|
||||
}
|
||||
|
||||
// Config or a secret changed — recreate so the new config is uploaded.
|
||||
if state == "running" {
|
||||
docker
|
||||
.stop_container(&id, None::<StopContainerOptions>)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to stop gateway container: {}", e))?;
|
||||
}
|
||||
docker
|
||||
.remove_container(
|
||||
&id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to remove gateway container: {}", e))?;
|
||||
// Config, a secret or the image changed — recreate so the new config
|
||||
// is uploaded and the pinned image is used.
|
||||
remove_gateway_container(&docker, &id, &state).await?;
|
||||
}
|
||||
|
||||
let id = create_gateway_container(settings, &binding, &fingerprint).await?;
|
||||
let id = create_gateway_container(settings, &binding, &image, &fingerprint).await?;
|
||||
|
||||
// Upload before the first start: LiteLLM reads the config once at boot.
|
||||
let rendered = render_config(settings, &api_key, &master_key);
|
||||
if let Err(e) = upload_config(&id, &rendered).await {
|
||||
// Don't leave a half-configured container behind for the next run to
|
||||
// mistake for a good one.
|
||||
let _ = docker
|
||||
.remove_container(
|
||||
&id,
|
||||
Some(RemoveContainerOptions {
|
||||
force: true,
|
||||
..Default::default()
|
||||
}),
|
||||
)
|
||||
.await;
|
||||
if let Err(cleanup) = super::container::remove_container(&id).await {
|
||||
log::warn!("{}", cleanup);
|
||||
}
|
||||
return Err(e);
|
||||
}
|
||||
|
||||
@@ -626,12 +863,12 @@ async fn ensure_gateway_running_locked(
|
||||
.map_err(|e| format!("Failed to start gateway container: {}", e))?;
|
||||
|
||||
log::info!(
|
||||
"Model gateway started on {}:{} ({} model(s))",
|
||||
"Model gateway started on {}:{} ({} model(s), {})",
|
||||
binding.host_ip,
|
||||
settings.port,
|
||||
settings.valid_models().len()
|
||||
settings.valid_models().len(),
|
||||
image.reference
|
||||
);
|
||||
|
||||
get_gateway_status(settings).await
|
||||
}
|
||||
|
||||
@@ -645,7 +882,7 @@ pub async fn stop_gateway_container() -> Result<(), String> {
|
||||
let _guard = gateway_lock().lock().await;
|
||||
let docker = get_docker()?;
|
||||
|
||||
if let Some((id, state, _)) = find_gateway_container().await? {
|
||||
if let Some(GatewayContainer { id, state, .. }) = find_gateway_container().await? {
|
||||
if state == "running" {
|
||||
docker
|
||||
.stop_container(
|
||||
@@ -689,7 +926,27 @@ pub async fn pull_gateway_image<F>(on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
{
|
||||
super::image::pull_image(GATEWAY_REGISTRY_IMAGE, on_progress).await
|
||||
super::image::pull_image(®istry_image_ref(), on_progress).await?;
|
||||
// A pull by digest leaves the image untagged, which `docker image prune`
|
||||
// treats as dangling and deletes. Tag it locally; lookups still go by
|
||||
// digest, so a re-pushed upstream tag changes nothing.
|
||||
// Not fatal: the image is present and will be used either way.
|
||||
if let Err(e) = get_docker()?
|
||||
.tag_image(
|
||||
®istry_image_ref(),
|
||||
Some(bollard::image::TagImageOptions {
|
||||
repo: GATEWAY_REGISTRY_REPO,
|
||||
tag: GATEWAY_LITELLM_VERSION,
|
||||
}),
|
||||
)
|
||||
.await
|
||||
{
|
||||
log::warn!(
|
||||
"Pulled the gateway image but could not tag it (a prune may remove it): {}",
|
||||
e
|
||||
);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn build_gateway_image<F>(on_progress: F) -> Result<(), String>
|
||||
@@ -701,8 +958,9 @@ where
|
||||
let tar_bytes = create_gateway_build_context()
|
||||
.map_err(|e| format!("Failed to create gateway build context: {}", e))?;
|
||||
|
||||
let tag = local_image_ref();
|
||||
let options = BuildImageOptions {
|
||||
t: GATEWAY_LOCAL_IMAGE,
|
||||
t: tag.as_str(),
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
..Default::default()
|
||||
@@ -784,6 +1042,111 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
fn chosen(reference: &str, id: &str) -> ChosenImage {
|
||||
ChosenImage {
|
||||
reference: reference.to_string(),
|
||||
id: id.to_string(),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_builds_are_tagged_with_the_pin_and_context() {
|
||||
let v = pinned_litellm_version();
|
||||
assert!(v.starts_with('v') && !v.contains(['/', '@', ':']), "bad pin tag {v:?}");
|
||||
let tag = local_image_ref();
|
||||
let suffix = tag
|
||||
.strip_prefix(&format!("triple-c-gateway:{}-", v))
|
||||
.expect("tag is repo:pin-hash");
|
||||
assert_eq!(suffix.len(), 8);
|
||||
assert!(suffix.chars().all(|c| c.is_ascii_hexdigit()));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn registry_lookups_go_by_digest() {
|
||||
assert_eq!(
|
||||
registry_image_ref(),
|
||||
format!("ghcr.io/berriai/litellm@{}", GATEWAY_REGISTRY_DIGEST)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn dockerfile_from_matches_the_registry_pin() {
|
||||
// Local builds are tagged with the pin; that is only truthful while the
|
||||
// Dockerfile builds FROM the same tag.
|
||||
let from = GATEWAY_DOCKERFILE
|
||||
.lines()
|
||||
.find(|l| l.starts_with("FROM "))
|
||||
.expect("Dockerfile has a FROM line");
|
||||
let expected = format!(
|
||||
"FROM {}:{}@{}",
|
||||
GATEWAY_REGISTRY_REPO, GATEWAY_LITELLM_VERSION, GATEWAY_REGISTRY_DIGEST
|
||||
);
|
||||
assert_eq!(from.trim(), expected, "gateway Dockerfile FROM is out of lockstep with the pin");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn local_build_wins_then_registry_then_nothing() {
|
||||
assert_eq!(
|
||||
choose_gateway_image(Some("sha256:local".into()), Some("sha256:reg".into())),
|
||||
Some(chosen(&local_image_ref(), "sha256:local"))
|
||||
);
|
||||
assert_eq!(
|
||||
choose_gateway_image(None, Some("sha256:reg".into())),
|
||||
Some(chosen(®istry_image_ref(), "sha256:reg"))
|
||||
);
|
||||
assert_eq!(choose_gateway_image(None, None), None);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn outdated_matches_what_ensure_would_recreate() {
|
||||
let pinned = chosen(®istry_image_ref(), "sha256:new");
|
||||
assert!(!container_image_outdated(true, Some("sha256:new"), Some(Some(&pinned))));
|
||||
assert!(container_image_outdated(true, Some("sha256:old"), Some(Some(&pinned))));
|
||||
// No pinned image at all: whatever runs is not it.
|
||||
assert!(container_image_outdated(true, Some("sha256:old"), Some(None)));
|
||||
// No container, or the check failed: don't claim it.
|
||||
assert!(!container_image_outdated(false, None, Some(Some(&pinned))));
|
||||
assert!(!container_image_outdated(true, Some("sha256:old"), None));
|
||||
// A container whose image id is unknown: status and ensure agree
|
||||
// that it isn't on the pinned image.
|
||||
assert!(container_image_outdated(true, None, Some(Some(&pinned))));
|
||||
assert!(needs_recreate("fp", "fp", None, Some(&pinned)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn comment_edits_dont_change_the_local_build_tag() {
|
||||
let a = "# about\nFROM x\n\n # indented note\nRUN y\n";
|
||||
let b = "# reworded\nFROM x\nRUN y\n# trailing\n";
|
||||
assert_eq!(significant_lines(a), significant_lines(b));
|
||||
assert_ne!(significant_lines(a), significant_lines("FROM x\nRUN z\n"));
|
||||
// A parser directive changes the build.
|
||||
assert_ne!(
|
||||
significant_lines(b),
|
||||
significant_lines(&format!("# syntax=docker/dockerfile:1.7\n{b}"))
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn fingerprint_format_is_unchanged_from_earlier_versions() {
|
||||
// What main wrote: sha256 of "shape|secret". Keeping it means the app
|
||||
// update alone recreates nothing.
|
||||
assert_eq!(gateway_fingerprint("shape", "1"), sha256_hex("shape|1"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn recreate_on_config_change_or_a_present_new_pinned_image() {
|
||||
let pinned = chosen(®istry_image_ref(), "sha256:new");
|
||||
// Unchanged config, already on the pinned image: keep.
|
||||
assert!(!needs_recreate("fp", "fp", Some("sha256:new"), Some(&pinned)));
|
||||
// Pinned image present, container on another: recreate.
|
||||
assert!(needs_recreate("fp", "fp", Some("sha256:old"), Some(&pinned)));
|
||||
// No pinned image: keep the working gateway on its image.
|
||||
assert!(!needs_recreate("fp", "fp", Some("sha256:old"), None));
|
||||
// Config changed: always recreate.
|
||||
assert!(needs_recreate("old", "fp", Some("sha256:new"), Some(&pinned)));
|
||||
assert!(needs_recreate("old", "fp", Some("sha256:old"), None));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn valid_models_skips_incomplete_rows() {
|
||||
assert_eq!(settings().valid_models().len(), 1);
|
||||
|
||||
@@ -69,25 +69,32 @@ pub async fn get_local_image_digest(image_name: &str) -> Result<Option<String>,
|
||||
Ok(None)
|
||||
}
|
||||
|
||||
/// Split an image reference into the `fromImage` and `tag` the pull API takes.
|
||||
/// A digest reference (`repo@sha256:…`, optionally `repo:tag@sha256:…`) is
|
||||
/// pulled by digest: the API takes the digest in the `tag` field.
|
||||
fn split_image_ref(image_name: &str) -> (&str, &str) {
|
||||
if let Some((name, digest)) = image_name.split_once('@') {
|
||||
let repo = match name.rfind(':') {
|
||||
Some(pos) if !name[pos + 1..].contains('/') => &name[..pos],
|
||||
_ => name,
|
||||
};
|
||||
return (repo, digest);
|
||||
}
|
||||
match image_name.rfind(':') {
|
||||
// The colon is part of a port (e.g., host:port/repo), not a tag.
|
||||
Some(pos) if image_name[pos + 1..].contains('/') => (image_name, "latest"),
|
||||
Some(pos) => (&image_name[..pos], &image_name[pos + 1..]),
|
||||
None => (image_name, "latest"),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn pull_image<F>(image_name: &str, on_progress: F) -> Result<(), String>
|
||||
where
|
||||
F: Fn(String) + Send + 'static,
|
||||
{
|
||||
let docker = get_docker()?;
|
||||
|
||||
// Parse image name into from_image and tag
|
||||
let (from_image, tag) = if let Some(pos) = image_name.rfind(':') {
|
||||
// Check that the colon is part of a tag, not a port
|
||||
let after_colon = &image_name[pos + 1..];
|
||||
if after_colon.contains('/') {
|
||||
// The colon is part of a port (e.g., host:port/repo)
|
||||
(image_name, "latest")
|
||||
} else {
|
||||
(&image_name[..pos], after_colon)
|
||||
}
|
||||
} else {
|
||||
(image_name, "latest")
|
||||
};
|
||||
let (from_image, tag) = split_image_ref(image_name);
|
||||
|
||||
let options = CreateImageOptions {
|
||||
from_image,
|
||||
@@ -339,3 +346,70 @@ mod tests {
|
||||
assert_eq!(read(&dest.join("config.d/work")), "Host work");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod apt_refresh_tests {
|
||||
use super::DOCKERFILE;
|
||||
|
||||
const BUILD_WORKFLOW: &str = include_str!("../../../../.gitea/workflows/build.yml");
|
||||
|
||||
/// The `RUN` instruction (with its `\` continuations) that contains `needle`.
|
||||
fn run_containing(needle: &str) -> String {
|
||||
let mut run = String::new();
|
||||
let mut current = String::new();
|
||||
for line in DOCKERFILE.lines() {
|
||||
if line.starts_with("RUN ") {
|
||||
current.clear();
|
||||
}
|
||||
current.push_str(line);
|
||||
current.push('\n');
|
||||
if !line.trim_end().ends_with('\\') {
|
||||
if current.contains(needle) && current.starts_with("RUN ") {
|
||||
run = current.clone();
|
||||
}
|
||||
current.clear();
|
||||
}
|
||||
}
|
||||
run
|
||||
}
|
||||
|
||||
/// CI builds with `cache-from: type=gha`, so the `apt-get upgrade` layer is
|
||||
/// only re-run when its cache key changes. An ARG changes a RUN's key only
|
||||
/// where it is *used*, so the upgrade's RUN must reference it, after an
|
||||
/// `ARG APT_REFRESH` declared in its stage.
|
||||
#[test]
|
||||
fn the_apt_upgrade_layer_is_keyed_on_apt_refresh() {
|
||||
let arg = DOCKERFILE.find("\nARG APT_REFRESH").expect("ARG APT_REFRESH declared");
|
||||
let run = run_containing("upgrade -y");
|
||||
assert!(!run.is_empty(), "no RUN with apt-get upgrade");
|
||||
assert!(DOCKERFILE.find(&run).unwrap() > arg, "ARG must come before the RUN");
|
||||
assert!(run.contains("${APT_REFRESH"), "RUN must use APT_REFRESH: {run}");
|
||||
}
|
||||
|
||||
/// …and CI must actually pass a value that changes at least weekly, and
|
||||
/// rebuild on a schedule so a week with no commits still gets the fixes.
|
||||
#[test]
|
||||
fn ci_passes_a_weekly_apt_refresh_and_rebuilds_on_a_schedule() {
|
||||
assert!(BUILD_WORKFLOW.contains("APT_REFRESH=${{ steps.apt.outputs.stamp }}"));
|
||||
assert!(BUILD_WORKFLOW.contains("date -u +%G-W%V"));
|
||||
assert!(BUILD_WORKFLOW.contains("schedule:"));
|
||||
// A scheduled run must publish, a PR run must not.
|
||||
assert!(BUILD_WORKFLOW.contains("push: ${{ gitea.event_name != 'pull_request' }}"));
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod pull_ref_tests {
|
||||
use super::split_image_ref;
|
||||
|
||||
#[test]
|
||||
fn split_image_ref_handles_tags_ports_and_digests() {
|
||||
assert_eq!(split_image_ref("repo:v1"), ("repo", "v1"));
|
||||
assert_eq!(split_image_ref("repo"), ("repo", "latest"));
|
||||
assert_eq!(split_image_ref("host:5000/repo"), ("host:5000/repo", "latest"));
|
||||
assert_eq!(split_image_ref("host:5000/repo:v1"), ("host:5000/repo", "v1"));
|
||||
assert_eq!(split_image_ref("ghcr.io/a/b@sha256:abc"), ("ghcr.io/a/b", "sha256:abc"));
|
||||
assert_eq!(split_image_ref("ghcr.io/a/b:v1@sha256:abc"), ("ghcr.io/a/b", "sha256:abc"));
|
||||
assert_eq!(split_image_ref("host:5000/b@sha256:abc"), ("host:5000/b", "sha256:abc"));
|
||||
}
|
||||
}
|
||||
@@ -94,6 +94,68 @@ pub const MANIFEST_ROOTS: &[&str] = &[
|
||||
"/var/www",
|
||||
];
|
||||
|
||||
/// [`MANIFEST_ROOTS`] entries reported only to [`SUMMARY_DEPTH`], with every
|
||||
/// directory at that depth that holds at least one non-directory (at any depth)
|
||||
/// reported as one [`SUMMARY_KIND`] entry whose size is the sum of those
|
||||
/// files' sizes — exactly what [`verbatim_payload_bytes`] would have summed
|
||||
/// from a per-file listing. A directory holding no file is reported as a plain
|
||||
/// `d`, so the empty-tree filter still drops it.
|
||||
///
|
||||
/// **Why `/workspace`, and why it has to be.** It is the one root whose size is
|
||||
/// the user's rather than the image's: git worktrees, `node_modules`, a pnpm
|
||||
/// store and vendored trees all sit in the writable layer beside the bind
|
||||
/// mounts. A real container listed 94k entries — 8.6 MB of `find` output —
|
||||
/// under `/workspace` alone, which overflowed
|
||||
/// [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] and failed the staleness check
|
||||
/// on every start and stop. Pruning by name (`node_modules`, `.git`) was
|
||||
/// measured on that container and only got it to 6.3 MB: most of it was a
|
||||
/// vendored phpMyAdmin in each of eight worktrees. Depth is what bounds it.
|
||||
///
|
||||
/// **Why summarising loses nothing.** Every per-path filter in
|
||||
/// [`compute_verbatim_paths`] is vacuous below `/workspace/<x>`: dpkg owns
|
||||
/// nothing there, no [`COPY_EXCLUSIONS`] entry is there, bind mounts are
|
||||
/// exactly `/workspace/<mount_name>` (a mount name cannot hold a `/`), and the
|
||||
/// base ships an empty `/workspace`. So the verbatim set was already pruned to
|
||||
/// first-level entries; a summary just stops listing what was going to be
|
||||
/// pruned away. Migration's `tar` still archives the directory recursively.
|
||||
///
|
||||
/// **One `find` pass, aggregated by `awk`.** The listing and the sizes come
|
||||
/// from the same walk, so they cannot disagree under concurrent writes, and
|
||||
/// `-xdev` keeps it off the bind mounts (a mount point is listed, never
|
||||
/// entered). Because the walk's presence then depends on `awk`, the pipeline
|
||||
/// prints `###SUMMARISED-OK` only when `awk` succeeded, and
|
||||
/// [`check_manifest_complete`] refuses a manifest without it — a missing
|
||||
/// `/workspace` must never read as an empty one.
|
||||
///
|
||||
/// **Why the base-presence exemption is safe.** See the check in
|
||||
/// [`compute_verbatim_paths`]; it rests on the base shipping nothing under
|
||||
/// these roots, which `the_base_ships_nothing_under_a_summarised_root` pins and
|
||||
/// which is logged loudly if it ever stops being true.
|
||||
pub const SUMMARISED_ROOTS: &[&str] = &["/workspace"];
|
||||
|
||||
/// Depth below a [`SUMMARISED_ROOTS`] entry at which directories are
|
||||
/// summarised instead of listed. 1 would be just as correct for migration (the
|
||||
/// verbatim set prunes to first-level entries anyway); 2 keeps the immediate
|
||||
/// contents of each top-level folder visible in the manifest for debugging, at
|
||||
/// a few hundred lines on the container that overflowed.
|
||||
pub const SUMMARY_DEPTH: usize = 2;
|
||||
|
||||
/// Marker the summarised walk prints after a successful pass, once per root.
|
||||
const SUMMARISED_OK: &str = "SUMMARISED-OK";
|
||||
|
||||
/// [`ManifestEntry::kind`] of a summarised directory. Not a `find` `%y` letter.
|
||||
pub const SUMMARY_KIND: char = 'S';
|
||||
|
||||
/// Output budget for one manifest exec against a running container.
|
||||
///
|
||||
/// Above [`crate::docker::exec::MAX_ONESHOT_OUTPUT`] on purpose. With
|
||||
/// [`SUMMARISED_ROOTS`] a real container's manifest is a few MB (`/usr/local`
|
||||
/// ~0.8 MB, `/var/lib` ~0.1 MB, the dpkg section a few hundred kB), but the
|
||||
/// non-summarised roots are still walked file by file and a user can install
|
||||
/// anything into `/opt`. Failing at 8 MiB cost the user every staleness check;
|
||||
/// 32 MiB keeps that rare while still bounding a container-controlled read.
|
||||
pub const MANIFEST_OUTPUT_LIMIT: usize = 32 * 1024 * 1024;
|
||||
|
||||
/// Roots holding **state a base-image swap destroys and no replay can put
|
||||
/// back**. Reported by [`unpreserved_data`], never copied.
|
||||
///
|
||||
@@ -173,16 +235,28 @@ pub const PROBE_LABEL_MIGRATION: &str = "migration";
|
||||
/// One entry from the filesystem walk.
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub struct ManifestEntry {
|
||||
/// `find`'s `%y`: `f` regular, `d` directory, `l` symlink, …
|
||||
/// `find`'s `%y`: `f` regular, `d` directory, `l` symlink, … — or
|
||||
/// [`SUMMARY_KIND`] for a directory under a [`SUMMARISED_ROOTS`] entry that
|
||||
/// was not listed file by file.
|
||||
pub kind: char,
|
||||
/// `find`'s `%s`; for a summary, the sum of `%s` over every non-directory
|
||||
/// beneath it, from the same pass.
|
||||
pub size: u64,
|
||||
pub path: String,
|
||||
}
|
||||
|
||||
impl ManifestEntry {
|
||||
/// A directory reported as itself, with nothing implied about content.
|
||||
/// A summary is deliberately *not* one: it stands for a subtree that holds
|
||||
/// at least one file.
|
||||
pub fn is_dir(&self) -> bool {
|
||||
self.kind == 'd'
|
||||
}
|
||||
|
||||
/// A directory whose contents were aggregated rather than listed.
|
||||
pub fn is_summary(&self) -> bool {
|
||||
self.kind == SUMMARY_KIND
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything one probe run learned about an image or a running container.
|
||||
@@ -202,6 +276,9 @@ pub struct Manifest {
|
||||
pub etc_paths: BTreeSet<String>,
|
||||
/// `package -> version` for every installed dpkg package.
|
||||
pub dpkg_versions: BTreeMap<String, String>,
|
||||
/// How many [`SUMMARISED_ROOTS`] walks reported finishing. See
|
||||
/// [`check_manifest_complete`].
|
||||
pub summarised_roots_complete: usize,
|
||||
}
|
||||
|
||||
impl Manifest {
|
||||
@@ -217,18 +294,26 @@ impl Manifest {
|
||||
/// Sections are separated by sentinel lines so one exec answers every question;
|
||||
/// on a 5.49 GB image the whole thing takes about three seconds. Every command
|
||||
/// is failure-tolerant (`2>/dev/null`, no `set -e`) because a missing `npm` or
|
||||
/// an unreadable directory must degrade one section, not the run.
|
||||
/// an unreadable directory must degrade one section, not the run — with one
|
||||
/// exception, the summarised walk, whose failure [`check_manifest_complete`]
|
||||
/// turns into an error because degrading it would lose data.
|
||||
pub fn manifest_script() -> String {
|
||||
let feature_paths = FEATURE_PROBES
|
||||
.iter()
|
||||
.map(|(p, _)| shell_single_quote(p))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
let roots = MANIFEST_ROOTS
|
||||
let plain_roots: Vec<&str> = MANIFEST_ROOTS
|
||||
.iter()
|
||||
.map(|p| shell_single_quote(p))
|
||||
.copied()
|
||||
.filter(|p| !SUMMARISED_ROOTS.contains(p))
|
||||
.collect();
|
||||
let plain = plain_walk(&plain_roots);
|
||||
let summarised = SUMMARISED_ROOTS
|
||||
.iter()
|
||||
.map(|r| summarised_walk(r))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
.join("\necho '###PATHS'\n");
|
||||
// The dpkg grep is anchored to the manifest roots so the section stays a
|
||||
// few hundred kB instead of the ~40 MB a full ownership dump would be.
|
||||
let dpkg_filter = MANIFEST_ROOTS
|
||||
@@ -239,7 +324,9 @@ pub fn manifest_script() -> String {
|
||||
format!(
|
||||
r#"
|
||||
echo '###PATHS'
|
||||
find {roots} -xdev -printf '%y\t%s\t%p\n' 2>/dev/null
|
||||
{plain}
|
||||
echo '###PATHS'
|
||||
{summarised}
|
||||
echo '###DPKG'
|
||||
cat /var/lib/dpkg/info/*.list 2>/dev/null | grep -E '^/({dpkg_filter})(/|$)'
|
||||
echo '###APT'
|
||||
@@ -260,6 +347,90 @@ exit 0
|
||||
)
|
||||
}
|
||||
|
||||
/// A per-file `find` over `roots`, or nothing at all when there are none.
|
||||
///
|
||||
/// The empty case is not cosmetic: `find -xdev …` with no starting point walks
|
||||
/// `.`, which in a probe container is `/` — the whole filesystem, straight into
|
||||
/// an output-capped manifest.
|
||||
fn plain_walk(roots: &[&str]) -> String {
|
||||
if roots.is_empty() {
|
||||
return String::new();
|
||||
}
|
||||
let roots = roots
|
||||
.iter()
|
||||
.map(|p| shell_single_quote(p))
|
||||
.collect::<Vec<_>>()
|
||||
.join(" ");
|
||||
format!("find {} -xdev -printf '%y\\t%s\\t%p\\n' 2>/dev/null", roots)
|
||||
}
|
||||
|
||||
/// Aggregates a per-file `find` listing of one root (`kind<TAB>size<TAB>path`)
|
||||
/// into [`SUMMARY_DEPTH`]-limited `###PATHS` lines. See [`SUMMARISED_ROOTS`].
|
||||
///
|
||||
/// Entries above the boundary are printed as they arrive; a directory *at* the
|
||||
/// boundary is held back and printed at the end — as `S` with the summed size
|
||||
/// of every non-directory beneath it, or as its own `d` line when it has none,
|
||||
/// so an empty scaffold stays empty to the filter. A deeper entry whose
|
||||
/// boundary ancestor was never seen is printed rather than dropped (it cannot
|
||||
/// happen in a pre-order walk; erring towards over-listing is the safe side).
|
||||
/// The path is everything after the second tab, so a tab in a name survives.
|
||||
/// POSIX `awk` only: the image has `mawk`.
|
||||
const SUMMARY_AWK: &str = r#"BEGIN { FS = "\t"; rl = length(root) }
|
||||
{
|
||||
kind = $1; size = $2
|
||||
p = $0; sub(/^[^\t]*\t[^\t]*\t/, "", p)
|
||||
if (p == root || substr(p, 1, rl + 1) != root "/") { print; next }
|
||||
n = split(substr(p, rl + 2), c, "/")
|
||||
if (n < depth) { print; next }
|
||||
key = root
|
||||
for (i = 1; i <= depth; i++) key = key "/" c[i]
|
||||
if (n == depth) {
|
||||
if (kind != "d") { print; next }
|
||||
order[++nb] = key; dsize[key] = size; files[key] = 0; bytes[key] = 0
|
||||
next
|
||||
}
|
||||
if (!(key in files)) { print; next }
|
||||
if (kind != "d") { files[key]++; bytes[key] += size }
|
||||
}
|
||||
END {
|
||||
for (i = 1; i <= nb; i++) {
|
||||
k = order[i]
|
||||
if (files[k] > 0) printf "S\t%.0f\t%s\n", bytes[k], k
|
||||
else printf "d\t%s\t%s\n", dsize[k], k
|
||||
}
|
||||
}"#;
|
||||
|
||||
/// The `###PATHS` lines for one [`SUMMARISED_ROOTS`] entry, from one `find`
|
||||
/// pass piped through [`SUMMARY_AWK`], then `###SUMMARISED-OK` only if `awk`
|
||||
/// succeeded.
|
||||
fn summarised_walk(root: &str) -> String {
|
||||
format!(
|
||||
"find {q} -xdev -printf '%y\\t%s\\t%p\\n' 2>/dev/null | awk -v root={q} -v depth={d} {prog} && echo '###{ok}'",
|
||||
q = shell_single_quote(root),
|
||||
d = SUMMARY_DEPTH,
|
||||
prog = shell_single_quote(SUMMARY_AWK),
|
||||
ok = SUMMARISED_OK,
|
||||
)
|
||||
}
|
||||
|
||||
/// Refuse a manifest whose summarised walk did not finish.
|
||||
///
|
||||
/// Every other section degrades on failure, and that is right for them — a
|
||||
/// missing `npm` costs the npm delta. This one cannot: the summarised walk is
|
||||
/// the *only* record of what lives under `/workspace`, so a silently absent one
|
||||
/// reads as "nothing there" and the container swap would destroy it.
|
||||
pub fn check_manifest_complete(m: &Manifest) -> Result<(), String> {
|
||||
if m.summarised_roots_complete < SUMMARISED_ROOTS.len() {
|
||||
return Err(format!(
|
||||
"Could not list {} inside the container (the probe's `awk` step did not finish), \
|
||||
so Triple-C cannot tell what an update would need to carry across.",
|
||||
SUMMARISED_ROOTS.join(", ")
|
||||
));
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
|
||||
/// Parse the output of [`manifest_script`].
|
||||
///
|
||||
/// Unknown sections and malformed lines are skipped rather than failing: the
|
||||
@@ -271,6 +442,9 @@ pub fn parse_manifest(raw: &str) -> Manifest {
|
||||
for line in raw.lines() {
|
||||
let line = line.strip_suffix('\r').unwrap_or(line);
|
||||
if let Some(name) = line.strip_prefix("###") {
|
||||
if name == SUMMARISED_OK {
|
||||
m.summarised_roots_complete += 1;
|
||||
}
|
||||
section = match name {
|
||||
"PATHS" | "DPKG" | "APT" | "NPM" | "FEATURES" | "ETC" | "PKGVER" | "END" => name,
|
||||
_ => "",
|
||||
@@ -416,7 +590,8 @@ pub fn prune_to_roots(paths: &BTreeSet<String>) -> Vec<String> {
|
||||
/// * it lives under a [`COPY_ROOTS`] entry,
|
||||
/// * it is not under a [`COPY_EXCLUSIONS`] entry or a bind-mount target,
|
||||
/// * neither image's dpkg database owns it,
|
||||
/// * the current base image does not already have it.
|
||||
/// * the current base image does not already have it (a [`SUMMARY_KIND`]
|
||||
/// entry is exempt from this one — see the comment at the check).
|
||||
///
|
||||
/// The result is then pruned to subtree roots. An empty result means the copy
|
||||
/// step is skipped entirely.
|
||||
@@ -446,7 +621,24 @@ pub fn compute_verbatim_paths(
|
||||
if from.dpkg_owned.contains(p) || base.dpkg_owned.contains(p) {
|
||||
continue;
|
||||
}
|
||||
if base_paths.contains(p) {
|
||||
// Not for a summary: "the base already has this path" is a per-path
|
||||
// judgment, and a summary hides which paths are inside it. Skipping one
|
||||
// would drop every user file under any subtree the base happens to ship
|
||||
// too. Copying it is safe — the restore's `--skip-old-files` keeps the
|
||||
// base's copy of anything both have, which is the per-file answer.
|
||||
// What it cannot do is notice base content the new base *removed*, so
|
||||
// this rests on the base shipping nothing under a summarised root —
|
||||
// pinned by `the_base_ships_nothing_under_a_summarised_root`, and said
|
||||
// out loud here if a base ever breaks it.
|
||||
if base_paths.contains(p) && entry.is_summary() {
|
||||
log::warn!(
|
||||
"The base image ships {}, which this manifest only summarises — copying the \
|
||||
container's whole subtree and relying on --skip-old-files to keep the base's \
|
||||
files. SUMMARISED_ROOTS assumes the base ships nothing there; revisit it.",
|
||||
p
|
||||
);
|
||||
}
|
||||
if base_paths.contains(p) && !entry.is_summary() {
|
||||
continue;
|
||||
}
|
||||
candidates.insert(entry.path.clone());
|
||||
@@ -1061,7 +1253,29 @@ pub async fn manifest_from_image(image: &str) -> Result<Manifest, String> {
|
||||
}
|
||||
));
|
||||
}
|
||||
Ok(parse_manifest(&out.stdout))
|
||||
let m = parse_manifest(&out.stdout);
|
||||
check_manifest_complete(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Turn a manifest exec's failure into something the user can act on.
|
||||
///
|
||||
/// The one case worth naming is the output ceiling: what reached the banner was
|
||||
/// "OUTPUT_LIMIT: Command output exceeded 8388608 bytes and was abandoned",
|
||||
/// true of a buffer and no help about a container. Same idea as
|
||||
/// `file_commands::describe_listing_failure`.
|
||||
pub fn describe_manifest_failure(error: String) -> String {
|
||||
if error.starts_with(super::exec::OUTPUT_LIMIT_MARKER) {
|
||||
return format!(
|
||||
"This container has too many files for Triple-C to inspect for an update (its \
|
||||
file listing passed {} MiB). The usual cause is a very large tree installed \
|
||||
under /usr/local, /opt, /srv, /var/lib, /var/www or /etc, or a huge number of \
|
||||
files directly in a top-level /workspace folder. Removing it, or moving it \
|
||||
into a mounted project folder, will let the check run.",
|
||||
MANIFEST_OUTPUT_LIMIT / (1024 * 1024)
|
||||
);
|
||||
}
|
||||
error
|
||||
}
|
||||
|
||||
/// Capture a [`Manifest`] from a *running* container.
|
||||
@@ -1071,20 +1285,23 @@ pub async fn manifest_from_image(image: &str) -> Result<Manifest, String> {
|
||||
/// installed since the last commit, and a verbatim set computed from a stale
|
||||
/// manifest would silently fail to carry that work across.
|
||||
pub async fn manifest_from_container(container_id: &str) -> Result<Manifest, String> {
|
||||
let (out, code) = super::exec::exec_oneshot_as(
|
||||
let (out, code) = super::exec::exec_oneshot_limited(
|
||||
container_id,
|
||||
"root",
|
||||
vec!["/bin/sh".to_string(), "-c".to_string(), manifest_script()],
|
||||
Vec::new(),
|
||||
MANIFEST_OUTPUT_LIMIT,
|
||||
)
|
||||
.await?;
|
||||
.await
|
||||
.map_err(describe_manifest_failure)?;
|
||||
if !out.contains("###END") {
|
||||
return Err(format!(
|
||||
"Probe of the running container did not complete (exit {})",
|
||||
code
|
||||
));
|
||||
}
|
||||
Ok(parse_manifest(&out))
|
||||
let m = parse_manifest(&out);
|
||||
check_manifest_complete(&m)?;
|
||||
Ok(m)
|
||||
}
|
||||
|
||||
/// Cached stopped-container manifests, keyed by container id, each paired with
|
||||
@@ -2220,6 +2437,308 @@ mod tests {
|
||||
assert_eq!(shell_single_quote("/opt/a'b"), r#"'/opt/a'\''b'"#);
|
||||
}
|
||||
|
||||
// ── /workspace summarisation (the 8 MiB overflow) ───────────────────────
|
||||
|
||||
|
||||
#[test]
|
||||
fn a_summary_carries_its_own_size_and_is_not_a_directory() {
|
||||
let raw = "###PATHS\n\
|
||||
d\t4096\t/workspace/wt\n\
|
||||
f\t10\t/workspace/wt/README.md\n\
|
||||
S\t123456\t/workspace/wt/node_modules\n\
|
||||
###SUMMARISED-OK\n\
|
||||
###DPKG\n\
|
||||
###END\n";
|
||||
let m = parse_manifest(raw);
|
||||
assert_eq!(m.paths.len(), 3);
|
||||
let s = &m.paths[2];
|
||||
assert!(s.is_summary());
|
||||
assert!(!s.is_dir(), "a summary stands for content, not an empty directory");
|
||||
assert_eq!(s.size, 123456);
|
||||
assert!(check_manifest_complete(&m).is_ok());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_manifest_whose_summarised_walk_did_not_finish_is_refused() {
|
||||
// If the aggregating `awk` is missing or dies, the /workspace walk is
|
||||
// simply absent. Read as "nothing there", the migration would destroy
|
||||
// the user's top-level /workspace folders — so it is an error instead.
|
||||
let raw = "###PATHS\nd\t4096\t/opt\n###DPKG\n###END\n";
|
||||
let m = parse_manifest(raw);
|
||||
let err = check_manifest_complete(&m).unwrap_err();
|
||||
assert!(err.contains("/workspace"), "{}", err);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_summarised_subtree_is_copied_whole_and_counted_in_the_payload() {
|
||||
// `/workspace/node_modules` holds nothing at its own level but deeper
|
||||
// summaries: it must not look like an empty tree and be dropped.
|
||||
let from = manifest(
|
||||
&[
|
||||
('d', 4096, "/workspace/node_modules"),
|
||||
('S', 50_000, "/workspace/node_modules/typescript"),
|
||||
('S', 7_000, "/workspace/node_modules/.bin"),
|
||||
('d', 4096, "/workspace/wt"),
|
||||
('f', 10, "/workspace/wt/README.md"),
|
||||
('S', 1_000, "/workspace/wt/src"),
|
||||
],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let verbatim = compute_verbatim_paths(&from, &Manifest::default(), &[]);
|
||||
assert_eq!(
|
||||
verbatim,
|
||||
vec!["/workspace/node_modules".to_string(), "/workspace/wt".to_string()]
|
||||
);
|
||||
assert_eq!(verbatim_payload_bytes(&from, &verbatim), 58_010);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_summary_the_base_also_has_is_still_copied() {
|
||||
// The presence-in-base filter is a per-path judgment, and a summary
|
||||
// hides which paths are inside it. Dropping it would lose the user's
|
||||
// files under any subtree the base happens to ship; copying it is safe
|
||||
// because the restore's `--skip-old-files` never clobbers the base.
|
||||
// (Today's base ships an empty /workspace — the ignored live test
|
||||
// `the_base_ships_nothing_under_a_summarised_root` pins that.)
|
||||
let from = manifest(
|
||||
&[
|
||||
('d', 4096, "/workspace/tools"),
|
||||
('S', 900, "/workspace/tools/lib"),
|
||||
],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
let base = manifest(
|
||||
&[
|
||||
('d', 4096, "/workspace/tools"),
|
||||
('S', 100, "/workspace/tools/lib"),
|
||||
],
|
||||
&[],
|
||||
&[],
|
||||
);
|
||||
assert_eq!(
|
||||
compute_verbatim_paths(&from, &base, &[]),
|
||||
vec!["/workspace/tools/lib".to_string()]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_workspace_is_walked_once_and_never_file_by_file() {
|
||||
let s = manifest_script();
|
||||
assert_eq!(
|
||||
s.matches("find '/workspace'").count(),
|
||||
1,
|
||||
"/workspace must be walked in a single pass:\n{}",
|
||||
s
|
||||
);
|
||||
assert!(!s.contains("'/workspace' '/var/lib'"), "flat walk is back:\n{}", s);
|
||||
assert!(!s.contains("du "), "sizes come from the same pass, not du:\n{}", s);
|
||||
assert!(s.contains("###SUMMARISED-OK"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_walk_of_no_roots_is_no_walk_at_all() {
|
||||
// `find -xdev …` with no starting point walks `.`, which is `/` in a
|
||||
// probe container — the whole filesystem, into the manifest.
|
||||
assert_eq!(plain_walk(&[]), "");
|
||||
assert!(plain_walk(&["/opt"]).starts_with("find '/opt' -xdev"));
|
||||
}
|
||||
|
||||
/// Runs the real walk against a scratch tree shaped like the container that
|
||||
/// overflowed, then a section after it, through the real parser.
|
||||
#[cfg(target_os = "linux")]
|
||||
#[test]
|
||||
fn the_summarised_walk_is_small_exact_and_ends_cleanly() {
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let root = dir.path().join("workspace");
|
||||
for wt in 0..4 {
|
||||
let nm = root.join(format!("wt{}", wt)).join("node_modules");
|
||||
for pkg in 0..50 {
|
||||
let p = nm.join(format!("pkg{}", pkg)).join("lib");
|
||||
std::fs::create_dir_all(&p).unwrap();
|
||||
for f in 0..10 {
|
||||
std::fs::write(p.join(format!("f{}.js", f)), b"0123456789").unwrap();
|
||||
}
|
||||
}
|
||||
std::fs::write(root.join(format!("wt{}", wt)).join("README.md"), b"hi").unwrap();
|
||||
}
|
||||
// Second-level directories holding no file at any depth: one empty,
|
||||
// one with only empty subdirectories. Neither is content.
|
||||
std::fs::create_dir_all(root.join("wt0").join("empty")).unwrap();
|
||||
std::fs::create_dir_all(root.join("scaffold").join("a").join("b").join("c")).unwrap();
|
||||
std::fs::write(root.join("loose.txt"), b"x").unwrap();
|
||||
let root_s = root.to_string_lossy().to_string();
|
||||
|
||||
let script = format!(
|
||||
"echo '###PATHS'\n{}\necho '###APT'\necho socat\necho '###END'\n",
|
||||
summarised_walk(&root_s)
|
||||
);
|
||||
let out = std::process::Command::new("/bin/sh")
|
||||
.arg("-c")
|
||||
.arg(&script)
|
||||
.output()
|
||||
.unwrap();
|
||||
let text = String::from_utf8_lossy(&out.stdout).to_string();
|
||||
let m = parse_manifest(&text);
|
||||
|
||||
// 2,000 files below; one line per first- and second-level entry above.
|
||||
assert!(m.paths.len() < 20, "walk was not summarised: {}", text);
|
||||
// Exactly the old per-file sum: 50 packages × 10 files × 10 bytes.
|
||||
// Directory inode sizes are not content and must not be counted.
|
||||
let nm0 = format!("{}/wt0/node_modules", root_s);
|
||||
let entry = m.paths.iter().find(|e| e.path == nm0).expect("summary present");
|
||||
assert!(entry.is_summary());
|
||||
assert_eq!(entry.size, 5_000, "{:?}", entry);
|
||||
for empty in [format!("{}/wt0/empty", root_s), format!("{}/scaffold/a", root_s)] {
|
||||
let e = m.paths.iter().find(|e| e.path == empty).expect("listed");
|
||||
assert!(e.is_dir(), "a tree with no files is not content: {:?}", e);
|
||||
}
|
||||
assert!(m.paths.iter().any(|e| e.path == format!("{}/loose.txt", root_s)));
|
||||
// The walk terminates its section: the next one still parses.
|
||||
assert_eq!(m.apt_manual, strs(&["socat"]));
|
||||
assert_eq!(m.summarised_roots_complete, 1, "{}", text);
|
||||
|
||||
// And the migration sees the same thing it would have per file.
|
||||
let from = Manifest {
|
||||
paths: m
|
||||
.paths
|
||||
.iter()
|
||||
.map(|e| ManifestEntry {
|
||||
path: e.path.replacen(&root_s, "/workspace", 1),
|
||||
..e.clone()
|
||||
})
|
||||
.collect(),
|
||||
..Default::default()
|
||||
};
|
||||
let verbatim = compute_verbatim_paths(&from, &Manifest::default(), &[]);
|
||||
assert!(!verbatim.contains(&"/workspace/scaffold".to_string()), "{:?}", verbatim);
|
||||
assert_eq!(
|
||||
verbatim_payload_bytes(&from, &["/workspace/wt0".to_string()]),
|
||||
5_000 + 2
|
||||
);
|
||||
}
|
||||
|
||||
/// Pins the assumption that makes summaries safe to exempt from the
|
||||
/// presence-in-base check: the base ships nothing under a summarised root.
|
||||
/// If a future Dockerfile puts content in /workspace this fails, and
|
||||
/// `compute_verbatim_paths` needs revisiting before it ships.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture base_ships_nothing
|
||||
/// ```
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon and the base image"]
|
||||
async fn the_base_ships_nothing_under_a_summarised_root() {
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
let m = manifest_from_image(&image).await.expect("base probe");
|
||||
for root in SUMMARISED_ROOTS {
|
||||
let inside: Vec<_> = m
|
||||
.paths
|
||||
.iter()
|
||||
.filter(|e| is_under(&e.path, root) && e.path != *root)
|
||||
.map(|e| e.path.as_str())
|
||||
.collect();
|
||||
assert!(inside.is_empty(), "the base ships {:?} under {}", inside, root);
|
||||
}
|
||||
}
|
||||
|
||||
/// The overflow end to end, against a scratch container off the real base:
|
||||
/// 80k files in a writable-layer `/workspace` tree (past 8 MiB unsummarised)
|
||||
/// beside a bind-mounted project folder.
|
||||
///
|
||||
/// ```text
|
||||
/// cargo test -- --ignored --nocapture large_workspace
|
||||
/// ```
|
||||
#[cfg(unix)]
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon; creates and removes a throwaway container"]
|
||||
async fn a_large_workspace_is_probed_without_overflowing_and_still_copied() {
|
||||
fn docker_cli(args: &[&str]) -> String {
|
||||
let out = std::process::Command::new("docker")
|
||||
.args(args)
|
||||
.output()
|
||||
.expect("docker CLI");
|
||||
assert!(
|
||||
out.status.success(),
|
||||
"docker {:?} failed: {}",
|
||||
args,
|
||||
String::from_utf8_lossy(&out.stderr)
|
||||
);
|
||||
String::from_utf8_lossy(&out.stdout).trim().to_string()
|
||||
}
|
||||
let image = std::env::var("TRIPLE_C_TEST_IMAGE")
|
||||
.unwrap_or_else(|_| "ghcr.io/shadowdao/triple-c-sandbox:latest".to_string());
|
||||
let host = tempfile::tempdir().unwrap();
|
||||
for i in 0..50 {
|
||||
std::fs::write(host.path().join(format!("host-{}.txt", i)), b"h").unwrap();
|
||||
}
|
||||
let mount = format!("{}:/workspace/proj", host.path().display());
|
||||
let id = docker_cli(&[
|
||||
"run", "-d", "--label", "triple-c.managed=true",
|
||||
"-v", &mount,
|
||||
"--entrypoint", "/bin/sh",
|
||||
&image, "-c", "sleep 300",
|
||||
]);
|
||||
// 80k files with long names, three levels down: ~9.2 MB as a flat walk.
|
||||
let populate = "for w in 0 1 2 3 4 5 6 7; do for p in $(seq 1 100); do \
|
||||
d=/workspace/worktree-number-$w/node_modules/package-with-a-long-name-$p/lib; \
|
||||
mkdir -p $d; cd $d && seq -f 'module-file-with-a-long-name-%g.js' 1 100 | xargs touch; \
|
||||
done; done; echo x > /workspace/worktree-number-0/README.md; \
|
||||
head -c 4096 /dev/zero > /workspace/worktree-number-0/node_modules/real.js";
|
||||
docker_cli(&["exec", "-u", "root", &id, "sh", "-c", populate]);
|
||||
|
||||
let result = manifest_from_container(&id).await;
|
||||
let _ = std::process::Command::new("docker").args(["rm", "-f", &id]).output();
|
||||
|
||||
let m = result.expect("the probe must not overflow");
|
||||
let ws: Vec<_> = m.paths.iter().filter(|e| is_under(&e.path, "/workspace")).collect();
|
||||
assert!(ws.len() < 100, "/workspace was walked file by file: {} entries", ws.len());
|
||||
assert!(
|
||||
!ws.iter().any(|e| is_under(&e.path, "/workspace/proj") && e.path != "/workspace/proj"),
|
||||
"the walk crossed into the bind mount"
|
||||
);
|
||||
let nm = m
|
||||
.paths
|
||||
.iter()
|
||||
.find(|e| e.path == "/workspace/worktree-number-0/node_modules")
|
||||
.expect("summary entry");
|
||||
// Every file below is empty but `real.js`: the per-file sum, exactly,
|
||||
// with no directory inode sizes in it — computed by the image's `mawk`.
|
||||
assert!(nm.is_summary() && nm.size == 4096, "{:?}", nm);
|
||||
assert_eq!(m.summarised_roots_complete, SUMMARISED_ROOTS.len());
|
||||
|
||||
let excl = bind_mount_exclusions(&[pp("proj")]);
|
||||
let verbatim = compute_verbatim_paths(&m, &Manifest::default(), &excl);
|
||||
let ws_verbatim: Vec<_> =
|
||||
verbatim.iter().filter(|p| is_under(p, "/workspace")).cloned().collect();
|
||||
assert_eq!(
|
||||
ws_verbatim,
|
||||
(0..8).map(|w| format!("/workspace/worktree-number-{}", w)).collect::<Vec<_>>()
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_overflowing_probe_says_what_to_do_rather_than_quoting_a_buffer() {
|
||||
let raw = format!(
|
||||
"{}: Command output exceeded 33554432 bytes and was abandoned",
|
||||
crate::docker::exec::OUTPUT_LIMIT_MARKER
|
||||
);
|
||||
let msg = describe_manifest_failure(raw);
|
||||
assert!(!msg.contains(crate::docker::exec::OUTPUT_LIMIT_MARKER), "{}", msg);
|
||||
assert!(!msg.contains("33554432"), "{}", msg);
|
||||
// Every root still walked file by file, so the advice is never wrong
|
||||
// about where to look.
|
||||
for root in MANIFEST_ROOTS.iter().chain(["/etc"].iter()) {
|
||||
if !SUMMARISED_ROOTS.contains(root) {
|
||||
assert!(msg.contains(root), "message omits {}: {}", root, msg);
|
||||
}
|
||||
}
|
||||
// Anything else passes through untouched.
|
||||
assert_eq!(describe_manifest_failure("boom".into()), "boom");
|
||||
}
|
||||
|
||||
|
||||
// ── Stale rollback pins (A5) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -8,6 +8,9 @@ pub mod legacy_cleanup;
|
||||
pub mod migration;
|
||||
pub mod stt;
|
||||
|
||||
/// The container user `claude`'s home directory, as the image creates it.
|
||||
pub(crate) const CONTAINER_HOME: &str = "/home/claude";
|
||||
|
||||
#[allow(unused_imports)]
|
||||
pub use gateway::*;
|
||||
#[allow(unused_imports)]
|
||||
|
||||
@@ -7,7 +7,7 @@
|
||||
|
||||
use serde::Serialize;
|
||||
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
|
||||
pub struct ViewerPoll {
|
||||
@@ -48,8 +48,9 @@ pub async fn poll_file(container_id: &str, container_path: &str) -> Result<Viewe
|
||||
"poll".to_string(),
|
||||
container_path.to_string(),
|
||||
];
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, stderr, code) = ContainerTarget::docker(container_id)
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
if code != 0 && code != 4 {
|
||||
return Err(format!(
|
||||
"Could not check the file: {}",
|
||||
|
||||
@@ -2,13 +2,16 @@
|
||||
//!
|
||||
//! Relative paths are the common case (Claude prints project-relative paths). The
|
||||
//! terminal exec's cwd is `/workspace`, and each project path is mounted at
|
||||
//! `/workspace/<mount_name>`, so those are the roots probed, in that order. The probe
|
||||
//! `/workspace/<mount_name>`, so those are the roots probed, in that order.
|
||||
//! A relative path none of them has is then tried under `/home/claude` and `/tmp`
|
||||
//! ([`fallback_candidate_paths`]); `~/p` is `/home/claude/p`. The probe
|
||||
//! is one exec as the container user and prints `realpath -e` of every candidate that
|
||||
//! is a regular file: `fetch_container_file` refuses a symlink, so the registry must
|
||||
//! hold the resolved path, not the one that was clicked.
|
||||
|
||||
use crate::commands::file_commands::validate_container_path;
|
||||
use crate::docker::exec::exec_oneshot_streams_as;
|
||||
use crate::docker::CONTAINER_HOME;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
pub const MAX_CANDIDATES: usize = 16;
|
||||
const MAX_RAW_LEN: usize = 4096;
|
||||
@@ -36,12 +39,20 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>,
|
||||
return Ok(vec![normalised]);
|
||||
}
|
||||
|
||||
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
|
||||
let rel = rel.trim_start_matches("./");
|
||||
if rel.is_empty() {
|
||||
return Err("The path is empty.".into());
|
||||
// Claude Code prints paths under $HOME as `~/...`. Only the bare `~/` form:
|
||||
// `~user/` names some other account's home and stays an ordinary name.
|
||||
if let Some(rest) = raw.strip_prefix("~/") {
|
||||
let rest = collapse(rest);
|
||||
if rest.is_empty() {
|
||||
return Err("The path is empty.".into());
|
||||
}
|
||||
let p = format!("{}/{}", CONTAINER_HOME, rest);
|
||||
validate_container_path("File", &p)?;
|
||||
return Ok(vec![p]);
|
||||
}
|
||||
|
||||
let rel = relative_part(raw).ok_or_else(|| "The path is empty.".to_string())?;
|
||||
|
||||
let mut out: Vec<String> = Vec::new();
|
||||
let mut push = |candidate: String| {
|
||||
if out.len() < MAX_CANDIDATES && !out.contains(&candidate) {
|
||||
@@ -61,6 +72,36 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>,
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Where a relative path is looked for when no project root has it, in order.
|
||||
/// The container user's home, then `/tmp` (drops, pastes and scratch files).
|
||||
/// Probed only after [`candidate_paths`] found nothing, so a project file never
|
||||
/// turns into a "which one?" chooser because a same-named file sits in `/tmp`.
|
||||
const FALLBACK_ROOTS: &[&str] = &[CONTAINER_HOME, "/tmp"];
|
||||
|
||||
/// The second tier for a relative path: [`FALLBACK_ROOTS`]`/<p>`. Empty for an
|
||||
/// absolute or `~/` path (those name one place already) and for anything
|
||||
/// [`candidate_paths`] refuses — it is always called after that succeeded, but
|
||||
/// must not be a way around its checks.
|
||||
pub fn fallback_candidate_paths(raw: &str) -> Vec<String> {
|
||||
if raw.starts_with('/') || raw.starts_with("~/") || candidate_paths(raw, &[]).is_err() {
|
||||
return Vec::new();
|
||||
}
|
||||
let Some(rel) = relative_part(raw) else { return Vec::new() };
|
||||
FALLBACK_ROOTS
|
||||
.iter()
|
||||
.map(|root| format!("{}/{}", root, rel))
|
||||
.filter(|c| validate_container_path("File", c).is_ok())
|
||||
.take(MAX_CANDIDATES)
|
||||
.collect()
|
||||
}
|
||||
|
||||
/// `./a//b` → `a/b`; `None` when nothing is left.
|
||||
fn relative_part(raw: &str) -> Option<String> {
|
||||
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
|
||||
let rel = rel.trim_start_matches("./");
|
||||
(!rel.is_empty()).then(|| rel.to_string())
|
||||
}
|
||||
|
||||
/// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs).
|
||||
fn collapse(path: &str) -> String {
|
||||
let absolute = path.starts_with('/');
|
||||
@@ -95,8 +136,9 @@ pub async fn probe_candidates(
|
||||
) -> Result<Vec<String>, String> {
|
||||
let mut cmd: Vec<String> = vec!["sh".into(), "-c".into(), PROBE_SCRIPT.into(), "probe".into()];
|
||||
cmd.extend(candidates.iter().cloned());
|
||||
let (stdout, _stderr, _code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, _stderr, _code) = ContainerTarget::docker(container_id)
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
Ok(parse_probe_output(&stdout))
|
||||
}
|
||||
|
||||
@@ -129,6 +171,38 @@ mod tests {
|
||||
assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_absolute_tmp_path_is_accepted_as_is() {
|
||||
// Claude Code's scratchpad: long, and with a segment that starts with `-`.
|
||||
let p = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
|
||||
assert_eq!(candidate_paths(p, &mounts(&["api"])).unwrap(), vec![p]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_home_relative_path_is_the_container_users_home() {
|
||||
// Claude Code prints paths under $HOME as `~/...`.
|
||||
let c = candidate_paths("~/.claude/plans/plan.md", &mounts(&["api"])).unwrap();
|
||||
assert_eq!(c, vec!["/home/claude/.claude/plans/plan.md"]);
|
||||
assert!(candidate_paths("~/", &[]).is_err());
|
||||
assert!(candidate_paths("~/../etc/passwd", &[]).is_err());
|
||||
// `~user/` is not expanded; it stays an ordinary relative name.
|
||||
assert_eq!(candidate_paths("~bob/x.md", &[]).unwrap(), vec!["/workspace/~bob/x.md"]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_relative_path_falls_back_to_home_and_tmp() {
|
||||
assert_eq!(
|
||||
fallback_candidate_paths("scratch/notes.md"),
|
||||
vec!["/home/claude/scratch/notes.md", "/tmp/scratch/notes.md"]
|
||||
);
|
||||
assert_eq!(fallback_candidate_paths("./notes.md"), vec!["/home/claude/notes.md", "/tmp/notes.md"]);
|
||||
// Absolute, home-relative and refused paths have no fallbacks.
|
||||
assert!(fallback_candidate_paths("/tmp/notes.md").is_empty());
|
||||
assert!(fallback_candidate_paths("~/notes.md").is_empty());
|
||||
assert!(fallback_candidate_paths("../notes.md").is_empty());
|
||||
assert!(fallback_candidate_paths("").is_empty());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn traversal_nul_and_oversize_are_refused() {
|
||||
assert!(candidate_paths("../etc/passwd", &[]).is_err());
|
||||
|
||||
@@ -11,7 +11,7 @@ use serde::Serialize;
|
||||
use sha2::{Digest, Sha256};
|
||||
|
||||
use crate::commands::file_commands::clip_container_text;
|
||||
use crate::docker::exec::{exec_oneshot_streams_as, ExecSessionManager};
|
||||
use crate::host::{ContainerTarget, ExecSessionManager};
|
||||
|
||||
/// Spec §4/§5: only untruncated (≤ 1 MiB) text is editable, so nothing larger is saved.
|
||||
pub const MAX_WRITE_BYTES: usize = 1024 * 1024;
|
||||
@@ -180,12 +180,14 @@ pub async fn write_file(
|
||||
) -> Result<SavedFile, String> {
|
||||
check_write_input(bytes.len(), base_hash)?;
|
||||
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
|
||||
let container = ContainerTarget::docker(container_id);
|
||||
let tmp_path = exec_manager
|
||||
.write_file_to_container(container_id, &tmp_name, bytes)
|
||||
.write_file_to_container(&container, &tmp_name, bytes)
|
||||
.await?;
|
||||
let cmd = write_command(target, &tmp_path, base_hash);
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", cmd, Vec::new()).await?;
|
||||
let (stdout, stderr, code) = container
|
||||
.exec_oneshot_streams_as("claude", cmd, Vec::new())
|
||||
.await?;
|
||||
saved_file(classify_write(code, &stdout, &stderr), bytes)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,474 @@
|
||||
//! Where a project's container runs, and the one interface the rest of the app
|
||||
//! uses to reach inside it.
|
||||
//!
|
||||
//! Spec: `docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md` ("Host
|
||||
//! abstraction"). This is phase 1 of that plan: every operation that happens
|
||||
//! *inside* a project container — one-shot and attached execs, file transfer,
|
||||
//! "is it running" — goes through [`ContainerTarget`] instead of naming
|
||||
//! `docker::exec` or bollard directly. Today the only variant is
|
||||
//! [`ContainerTarget::Docker`], which delegates to the existing code in
|
||||
//! `docker/exec.rs` and `docker/container.rs` with no change in behaviour. An
|
||||
//! AWS variant (reached through the in-container agent) slots in beside it.
|
||||
//!
|
||||
//! ## Why an enum and not a trait object
|
||||
//!
|
||||
//! The set of hosts is closed and known at compile time, every method is async,
|
||||
//! and a `dyn` async trait would need boxing on every call (or the
|
||||
//! `async-trait` crate) for no benefit. Adding a host means adding a variant and
|
||||
//! letting the compiler point at every `match` that needs an arm.
|
||||
//!
|
||||
//! ## What does *not* go through here
|
||||
//!
|
||||
//! Things that only make sense for the local Docker host: image build/pull,
|
||||
//! base-image migration, snapshot commits, volumes, the LiteLLM gateway and STT
|
||||
//! sidecars, and (until phase 1b) container create/start/stop and the `die`
|
||||
//! event stream. Those keep calling `crate::docker` directly. The
|
||||
//! `no_container_interior_bollard_outside_the_host_layer` test pins which files
|
||||
//! may still do so.
|
||||
|
||||
pub mod sessions;
|
||||
|
||||
pub use crate::docker::exec::{
|
||||
MAX_DROP_BYTES, OUTPUT_LIMIT_MARKER, PROC_NET_OUTPUT_LIMIT,
|
||||
};
|
||||
pub use sessions::ExecSessionManager;
|
||||
|
||||
use bytes::Bytes;
|
||||
use futures_util::Stream;
|
||||
use std::pin::Pin;
|
||||
use tokio::io::AsyncWrite;
|
||||
|
||||
use crate::docker;
|
||||
use crate::models::Project;
|
||||
|
||||
/// One chunk of output from an exec, already demultiplexed.
|
||||
///
|
||||
/// Host-neutral stand-in for bollard's `LogOutput`. With `tty = true` there is
|
||||
/// no stdout/stderr split and everything arrives as [`ExecChunk::StdOut`] (Docker
|
||||
/// reports a TTY stream as `Console`, which maps here too — consumers that cared
|
||||
/// treated the two the same).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ExecChunk {
|
||||
StdOut(Bytes),
|
||||
StdErr(Bytes),
|
||||
/// stdin echo or anything else a host might surface. Consumers ignore it.
|
||||
Other(Bytes),
|
||||
}
|
||||
|
||||
impl ExecChunk {
|
||||
pub fn into_bytes(self) -> Bytes {
|
||||
match self {
|
||||
ExecChunk::StdOut(b) | ExecChunk::StdErr(b) | ExecChunk::Other(b) => b,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub type ExecOutputStream = Pin<Box<dyn Stream<Item = Result<ExecChunk, String>> + Send>>;
|
||||
|
||||
/// An exec that has been created and started with its streams attached.
|
||||
///
|
||||
/// `input` is a sink that is never read when the exec was opened with
|
||||
/// `attach_stdin: false`.
|
||||
pub struct AttachedExec {
|
||||
pub exec_id: String,
|
||||
pub output: ExecOutputStream,
|
||||
pub input: Pin<Box<dyn AsyncWrite + Send>>,
|
||||
}
|
||||
|
||||
/// Everything about an attached exec except where it runs.
|
||||
///
|
||||
/// Defaults match what this app has always used: run as `claude` in
|
||||
/// `/workspace`, no TTY, stdin attached, no extra env.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct ExecSpec {
|
||||
pub cmd: Vec<String>,
|
||||
pub user: String,
|
||||
pub working_dir: Option<String>,
|
||||
pub env: Vec<String>,
|
||||
pub tty: bool,
|
||||
pub attach_stdin: bool,
|
||||
}
|
||||
|
||||
impl ExecSpec {
|
||||
pub fn new(cmd: Vec<String>) -> Self {
|
||||
Self {
|
||||
cmd,
|
||||
user: "claude".to_string(),
|
||||
working_dir: Some("/workspace".to_string()),
|
||||
env: Vec::new(),
|
||||
tty: false,
|
||||
attach_stdin: true,
|
||||
}
|
||||
}
|
||||
pub fn user(mut self, user: &str) -> Self {
|
||||
self.user = user.to_string();
|
||||
self
|
||||
}
|
||||
pub fn working_dir(mut self, dir: Option<&str>) -> Self {
|
||||
self.working_dir = dir.map(str::to_string);
|
||||
self
|
||||
}
|
||||
pub fn env(mut self, env: Vec<String>) -> Self {
|
||||
self.env = env;
|
||||
self
|
||||
}
|
||||
pub fn tty(mut self, tty: bool) -> Self {
|
||||
self.tty = tty;
|
||||
self
|
||||
}
|
||||
pub fn no_stdin(mut self) -> Self {
|
||||
self.attach_stdin = false;
|
||||
self
|
||||
}
|
||||
}
|
||||
|
||||
/// A byte stream of a tar archive read out of a container.
|
||||
pub type ArchiveStream = Pin<Box<dyn Stream<Item = Result<Bytes, String>> + Send>>;
|
||||
|
||||
/// A specific project container on a specific host.
|
||||
#[derive(Debug, Clone, PartialEq, Eq, Hash)]
|
||||
pub enum ContainerTarget {
|
||||
/// A container on the local Docker daemon, by id or name.
|
||||
Docker { container_id: String },
|
||||
}
|
||||
|
||||
impl ContainerTarget {
|
||||
pub fn docker(container_id: impl Into<String>) -> Self {
|
||||
ContainerTarget::Docker {
|
||||
container_id: container_id.into(),
|
||||
}
|
||||
}
|
||||
|
||||
/// The target for a project's container, if it has one yet.
|
||||
pub fn for_project(project: &Project) -> Option<Self> {
|
||||
project.container_id.as_deref().map(Self::docker)
|
||||
}
|
||||
|
||||
/// The host-specific identifier (a Docker container id today). For logs
|
||||
/// and for keying per-container state; never parse it.
|
||||
pub fn id(&self) -> &str {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => container_id,
|
||||
}
|
||||
}
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// Whether the container is running. An inspect failure (gone, daemon
|
||||
/// down) reads as `false`, as `docker::is_container_running` always has.
|
||||
pub async fn is_running(&self) -> Result<bool, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::container::is_container_running(container_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────
|
||||
|
||||
/// Run as `claude`, return combined output. Errors on a non-UTF-8-safe
|
||||
/// transport failure only; a non-zero exit is *not* an error here.
|
||||
pub async fn exec_oneshot(&self, cmd: Vec<String>) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot(container_id, cmd).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_env(
|
||||
&self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_env(container_id, cmd, env).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_env_status(
|
||||
&self,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_env_status(container_id, cmd, env).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_as(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_as(container_id, user, cmd, env).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn exec_oneshot_limited(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_limited(container_id, user, cmd, limit).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// stdout and stderr kept apart: `(stdout, stderr, exit_code)`.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
&self,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_oneshot_streams_as(container_id, user, cmd, env).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start a command and return without waiting or reading its output.
|
||||
/// Returns the exec id.
|
||||
pub async fn exec_detached(&self, spec: ExecSpec) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::exec_detached(container_id, &spec).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── attached execs ───────────────────────────────────────────────────────
|
||||
|
||||
/// Run as `claude` in `/workspace` with stdin attached.
|
||||
pub async fn attach_exec(&self, cmd: Vec<String>, tty: bool) -> Result<AttachedExec, String> {
|
||||
self.attach(ExecSpec::new(cmd).tty(tty)).await
|
||||
}
|
||||
|
||||
pub async fn attach(&self, spec: ExecSpec) -> Result<AttachedExec, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::create_attached_exec_spec(container_id, &spec).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The exit code of an exec this target started, once it has finished.
|
||||
/// `None` if the host could not say.
|
||||
pub async fn wait_exec_exit(&self, exec_id: &str) -> Option<i64> {
|
||||
match self {
|
||||
ContainerTarget::Docker { .. } => docker::exec::wait_for_exec_exit(exec_id).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn resize_exec(&self, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { .. } => docker::exec::resize_exec(exec_id, cols, rows).await,
|
||||
}
|
||||
}
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────
|
||||
|
||||
/// The container user's `(uid, gid)`, falling back to `(1000, 1000)`.
|
||||
pub async fn user_ids(&self) -> (u64, u64) {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::container_user_ids(container_id).await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Write `data` to `dest_dir/file_name` as root-owned with `mode`.
|
||||
pub async fn upload_bytes(
|
||||
&self,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_bytes_to_container(
|
||||
container_id,
|
||||
dest_dir,
|
||||
file_name,
|
||||
data,
|
||||
mode,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::upload_bytes`] with the owner set to `ids`.
|
||||
pub async fn upload_bytes_with_ids(
|
||||
&self,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_bytes_with_ids(
|
||||
container_id,
|
||||
dest_dir,
|
||||
file_name,
|
||||
data,
|
||||
mode,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Copy a host file in, owned by the container user.
|
||||
pub async fn upload_host_file(
|
||||
&self,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_host_file_to_container(
|
||||
container_id,
|
||||
host_path,
|
||||
dest_dir,
|
||||
dest_name,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// [`Self::upload_host_file`] with the owner ids already known.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
&self,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::upload_host_file_with_ids(
|
||||
container_id,
|
||||
host_path,
|
||||
dest_dir,
|
||||
dest_name,
|
||||
ids,
|
||||
)
|
||||
.await
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Read `path` out of the container as a tar stream (Docker's archive
|
||||
/// API). Dropping the stream cancels the transfer.
|
||||
pub fn download_archive(&self, path: &str) -> ArchiveStream {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
docker::exec::download_archive(container_id, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn exec_spec_defaults_match_what_the_app_always_used() {
|
||||
let s = ExecSpec::new(vec!["true".into()]);
|
||||
assert_eq!(s.user, "claude");
|
||||
assert_eq!(s.working_dir.as_deref(), Some("/workspace"));
|
||||
assert!(s.env.is_empty());
|
||||
assert!(!s.tty);
|
||||
assert!(s.attach_stdin);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn for_project_follows_the_container_id() {
|
||||
let mut p: Project = serde_json::from_value(serde_json::json!({
|
||||
"id": "p1", "name": "p", "paths": [], "container_id": null,
|
||||
"status": "stopped", "backend": "anthropic",
|
||||
"allow_docker_access": false, "full_permissions": false,
|
||||
"created_at": "now", "updated_at": "now"
|
||||
}))
|
||||
.expect("minimal project deserializes");
|
||||
assert_eq!(ContainerTarget::for_project(&p), None);
|
||||
p.container_id = Some("abc".into());
|
||||
assert_eq!(
|
||||
ContainerTarget::for_project(&p),
|
||||
Some(ContainerTarget::docker("abc"))
|
||||
);
|
||||
assert_eq!(ContainerTarget::docker("abc").id(), "abc");
|
||||
}
|
||||
|
||||
/// Phase 1's boundary, pinned: outside `docker/` and `host/`, only the
|
||||
/// files listed here may name bollard or `docker::exec`. Each entry is
|
||||
/// Docker-only by nature (migration, snapshots, sidecars) or is scheduled
|
||||
/// for phase 1b (lifecycle, the `die` event stream). Shrink this list; do
|
||||
/// not grow it — new container-interior code goes through
|
||||
/// [`ContainerTarget`].
|
||||
#[test]
|
||||
fn no_container_interior_bollard_outside_the_host_layer() {
|
||||
const ALLOWED: &[&str] = &[
|
||||
// Docker-only: base-image migration replays apt / restores a payload.
|
||||
"commands/migration_commands.rs",
|
||||
// Phase 1b: lifecycle and the `die` event stream.
|
||||
"container_watch.rs",
|
||||
];
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut offenders = Vec::new();
|
||||
let mut stack = vec![src.clone()];
|
||||
while let Some(dir) = stack.pop() {
|
||||
for entry in std::fs::read_dir(&dir).unwrap() {
|
||||
let path = entry.unwrap().path();
|
||||
let rel = path
|
||||
.strip_prefix(&src)
|
||||
.unwrap()
|
||||
.to_string_lossy()
|
||||
.replace('\\', "/");
|
||||
if path.is_dir() {
|
||||
if rel != "docker" && rel != "host" {
|
||||
stack.push(path);
|
||||
}
|
||||
continue;
|
||||
}
|
||||
if !rel.ends_with(".rs") || ALLOWED.contains(&rel.as_str()) {
|
||||
continue;
|
||||
}
|
||||
let text = std::fs::read_to_string(&path).unwrap();
|
||||
if text.contains("bollard::")
|
||||
|| text.contains("docker::exec")
|
||||
|| text.contains("get_docker")
|
||||
{
|
||||
offenders.push(rel);
|
||||
}
|
||||
}
|
||||
}
|
||||
offenders.sort();
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"these files reach into the container without going through host::ContainerTarget: {:?}",
|
||||
offenders
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,214 @@
|
||||
//! Interactive exec sessions — terminals and the audio bridge — keyed by the
|
||||
//! frontend's session id. Host-neutral: every session holds the
|
||||
//! [`ContainerTarget`] it was opened on and does all its I/O through it.
|
||||
//! (Moved here from `docker/exec.rs` in phase 1 of the AWS host work.)
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::{mpsc, Mutex};
|
||||
|
||||
use super::{AttachedExec, ContainerTarget};
|
||||
|
||||
pub struct ExecSession {
|
||||
pub exec_id: String,
|
||||
pub target: ContainerTarget,
|
||||
pub input_tx: mpsc::UnboundedSender<Vec<u8>>,
|
||||
shutdown_tx: mpsc::Sender<()>,
|
||||
}
|
||||
|
||||
impl ExecSession {
|
||||
pub async fn send_input(&self, data: Vec<u8>) -> Result<(), String> {
|
||||
self.input_tx
|
||||
.send(data)
|
||||
.map_err(|e| format!("Failed to send input: {}", e))
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub async fn resize(&self, cols: u16, rows: u16) -> Result<(), String> {
|
||||
self.target.resize_exec(&self.exec_id, cols, rows).await
|
||||
}
|
||||
|
||||
pub fn shutdown(&self) {
|
||||
let _ = self.shutdown_tx.try_send(());
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ExecSessionManager {
|
||||
sessions: Arc<Mutex<HashMap<String, ExecSession>>>,
|
||||
}
|
||||
|
||||
impl ExecSessionManager {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
sessions: Arc::new(Mutex::new(HashMap::new())),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn create_session<F>(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
self.create_session_with_tty(target, session_id, cmd, true, on_output, on_exit)
|
||||
.await
|
||||
}
|
||||
|
||||
pub async fn create_session_with_tty<F>(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
session_id: &str,
|
||||
cmd: Vec<String>,
|
||||
tty: bool,
|
||||
on_output: F,
|
||||
on_exit: Box<dyn FnOnce() + Send>,
|
||||
) -> Result<(), String>
|
||||
where
|
||||
F: Fn(Vec<u8>) + Send + 'static,
|
||||
{
|
||||
let AttachedExec {
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = target.attach_exec(cmd, tty).await?;
|
||||
|
||||
let (input_tx, mut input_rx) = mpsc::unbounded_channel::<Vec<u8>>();
|
||||
let (shutdown_tx, mut shutdown_rx) = mpsc::channel::<()>(1);
|
||||
|
||||
// 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
_ = shutdown_rx.recv() => {
|
||||
log::info!("Exec session {} shutting down", 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;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let session = ExecSession {
|
||||
exec_id,
|
||||
target: target.clone(),
|
||||
input_tx,
|
||||
shutdown_tx,
|
||||
};
|
||||
|
||||
self.sessions
|
||||
.lock()
|
||||
.await
|
||||
.insert(session_id.to_string(), session);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
pub async fn send_input(&self, session_id: &str, data: Vec<u8>) -> Result<(), String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
session.send_input(data).await
|
||||
}
|
||||
|
||||
pub async fn resize(&self, session_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
// Clone the exec_id under the lock, then drop the lock before the
|
||||
// async host call to avoid holding the mutex across await.
|
||||
let (exec_id, target) = {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
(session.exec_id.clone(), session.target.clone())
|
||||
};
|
||||
target.resize_exec(&exec_id, cols, rows).await
|
||||
}
|
||||
|
||||
pub async fn close_session(&self, session_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
if let Some(session) = sessions.remove(session_id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_sessions_for_container(&self, container_id: &str) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
let ids_to_close: Vec<String> = sessions
|
||||
.iter()
|
||||
.filter(|(_, s)| s.target.id() == container_id)
|
||||
.map(|(id, _)| id.clone())
|
||||
.collect();
|
||||
for id in ids_to_close {
|
||||
if let Some(session) = sessions.remove(&id) {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn close_all_sessions(&self) {
|
||||
let mut sessions = self.sessions.lock().await;
|
||||
for (_, session) in sessions.drain() {
|
||||
session.shutdown();
|
||||
}
|
||||
}
|
||||
|
||||
/// The container a session was opened on — reuse it for side-channel work
|
||||
/// on the same session (image paste, file drop, audio bridge) rather than
|
||||
/// rebuilding a target from an id, which would assume the host.
|
||||
pub async fn get_target(&self, session_id: &str) -> Result<ContainerTarget, String> {
|
||||
let sessions = self.sessions.lock().await;
|
||||
let session = sessions
|
||||
.get(session_id)
|
||||
.ok_or_else(|| format!("Session {} not found", session_id))?;
|
||||
Ok(session.target.clone())
|
||||
}
|
||||
|
||||
pub async fn write_file_to_container(
|
||||
&self,
|
||||
target: &ContainerTarget,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
) -> Result<String, String> {
|
||||
// Owned by the container user, stamped now: a default tar header would
|
||||
// land it as root:root/1970 and Claude Code could not rewrite it.
|
||||
let ids = target.user_ids().await;
|
||||
target
|
||||
.upload_bytes_with_ids("/tmp", file_name, data, 0o644, ids)
|
||||
.await
|
||||
}
|
||||
}
|
||||
@@ -3,7 +3,11 @@ mod browser_view;
|
||||
#[cfg(test)]
|
||||
mod command_census;
|
||||
mod commands;
|
||||
mod container_watch;
|
||||
#[cfg(target_os = "linux")]
|
||||
mod linux_media;
|
||||
mod docker;
|
||||
mod host;
|
||||
pub mod file_viewer;
|
||||
mod install_helper;
|
||||
mod logging;
|
||||
@@ -19,7 +23,7 @@ use std::sync::{Arc, Mutex};
|
||||
use std::time::Duration;
|
||||
|
||||
use auth_bridge::AuthBridgeManager;
|
||||
use docker::exec::ExecSessionManager;
|
||||
use host::ExecSessionManager;
|
||||
use storage::projects_store::ProjectsStore;
|
||||
use storage::settings_store::SettingsStore;
|
||||
use tauri::async_runtime::JoinHandle;
|
||||
@@ -254,6 +258,9 @@ pub fn run() {
|
||||
})
|
||||
.manage(file_viewer::registry::ViewerRegistry::default())
|
||||
.setup(move |app| {
|
||||
#[cfg(target_os = "linux")]
|
||||
linux_media::enable_microphone(app.handle());
|
||||
|
||||
match tauri::image::Image::from_bytes(include_bytes!("../icons/icon.png")) {
|
||||
Ok(icon) => {
|
||||
if let Some(window) = app.get_webview_window("main") {
|
||||
@@ -307,6 +314,16 @@ pub fn run() {
|
||||
.await;
|
||||
});
|
||||
|
||||
// Notice a project container that stops without the app asking
|
||||
// (its keepalive killed, an OOM, a `docker stop` from a shell) and
|
||||
// flip the project to Stopped. One daemon-side-filtered event
|
||||
// subscription for the app's lifetime — see `container_watch`.
|
||||
lifecycle_setup.track(container_watch::spawn(
|
||||
app.handle().clone(),
|
||||
projects_store_setup.clone(),
|
||||
lifecycle_setup.cancellation(),
|
||||
));
|
||||
|
||||
// Marketplaces: refresh each once at startup, in the background.
|
||||
// Failures are logged, not toasted — the Marketplace tab shows them.
|
||||
{
|
||||
@@ -645,7 +662,6 @@ pub fn run() {
|
||||
commands::gateway_commands::regenerate_gateway_auth_token,
|
||||
// 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,
|
||||
|
||||
@@ -0,0 +1,77 @@
|
||||
//! Microphone access for the main window on Linux (WebKitGTK).
|
||||
//!
|
||||
//! WebKitGTK denies `getUserMedia` unless the embedder (a) has the
|
||||
//! `enable-media-stream` setting on and (b) answers the webview's
|
||||
//! `permission-request` signal. wry does neither, so without this the speech-
|
||||
//! to-text button fails with
|
||||
//! `NotAllowedError: The request is not allowed by the user agent ...` even
|
||||
//! when GStreamer can see the microphone.
|
||||
//!
|
||||
//! Scope is deliberately narrow:
|
||||
//! * only the `main` window — the file-viewer windows and any embedded
|
||||
//! browser view load different content and get no grant;
|
||||
//! * only audio capture — a request that also asks for a camera, and every
|
||||
//! other permission type (geolocation, notifications, ...), is denied.
|
||||
|
||||
use tauri::{AppHandle, Manager};
|
||||
use webkit2gtk::glib::prelude::*;
|
||||
use webkit2gtk::{
|
||||
PermissionRequestExt, SettingsExt, UserMediaPermissionRequest, UserMediaPermissionRequestExt,
|
||||
WebView, WebViewExt,
|
||||
};
|
||||
|
||||
/// Whether a media permission request should be granted: microphone only.
|
||||
pub(crate) fn allow_media_request(for_audio: bool, for_video: bool) -> bool {
|
||||
for_audio && !for_video
|
||||
}
|
||||
|
||||
pub fn enable_microphone(app: &AppHandle) {
|
||||
let Some(window) = app.get_webview_window("main") else {
|
||||
log::warn!("Microphone: no main window, capture will stay disabled");
|
||||
return;
|
||||
};
|
||||
let result = window.with_webview(|platform| {
|
||||
let webview: WebView = platform.inner();
|
||||
if let Some(settings) = WebViewExt::settings(&webview) {
|
||||
settings.set_enable_media_stream(true);
|
||||
}
|
||||
webview.connect_permission_request(|_, request| {
|
||||
if let Some(media) = request.downcast_ref::<UserMediaPermissionRequest>() {
|
||||
let ok =
|
||||
allow_media_request(media.is_for_audio_device(), media.is_for_video_device());
|
||||
if ok {
|
||||
request.allow();
|
||||
} else {
|
||||
request.deny();
|
||||
}
|
||||
log::info!(
|
||||
"Microphone: media permission request {}",
|
||||
if ok { "allowed" } else { "denied" }
|
||||
);
|
||||
} else {
|
||||
request.deny();
|
||||
}
|
||||
true // handled; do not fall through to WebKit's default
|
||||
});
|
||||
});
|
||||
if let Err(e) = result {
|
||||
log::warn!("Microphone: could not configure the webview: {e}");
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::allow_media_request;
|
||||
|
||||
#[test]
|
||||
fn grants_audio_only() {
|
||||
assert!(allow_media_request(true, false));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn denies_camera_and_mixed_requests() {
|
||||
assert!(!allow_media_request(false, true));
|
||||
assert!(!allow_media_request(true, true));
|
||||
assert!(!allow_media_request(false, false));
|
||||
}
|
||||
}
|
||||
@@ -5,16 +5,13 @@
|
||||
|
||||
use std::time::Duration;
|
||||
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::io::{AsyncWrite, AsyncWriteExt};
|
||||
use tokio::sync::oneshot;
|
||||
|
||||
use crate::commands::auth_token_commands::{push_capped_tail, AnsiStripper, SUBMIT_ENTER_DELAY};
|
||||
use crate::docker::exec::{
|
||||
create_attached_exec_as, exec_oneshot_as, wait_for_exec_exit, AttachedExec,
|
||||
};
|
||||
use crate::host::{AttachedExec, ContainerTarget, ExecChunk, ExecSpec};
|
||||
|
||||
pub const CODE_EVENT: &str = "marketplace-gh-login-code";
|
||||
pub const OUTPUT_EVENT: &str = "marketplace-gh-login-output";
|
||||
@@ -32,8 +29,8 @@ const MAX_PENDING_LINE: usize = 4096;
|
||||
/// script around it (whose text contains the same words); errors are ignored.
|
||||
const CANCEL_PKILL: [&str; 3] = ["pkill", "-f", "gh auth login --hostname"];
|
||||
|
||||
/// Constant script; the host is `$1` (argv, never interpolated), because
|
||||
/// `create_attached_exec_as` takes no env.
|
||||
/// Constant script; the host is `$1` (argv, never interpolated); the attached
|
||||
/// exec is opened with no extra env.
|
||||
///
|
||||
/// * `GH_CONFIG_DIR` / `GIT_CONFIG_GLOBAL` live in a temp dir removed on exit,
|
||||
/// so the container is never left logged in. The `HUP INT TERM` trap turns a
|
||||
@@ -150,9 +147,9 @@ fn failure_tail(transcript: &str) -> String {
|
||||
|
||||
/// Pre-flight N9 / review fix 1: stop the in-container login after any
|
||||
/// failed attempt.
|
||||
async fn kill_container_login(container_id: &str) {
|
||||
async fn kill_container_login(target: &ContainerTarget) {
|
||||
let cmd = CANCEL_PKILL.iter().map(|s| s.to_string()).collect();
|
||||
let _ = exec_oneshot_as(container_id, "claude", cmd, vec![]).await;
|
||||
let _ = target.exec_oneshot_as("claude", cmd, vec![]).await;
|
||||
}
|
||||
|
||||
/// Hand `result` back, running `cleanup` first when it is a failure.
|
||||
@@ -187,7 +184,7 @@ async fn drive_login<S, W, E>(
|
||||
mut emit: E,
|
||||
) -> Result<String, String>
|
||||
where
|
||||
S: Stream<Item = Result<LogOutput, bollard::errors::Error>> + Unpin,
|
||||
S: Stream<Item = Result<ExecChunk, String>> + Unpin,
|
||||
W: AsyncWrite + Unpin,
|
||||
E: FnMut(&'static str, serde_json::Value),
|
||||
{
|
||||
@@ -262,7 +259,7 @@ where
|
||||
pub async fn run_gh_container_login(
|
||||
app: &AppHandle,
|
||||
account_id: &str,
|
||||
container_id: &str,
|
||||
target: &ContainerTarget,
|
||||
host: &str,
|
||||
mut cancel: oneshot::Receiver<()>,
|
||||
) -> Result<String, String> {
|
||||
@@ -273,20 +270,20 @@ pub async fn run_gh_container_login(
|
||||
exec_id,
|
||||
output,
|
||||
input,
|
||||
} = create_attached_exec_as(
|
||||
container_id,
|
||||
vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
GH_LOGIN_SCRIPT.to_string(),
|
||||
"triple-c-gh-login".to_string(),
|
||||
host.to_string(),
|
||||
],
|
||||
true,
|
||||
"claude",
|
||||
"/home/claude",
|
||||
)
|
||||
.await?;
|
||||
} = target
|
||||
.attach(
|
||||
ExecSpec::new(vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
GH_LOGIN_SCRIPT.to_string(),
|
||||
"triple-c-gh-login".to_string(),
|
||||
host.to_string(),
|
||||
])
|
||||
.tty(true)
|
||||
.user("claude")
|
||||
.working_dir(Some("/home/claude")),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let deadline = tokio::time::Instant::now() + LOGIN_TIMEOUT;
|
||||
let result = async {
|
||||
@@ -305,7 +302,7 @@ pub async fn run_gh_container_login(
|
||||
if let Some(token) = extract_token(&transcript) {
|
||||
return Ok(token);
|
||||
}
|
||||
let status = wait_for_exec_exit(&exec_id).await;
|
||||
let status = target.wait_exec_exit(&exec_id).await;
|
||||
Err(format!(
|
||||
"gh did not complete the sign-in (exit status {}). Nothing was stored.\n{}",
|
||||
status
|
||||
@@ -315,7 +312,7 @@ pub async fn run_gh_container_login(
|
||||
))
|
||||
}
|
||||
.await;
|
||||
cleanup_on_error(result, || kill_container_login(container_id)).await
|
||||
cleanup_on_error(result, || kill_container_login(target)).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -496,23 +493,19 @@ mod tests {
|
||||
/// successful one does not.
|
||||
mod teardown {
|
||||
use super::super::*;
|
||||
use bollard::container::LogOutput;
|
||||
use futures_util::stream;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::task::{Context, Poll};
|
||||
|
||||
type Frame = Result<LogOutput, bollard::errors::Error>;
|
||||
type Frame = Result<ExecChunk, String>;
|
||||
|
||||
fn out(s: &'static str) -> Frame {
|
||||
Ok(LogOutput::StdOut { message: s.into() })
|
||||
Ok(ExecChunk::StdOut(s.into()))
|
||||
}
|
||||
|
||||
fn lost() -> Frame {
|
||||
Err(bollard::errors::Error::DockerResponseServerError {
|
||||
status_code: 500,
|
||||
message: "connection reset".to_string(),
|
||||
})
|
||||
Err("connection reset".to_string())
|
||||
}
|
||||
|
||||
/// Records every write separately; or fails every write.
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
use std::time::Duration;
|
||||
|
||||
use super::payload::Payload;
|
||||
use crate::docker::exec::{exec_oneshot_as, exec_oneshot_streams_as, upload_bytes_to_container};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::marketplace::{SkippedItem, SyncReport};
|
||||
|
||||
/// Where the payload and the script are uploaded. Owned by `claude`.
|
||||
@@ -14,10 +14,19 @@ pub const INCOMING_DIR: &str = "/home/claude/.claude/triple-c/marketplace/incomi
|
||||
/// app version reaches existing containers without an image migration.
|
||||
pub const SYNC_SCRIPT: &str = include_str!("sync.sh");
|
||||
|
||||
/// True once the entrypoint has finished: its last step execs this exact
|
||||
/// command line. Before that it may still be merging `settings.json` or running
|
||||
/// `claude update`, both of which the sync would race.
|
||||
const READY_PROBE: &str = "pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null";
|
||||
/// True once the entrypoint has finished: its last step starts a process with
|
||||
/// one of these exact command lines. Before that it may still be merging
|
||||
/// `settings.json` or running `claude update`, both of which the sync would race.
|
||||
///
|
||||
/// Two, because there are two generations of entrypoint in the field. Current
|
||||
/// images keep the container alive with a root-owned `triple-c-keepalive`
|
||||
/// sleep (so a `pkill sleep` run as `claude` cannot stop the container — see
|
||||
/// the end of `container/entrypoint.sh`); older ones `exec su … claude -c exec
|
||||
/// sleep infinity`, and a project keeps its base image until it is migrated.
|
||||
/// Both are matched as root-owned only (the old image's `su` runs as root), so
|
||||
/// a `claude` process wearing either command line cannot fake readiness.
|
||||
const READY_PROBE: &str = "pgrep -u root -x -f 'triple-c-keepalive infinity' >/dev/null \
|
||||
|| pgrep -u root -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null";
|
||||
const READY_TIMEOUT: Duration = Duration::from_secs(180);
|
||||
const READY_POLL: Duration = Duration::from_secs(2);
|
||||
|
||||
@@ -48,10 +57,12 @@ fn run_script_env() -> Vec<String> {
|
||||
vec!["HOME=/home/claude".to_string()]
|
||||
}
|
||||
|
||||
async fn wait_until_ready(container_id: &str) -> Result<(), String> {
|
||||
async fn wait_until_ready(target: &ContainerTarget) -> Result<(), String> {
|
||||
let deadline = tokio::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let (_, code) = exec_oneshot_as(container_id, "root", ready_probe_cmd(), vec![]).await?;
|
||||
let (_, code) = target
|
||||
.exec_oneshot_as("root", ready_probe_cmd(), vec![])
|
||||
.await?;
|
||||
if code == 0 {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -82,34 +93,28 @@ fn tail(text: &str, max: usize) -> &str {
|
||||
/// Wait for readiness, upload the payload and the script, run the script as
|
||||
/// `claude`, and return its report.
|
||||
pub async fn sync_container(container_id: &str, payload: &Payload) -> Result<SyncReport, String> {
|
||||
wait_until_ready(container_id).await?;
|
||||
let target = ContainerTarget::docker(container_id);
|
||||
wait_until_ready(&target).await?;
|
||||
|
||||
let (out, code) = exec_oneshot_as(container_id, "root", sh(PREPARE_SCRIPT), vec![]).await?;
|
||||
let (out, code) = target
|
||||
.exec_oneshot_as("root", sh(PREPARE_SCRIPT), vec![])
|
||||
.await?;
|
||||
if code != 0 {
|
||||
return Err(format!(
|
||||
"Could not prepare the container for the marketplace sync: {}",
|
||||
tail(&out, 500)
|
||||
));
|
||||
}
|
||||
upload_bytes_to_container(
|
||||
container_id,
|
||||
INCOMING_DIR,
|
||||
"payload.tar",
|
||||
&payload.tar,
|
||||
0o644,
|
||||
)
|
||||
.await?;
|
||||
upload_bytes_to_container(
|
||||
container_id,
|
||||
INCOMING_DIR,
|
||||
"sync.sh",
|
||||
SYNC_SCRIPT.as_bytes(),
|
||||
0o755,
|
||||
)
|
||||
.await?;
|
||||
target
|
||||
.upload_bytes(INCOMING_DIR, "payload.tar", &payload.tar, 0o644)
|
||||
.await?;
|
||||
target
|
||||
.upload_bytes(INCOMING_DIR, "sync.sh", SYNC_SCRIPT.as_bytes(), 0o755)
|
||||
.await?;
|
||||
|
||||
let (stdout, stderr, code) =
|
||||
exec_oneshot_streams_as(container_id, "claude", run_script_cmd(), run_script_env()).await?;
|
||||
let (stdout, stderr, code) = target
|
||||
.exec_oneshot_streams_as("claude", run_script_cmd(), run_script_env())
|
||||
.await?;
|
||||
parse_report(&stdout).map_err(|e| {
|
||||
format!(
|
||||
"The marketplace sync script failed (exit {code}): {e}. {}",
|
||||
@@ -196,14 +201,22 @@ mod tests {
|
||||
|
||||
#[test]
|
||||
fn readiness_probes_the_entrypoints_final_exec() {
|
||||
// Both generations of entrypoint: a project keeps the base image it
|
||||
// was built from until it migrates, so the old `su … sleep` ending is
|
||||
// the common case for years and must still read as ready.
|
||||
assert_eq!(
|
||||
ready_probe_cmd(),
|
||||
vec![
|
||||
"sh",
|
||||
"-c",
|
||||
"pgrep -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null"
|
||||
"pgrep -u root -x -f 'triple-c-keepalive infinity' >/dev/null \
|
||||
|| pgrep -u root -x -f 'su -s /bin/bash claude -c exec sleep infinity' >/dev/null"
|
||||
]
|
||||
);
|
||||
// The marker name is shared with the entrypoint; a rename on one side
|
||||
// only would leave every new container "not ready" for 180 s.
|
||||
let entrypoint = include_str!("../../../../container/entrypoint.sh");
|
||||
assert!(entrypoint.contains("exec -a triple-c-keepalive sleep infinity"));
|
||||
assert_eq!(READY_POLL, Duration::from_secs(2));
|
||||
assert_eq!(READY_TIMEOUT, Duration::from_secs(180));
|
||||
}
|
||||
|
||||
@@ -89,6 +89,12 @@ pub struct GatewayStatus {
|
||||
pub running: bool,
|
||||
pub port: u16,
|
||||
pub image_exists: bool,
|
||||
/// The gateway container exists but runs something other than the pinned
|
||||
/// image (e.g. after an app update moved the pin). If a pinned image is
|
||||
/// present (`image_exists`), Stop and Start recreates it on that image;
|
||||
/// otherwise the container keeps its current image until one is pulled or
|
||||
/// built.
|
||||
pub image_outdated: bool,
|
||||
/// Number of fully-specified models in the current settings.
|
||||
pub model_count: usize,
|
||||
/// Whether a provider API key is present in the keychain.
|
||||
|
||||
@@ -1,10 +1,42 @@
|
||||
use std::fs;
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::sync::atomic::{AtomicU64, Ordering};
|
||||
use std::sync::Mutex;
|
||||
|
||||
use crate::models::marketplace::{MarketplaceInstall, MarketplaceItemRef};
|
||||
use crate::models::Project;
|
||||
|
||||
/// Proof that, at some revision of the store, a project was `Running` on a
|
||||
/// given container. See [`ProjectsStore::running_on`].
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub struct RunningSighting {
|
||||
revision: u64,
|
||||
}
|
||||
|
||||
/// What [`ProjectsStore::mark_exited`] did.
|
||||
#[derive(Debug)]
|
||||
pub enum ExitMark {
|
||||
/// Flipped to `Stopped`; the updated record.
|
||||
Flipped(Box<Project>),
|
||||
/// The store changed since the sighting while a project is still Running
|
||||
/// on that container — the caller's Docker check may be stale. Re-check.
|
||||
Changed,
|
||||
/// No project is Running on that container, or a command owns it.
|
||||
NotApplicable,
|
||||
}
|
||||
|
||||
/// The one rule for "this project is Running on that container". Both ids
|
||||
/// must be at least a short id long: a blank or truncated value is a prefix
|
||||
/// of everything and would match every container on the daemon.
|
||||
fn is_running_on(p: &Project, container_id: &str) -> bool {
|
||||
p.status == crate::models::ProjectStatus::Running
|
||||
&& container_id.len() >= 12
|
||||
&& p.container_id
|
||||
.as_deref()
|
||||
.filter(|stored| stored.len() >= 12)
|
||||
.is_some_and(|stored| stored.starts_with(container_id) || container_id.starts_with(stored))
|
||||
}
|
||||
|
||||
/// The sticky marker for `projects.json`: `projects.json.corrupt`, beside it.
|
||||
///
|
||||
/// Derived from the file rather than from `dirs::data_dir()` so the marker
|
||||
@@ -64,6 +96,8 @@ fn record_corrupt_load(file_path: &Path, now: &chrono::DateTime<chrono::Utc>) {
|
||||
pub struct ProjectsStore {
|
||||
projects: Mutex<Vec<Project>>,
|
||||
file_path: PathBuf,
|
||||
/// Bumped by every `save()`; see [`ProjectsStore::mark_exited`].
|
||||
revision: AtomicU64,
|
||||
}
|
||||
|
||||
impl ProjectsStore {
|
||||
@@ -147,6 +181,7 @@ impl ProjectsStore {
|
||||
let store = Self {
|
||||
projects: Mutex::new(projects),
|
||||
file_path,
|
||||
revision: AtomicU64::new(0),
|
||||
};
|
||||
|
||||
// Persist migrated/reconciled format back to disk
|
||||
@@ -161,11 +196,21 @@ impl ProjectsStore {
|
||||
Ok(store)
|
||||
}
|
||||
|
||||
/// A store over a file the test owns (`new()` insists on the real data dir).
|
||||
#[cfg(test)]
|
||||
pub(crate) fn over_file_for_test(file_path: PathBuf, projects: Vec<Project>) -> Self {
|
||||
Self { projects: Mutex::new(projects), file_path, revision: AtomicU64::new(0) }
|
||||
}
|
||||
|
||||
fn lock(&self) -> std::sync::MutexGuard<'_, Vec<Project>> {
|
||||
self.projects.lock().unwrap_or_else(|e| e.into_inner())
|
||||
}
|
||||
|
||||
fn save(&self, projects: &[Project]) -> Result<(), String> {
|
||||
// Every mutation saves with the lock held, so this counts every change
|
||||
// to the in-memory list — bumped first, because a failed write has
|
||||
// still changed what the list holds.
|
||||
self.revision.fetch_add(1, Ordering::SeqCst);
|
||||
let data = serde_json::to_string_pretty(projects)
|
||||
.map_err(|e| format!("Failed to serialize projects: {}", e))?;
|
||||
|
||||
@@ -237,6 +282,69 @@ impl ProjectsStore {
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Is some project `Running` on `container_id`? Checked under the lock
|
||||
/// without cloning anything — the container watcher asks this for every
|
||||
/// container death on the daemon, nearly all of them not ours.
|
||||
///
|
||||
/// The answer carries the store's revision, which [`Self::mark_exited`]
|
||||
/// needs: the caller confirms with Docker that the container really is
|
||||
/// down *between* the two calls, and that inspect can be slow.
|
||||
pub fn running_on(&self, container_id: &str) -> Option<RunningSighting> {
|
||||
let projects = self.lock();
|
||||
projects
|
||||
.iter()
|
||||
.any(|p| is_running_on(p, container_id))
|
||||
.then(|| RunningSighting { revision: self.revision.load(Ordering::SeqCst) })
|
||||
}
|
||||
|
||||
/// A container stopped without the app asking: flip the project that is
|
||||
/// `Running` on it to `Stopped`.
|
||||
///
|
||||
/// A compare-and-set under the store lock rather than a read followed by
|
||||
/// `update_status`, and against two things:
|
||||
///
|
||||
/// * **The store's revision as of `seen`.** Between [`Self::running_on`]
|
||||
/// and here the caller waited on a Docker inspect. If the user stopped
|
||||
/// and restarted the project meanwhile — same container id, stop/start
|
||||
/// model — that inspect's "not running" is about a container that has
|
||||
/// since come back, and flipping would mark a healthy project Stopped.
|
||||
/// Any write to the store in that window bumps the revision, and the
|
||||
/// answer is [`ExitMark::Changed`]: confirm again and retry.
|
||||
/// * **`is_busy`** — the project lock. Stop, Start, Reset and migration
|
||||
/// hold it across their own container's death and write the status they
|
||||
/// mean themselves. A start that takes the project lock just after this
|
||||
/// check still blocks on the store lock before it writes `Starting`.
|
||||
///
|
||||
/// `container_id` may be the short form; the store holds the full id.
|
||||
pub fn mark_exited(
|
||||
&self,
|
||||
container_id: &str,
|
||||
seen: &RunningSighting,
|
||||
is_busy: impl Fn(&str) -> bool,
|
||||
) -> ExitMark {
|
||||
let mut projects = self.lock();
|
||||
let unchanged = self.revision.load(Ordering::SeqCst) == seen.revision;
|
||||
let Some(p) = projects.iter_mut().find(|p| is_running_on(p, container_id)) else {
|
||||
return ExitMark::NotApplicable;
|
||||
};
|
||||
if !unchanged {
|
||||
return ExitMark::Changed;
|
||||
}
|
||||
if is_busy(&p.id) {
|
||||
return ExitMark::NotApplicable;
|
||||
}
|
||||
p.status = crate::models::ProjectStatus::Stopped;
|
||||
p.updated_at = chrono::Utc::now().to_rfc3339();
|
||||
let changed = p.clone();
|
||||
if let Err(e) = self.save(&projects) {
|
||||
// The in-memory record is what the UI is told about and what every
|
||||
// later command reads, so it stays flipped; the next save of any
|
||||
// kind writes it out.
|
||||
log::warn!("Could not persist Stopped for project {}: {}", changed.id, e);
|
||||
}
|
||||
ExitMark::Flipped(Box::new(changed))
|
||||
}
|
||||
|
||||
pub fn update_status(&self, id: &str, status: crate::models::ProjectStatus) -> Result<(), String> {
|
||||
let mut projects = self.lock();
|
||||
if let Some(p) = projects.iter_mut().find(|p| p.id == id) {
|
||||
@@ -437,6 +545,7 @@ mod tests {
|
||||
ProjectsStore {
|
||||
projects: Mutex::new(projects),
|
||||
file_path: dir.join("projects.json"),
|
||||
revision: AtomicU64::new(0),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -604,4 +713,138 @@ mod tests {
|
||||
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
// ── running_on / mark_exited: a container that died on its own ───────
|
||||
|
||||
/// A full-length id, as Docker's events and the store both carry.
|
||||
const C0FFEE: &str = "c0ffee0000000000000000000000000000000000000000000000000000000000";
|
||||
|
||||
fn running_on(container_id: &str) -> Project {
|
||||
let mut p = Project::new("p".to_string(), Vec::new());
|
||||
p.container_id = Some(container_id.to_string());
|
||||
p.status = crate::models::ProjectStatus::Running;
|
||||
p
|
||||
}
|
||||
|
||||
/// Sighting and flip back to back, as when Docker answers instantly.
|
||||
fn sight_and_mark(store: &ProjectsStore, cid: &str, busy: impl Fn(&str) -> bool) -> Option<Project> {
|
||||
let seen = store.running_on(cid)?;
|
||||
match store.mark_exited(cid, &seen, busy) {
|
||||
ExitMark::Flipped(p) => Some(*p),
|
||||
_ => None,
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_running_project_whose_container_exited_is_marked_stopped_and_saved() {
|
||||
let dir = temp_dir("exited");
|
||||
let p = running_on(C0FFEE);
|
||||
let id = p.id.clone();
|
||||
let store = store_over(&dir, vec![p]);
|
||||
|
||||
let changed = sight_and_mark(&store, C0FFEE, |_| false).expect("flipped");
|
||||
|
||||
assert_eq!(changed.id, id);
|
||||
assert_eq!(changed.status, crate::models::ProjectStatus::Stopped);
|
||||
assert_eq!(store.get(&id).unwrap().status, crate::models::ProjectStatus::Stopped);
|
||||
let on_disk = fs::read_to_string(dir.join("projects.json")).unwrap();
|
||||
assert!(on_disk.contains("\"stopped\""), "the flip must be persisted: {on_disk}");
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn mark_exited_leaves_a_project_alone_while_a_command_owns_it() {
|
||||
// Stop, Start-with-recreate, Reset and migration all hold the project
|
||||
// lock across their own container's death; the command writes the
|
||||
// status it means, and the watcher must not race it.
|
||||
let dir = temp_dir("busy");
|
||||
let p = running_on(C0FFEE);
|
||||
let id = p.id.clone();
|
||||
let store = store_over(&dir, vec![p]);
|
||||
|
||||
assert!(sight_and_mark(&store, C0FFEE, |pid| pid == id).is_none());
|
||||
assert_eq!(store.get(&id).unwrap().status, crate::models::ProjectStatus::Running);
|
||||
// ...and it was only the claim that held it back.
|
||||
assert!(sight_and_mark(&store, C0FFEE, |_| false).is_some());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
/// The slow-inspect race. The watcher sights the project Running, then
|
||||
/// waits on Docker; meanwhile the user stops and starts it again on the
|
||||
/// *same* container id (stop/start model). Docker's "not running" is now
|
||||
/// about a container that has come back, so the flip must not land.
|
||||
#[test]
|
||||
fn a_stop_and_start_during_the_docker_check_invalidates_the_sighting() {
|
||||
let dir = temp_dir("restarted");
|
||||
let p = running_on(C0FFEE);
|
||||
let id = p.id.clone();
|
||||
let store = store_over(&dir, vec![p]);
|
||||
|
||||
let seen = store.running_on(C0FFEE).expect("sighted");
|
||||
// ...the inspect is in flight...
|
||||
store.update_status(&id, crate::models::ProjectStatus::Stopping).unwrap();
|
||||
store.update_status(&id, crate::models::ProjectStatus::Stopped).unwrap();
|
||||
store.update_status(&id, crate::models::ProjectStatus::Starting).unwrap();
|
||||
store.update_status(&id, crate::models::ProjectStatus::Running).unwrap();
|
||||
|
||||
assert!(matches!(store.mark_exited(C0FFEE, &seen, |_| false), ExitMark::Changed));
|
||||
assert_eq!(store.get(&id).unwrap().status, crate::models::ProjectStatus::Running);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_stale_sighting_of_a_project_no_longer_running_is_simply_not_applicable() {
|
||||
let dir = temp_dir("gone");
|
||||
let p = running_on(C0FFEE);
|
||||
let id = p.id.clone();
|
||||
let store = store_over(&dir, vec![p]);
|
||||
|
||||
let seen = store.running_on(C0FFEE).expect("sighted");
|
||||
store.update_status(&id, crate::models::ProjectStatus::Stopped).unwrap();
|
||||
|
||||
assert!(matches!(store.mark_exited(C0FFEE, &seen, |_| false), ExitMark::NotApplicable));
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_running_projects_on_that_container_are_sighted_or_touched() {
|
||||
let dir = temp_dir("other");
|
||||
let mut starting = running_on(C0FFEE);
|
||||
starting.status = crate::models::ProjectStatus::Starting;
|
||||
let other = running_on(&"b".repeat(64));
|
||||
let (starting_id, other_id) = (starting.id.clone(), other.id.clone());
|
||||
let store = store_over(&dir, vec![starting, other]);
|
||||
|
||||
assert!(store.running_on(C0FFEE).is_none(), "Starting is a command's");
|
||||
assert!(store.running_on(&"d".repeat(64)).is_none(), "nobody's container");
|
||||
assert!(store.running_on("").is_none());
|
||||
assert!(sight_and_mark(&store, C0FFEE, |_| false).is_none());
|
||||
assert_eq!(
|
||||
store.get(&starting_id).unwrap().status,
|
||||
crate::models::ProjectStatus::Starting
|
||||
);
|
||||
assert_eq!(store.get(&other_id).unwrap().status, crate::models::ProjectStatus::Running);
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_blank_or_truncated_stored_id_matches_no_container() {
|
||||
// A blank stored id is a prefix of everything.
|
||||
let dir = temp_dir("blank");
|
||||
let store = store_over(&dir, vec![running_on(""), running_on("abc")]);
|
||||
assert!(store.running_on(&"f".repeat(64)).is_none());
|
||||
assert!(store.running_on("abcdef0123456789").is_none());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_short_id_is_matched_against_the_stored_full_one() {
|
||||
let dir = temp_dir("short");
|
||||
let full = "a".repeat(12) + &"b".repeat(52);
|
||||
let store = store_over(&dir, vec![running_on(&full)]);
|
||||
|
||||
assert!(store.running_on("aaaa").is_none(), "too short to be an id");
|
||||
assert!(sight_and_mark(&store, &full[..12], |_| false).is_some());
|
||||
fs::remove_dir_all(&dir).ok();
|
||||
}
|
||||
}
|
||||
@@ -8,7 +8,7 @@ use serde::{Deserialize, Serialize};
|
||||
use tokio::sync::watch;
|
||||
use tower_http::cors::CorsLayer;
|
||||
|
||||
use crate::docker::exec::ExecSessionManager;
|
||||
use crate::host::ExecSessionManager;
|
||||
use crate::storage::projects_store::ProjectsStore;
|
||||
use crate::storage::settings_store::SettingsStore;
|
||||
|
||||
|
||||
@@ -295,9 +295,7 @@ async fn handle_open(
|
||||
return Err(format!("Project '{}' is not running", project.name));
|
||||
}
|
||||
|
||||
let container_id = project
|
||||
.container_id
|
||||
.as_ref()
|
||||
let container = crate::host::ContainerTarget::for_project(&project)
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
let cmd = match session_type {
|
||||
@@ -329,7 +327,13 @@ async fn handle_open(
|
||||
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(container_id, &session_id, cmd, on_output, on_exit)
|
||||
.create_session(
|
||||
&container,
|
||||
&session_id,
|
||||
cmd,
|
||||
on_output,
|
||||
on_exit,
|
||||
)
|
||||
.await?;
|
||||
|
||||
// Track this session for cleanup on disconnect
|
||||
|
||||
@@ -35,7 +35,12 @@
|
||||
"icons/icon.ico",
|
||||
"icons/icon.icns",
|
||||
"icons/icon.png"
|
||||
]
|
||||
],
|
||||
"linux": {
|
||||
"appimage": {
|
||||
"bundleMediaFramework": true
|
||||
}
|
||||
}
|
||||
},
|
||||
"plugins": {}
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { useSTT } from "./hooks/useSTT";
|
||||
import { useContainerProgress } from "./hooks/useContainerProgress";
|
||||
import { useKeyboardShortcuts } from "./hooks/useKeyboardShortcuts";
|
||||
import { useMarketplaceSyncToasts } from "./hooks/useMarketplace";
|
||||
import { useProjectStatusEvents } from "./hooks/useProjectStatusEvents";
|
||||
import MarketplaceView from "./components/marketplace/MarketplaceView";
|
||||
import { useAppState, isHomeTab, tabKeyId, homeTabKey, MARKETPLACE_TAB_KEY } from "./store/appState";
|
||||
import { reconcileProjectStatuses } from "./lib/tauri-commands";
|
||||
@@ -75,6 +76,7 @@ export default function App() {
|
||||
useContainerProgress();
|
||||
useKeyboardShortcuts();
|
||||
useMarketplaceSyncToasts();
|
||||
useProjectStatusEvents();
|
||||
|
||||
// Initialize on mount
|
||||
useEffect(() => {
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("../../hooks/useProjectActions", () => ({
|
||||
handleBackup: vi.fn(),
|
||||
openClaudeTerminal: mockOpenClaudeTerminal,
|
||||
openShell: vi.fn(),
|
||||
resumeClaudeSession: vi.fn(),
|
||||
openTerminalWithCommand: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
listClaudeSessions,
|
||||
listScheduledTasks,
|
||||
getSchedulerNotifications,
|
||||
resumeSessionCommand,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import type { SaveState } from "../../../hooks/useSaveState";
|
||||
@@ -78,14 +77,8 @@ export default function OverviewTab({
|
||||
};
|
||||
}, [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);
|
||||
}
|
||||
};
|
||||
const handleResume = (session: ClaudeSession) =>
|
||||
actions.resumeClaudeSession(session.id, session.name ?? "resume");
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6 max-w-4xl">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ClaudeSession, Project } from "../../../lib/types";
|
||||
import { listClaudeSessions, resumeSessionCommand } from "../../../lib/tauri-commands";
|
||||
import { listClaudeSessions } 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";
|
||||
|
||||
@@ -18,7 +17,6 @@ interface Props {
|
||||
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(() => {
|
||||
@@ -35,18 +33,8 @@ export default function SessionsTab({ project, actions }: Props) {
|
||||
|
||||
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),
|
||||
});
|
||||
}
|
||||
};
|
||||
const resume = (session: ClaudeSession) =>
|
||||
actions.resumeClaudeSession(session.id, session.name ?? "resume");
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-4xl">
|
||||
|
||||
@@ -39,6 +39,7 @@ const status = (over: Partial<GatewayStatus> = {}): GatewayStatus => ({
|
||||
running: true,
|
||||
port: 4000,
|
||||
image_exists: true,
|
||||
image_outdated: false,
|
||||
model_count: 0,
|
||||
has_api_key: false,
|
||||
base_url: "http://host.docker.internal:4000",
|
||||
@@ -102,4 +103,35 @@ describe("GatewaySettings", () => {
|
||||
);
|
||||
await waitFor(() => expect(getGatewayStatus).toHaveBeenCalledTimes(2));
|
||||
});
|
||||
it("can stop a gateway running an outdated image, and says what to do", async () => {
|
||||
appSettings = settingsWithGateway(true);
|
||||
getGatewayStatus.mockResolvedValue(status({ image_outdated: true, image_exists: false }));
|
||||
render(<GatewaySettings />);
|
||||
|
||||
const stop = await screen.findByRole("button", { name: "Stop" });
|
||||
expect(screen.getByText(/image update available, Pull or Build/i)).toBeInTheDocument();
|
||||
|
||||
fireEvent.click(stop);
|
||||
await waitFor(() => expect(stopGateway).toHaveBeenCalledTimes(1));
|
||||
});
|
||||
|
||||
it("says Stop and Start applies a new image once it is present", async () => {
|
||||
appSettings = settingsWithGateway(true);
|
||||
getGatewayStatus.mockResolvedValue(status({ image_outdated: true }));
|
||||
render(<GatewaySettings />);
|
||||
|
||||
expect(await screen.findByText(/image ready, Stop and Start/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it("can restart a stopped gateway on its old image when no new image is present", async () => {
|
||||
appSettings = settingsWithGateway(true);
|
||||
getGatewayStatus.mockResolvedValue(
|
||||
status({ running: false, image_exists: false, image_outdated: true }),
|
||||
);
|
||||
render(<GatewaySettings />);
|
||||
|
||||
expect(await screen.findByRole("button", { name: "Start" })).toBeInTheDocument();
|
||||
// Starting it keeps the old LiteLLM, so say so.
|
||||
expect(screen.getByText(/Stopped — gateway image update available/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
@@ -178,24 +178,29 @@ export default function GatewaySettings() {
|
||||
setTimeout(() => setCopied(null), 2000);
|
||||
};
|
||||
|
||||
const tone: StatusTone = !status?.image_exists
|
||||
? "off"
|
||||
: status.running
|
||||
? healthy === false
|
||||
? "busy"
|
||||
: "running"
|
||||
: status.container_exists
|
||||
? "stopped"
|
||||
: "off";
|
||||
// A running container is shown (and stoppable) whatever its image.
|
||||
const tone: StatusTone = status?.running
|
||||
? healthy === false
|
||||
? "busy"
|
||||
: "running"
|
||||
: status?.container_exists
|
||||
? "stopped"
|
||||
: "off";
|
||||
|
||||
const statusLabel = !status?.image_exists
|
||||
? "No image"
|
||||
: status.running
|
||||
? healthy === false
|
||||
? "Starting…"
|
||||
const statusLabel = status?.running
|
||||
? healthy === false
|
||||
? "Starting…"
|
||||
: status.image_outdated
|
||||
? status.image_exists
|
||||
? `Running on port ${status.port} — new gateway image ready, Stop and Start to apply`
|
||||
: `Running on port ${status.port} — gateway image update available, Pull or Build to get it`
|
||||
: `Running on port ${status.port}`
|
||||
: status.container_exists
|
||||
? "Stopped"
|
||||
: status?.container_exists
|
||||
? status.image_outdated && !status.image_exists
|
||||
? "Stopped — gateway image update available, Pull or Build to get it"
|
||||
: "Stopped"
|
||||
: !status?.image_exists
|
||||
? "No image"
|
||||
: "Image ready";
|
||||
|
||||
// Rendered in whichever branch is live — only one of them ever mounts.
|
||||
@@ -254,7 +259,9 @@ export default function GatewaySettings() {
|
||||
{/* ── Container ─────────────────────────────────────────────── */}
|
||||
<div className="flex items-center gap-3 flex-wrap">
|
||||
<StatusIndicator tone={tone} label={statusLabel} className="text-xs" />
|
||||
{status?.image_exists && (
|
||||
{/* A stopped container can restart on the image it has, so Start
|
||||
needs either that container or a pinned image. */}
|
||||
{(status?.image_exists || status?.container_exists) && (
|
||||
<Button
|
||||
variant={status.running ? "danger" : "primary"}
|
||||
disabled={loading}
|
||||
|
||||
@@ -60,6 +60,8 @@ const ptyOutput = vi.hoisted(() => ({
|
||||
const xterm = vi.hoisted(() => ({
|
||||
options: null as Record<string, unknown> | null,
|
||||
instances: [] as unknown[],
|
||||
/** Every link provider registered on any terminal, for asking what gets underlined. */
|
||||
linkProviders: [] as import("@xterm/xterm").ILinkProvider[],
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -82,6 +84,10 @@ vi.mock("@xterm/xterm", async (importOriginal) => {
|
||||
xterm.options = (options ?? null) as Record<string, unknown> | null;
|
||||
xterm.instances.push(this);
|
||||
}
|
||||
override registerLinkProvider(provider: import("@xterm/xterm").ILinkProvider) {
|
||||
xterm.linkProviders.push(provider);
|
||||
return super.registerLinkProvider(provider);
|
||||
}
|
||||
}
|
||||
return { ...actual, Terminal: SpyTerminal };
|
||||
});
|
||||
@@ -1974,3 +1980,151 @@ describe("the plain-text URL path is gated the same way", () => {
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The underline is the link: `WebLinksAddon` paints exactly what its regex
|
||||
* matched and hands that same text to the click handler. So trailing prose
|
||||
* punctuation has to be kept out of the *match*, not trimmed afterwards —
|
||||
* otherwise `See https://x/docs.` underlines the full stop and opens it.
|
||||
*/
|
||||
describe("plain-text URLs stop before trailing punctuation", () => {
|
||||
function term() {
|
||||
return xterm.instances.at(-1) as unknown as {
|
||||
write(d: string, cb: () => void): void;
|
||||
};
|
||||
}
|
||||
|
||||
async function write(data: string) {
|
||||
await act(() => new Promise<void>((resolve) => term().write(data, resolve)));
|
||||
}
|
||||
|
||||
/** The links every registered provider reports for buffer row 1. */
|
||||
async function linksOnFirstRow() {
|
||||
const found: Array<{ text: string; startX: number; endX: number }> = [];
|
||||
for (const provider of xterm.linkProviders) {
|
||||
await new Promise<void>((resolve) =>
|
||||
provider.provideLinks(1, (links) => {
|
||||
for (const l of links ?? []) {
|
||||
found.push({ text: l.text, startX: l.range.start.x, endX: l.range.end.x });
|
||||
}
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
}
|
||||
return found.filter((l) => l.text.startsWith("http"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
xterm.linkProviders.length = 0;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["See https://example.com/docs.", "https://example.com/docs", 5],
|
||||
["(https://example.com/foo)", "https://example.com/foo", 2],
|
||||
["[text](https://x.example/z).", "https://x.example/z", 8],
|
||||
["<https://x.example/a>", "https://x.example/a", 2],
|
||||
["https://en.wikipedia.org/wiki/Foo_(bar)", "https://en.wikipedia.org/wiki/Foo_(bar)", 1],
|
||||
])("underlines only the URL in %j", async (line, url, startX) => {
|
||||
mountSession("bash");
|
||||
await write(line);
|
||||
|
||||
expect(await linksOnFirstRow()).toEqual([
|
||||
{ text: url, startX, endX: startX + url.length - 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — copying text out", () => {
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
async function emitBytes(bytes: Uint8Array) {
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({ payload: Array.from(bytes) });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
const utf8 = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
function pressCopy(container: HTMLElement, altKey = false) {
|
||||
helperTextarea(container).dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "C",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("puts OSC 52 text on the clipboard as UTF-8, not mojibake", async () => {
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
// What the xclip shim (`printf '%s' | base64`) and Claude Code both send.
|
||||
const text = "it’s café — naïve 日本語\u00A0👍";
|
||||
const b64 = Buffer.from(text, "utf8").toString("base64");
|
||||
await emitBytes(utf8(`\x1b]52;c;${b64}\x07`));
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
// NBSP normalized; everything else exactly as sent.
|
||||
expect(writeText).toHaveBeenCalledWith("it’s café — naïve 日本語 👍");
|
||||
});
|
||||
|
||||
it("does not write anything for an OSC 52 read request", async () => {
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
await emitBytes(utf8("\x1b]52;c;?\x07"));
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Claude Code's own hard wrap: it moves to the next row itself (CR + cursor
|
||||
// moves in the real stream), so the rows are not `isWrapped` and the
|
||||
// selection carries a newline at every break.
|
||||
const CLAUDE_ROWS = [
|
||||
"● The café’s naïve résumé handling means the parser — which was",
|
||||
" written years ago by someone who has since left the team — silently",
|
||||
" drops every accented character it cannot map.",
|
||||
];
|
||||
|
||||
it("Ctrl+Shift+C rejoins Claude's wrapped rows and drops its gutter", async () => {
|
||||
const { container } = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const term = xterm.instances[0] as Terminal;
|
||||
act(() => term.resize(70, 24));
|
||||
await emitBytes(utf8(CLAUDE_ROWS.join("\r\n")));
|
||||
term.selectAll();
|
||||
|
||||
pressCopy(container);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
);
|
||||
});
|
||||
|
||||
it("Ctrl+Shift+Alt+C copies the selection raw", async () => {
|
||||
const { container } = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const term = xterm.instances[0] as Terminal;
|
||||
act(() => term.resize(70, 24));
|
||||
await emitBytes(utf8(CLAUDE_ROWS.join("\r\n")));
|
||||
term.selectAll();
|
||||
|
||||
pressCopy(container, true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
expect(writeText.mock.calls[0][0]).toBe(term.getSelection());
|
||||
expect(writeText.mock.calls[0][0]).toContain("● The café’s");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
|
||||
import { TERMINAL_URL_REGEX } from "../../lib/urlMatch";
|
||||
import {
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
@@ -31,7 +32,8 @@ import UrlToast, {
|
||||
URL_TOAST_SELECTOR,
|
||||
URL_TOAST_SHORTCUT,
|
||||
} from "./UrlToast";
|
||||
import { trimSelection } from "./trimSelection";
|
||||
import { cleanSelection, sanitizeInvisibles, type CopyGeometry } from "./copyCleanup";
|
||||
import { decodeOsc52 } from "./osc52";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
import TerminalContextMenu from "./TerminalContextMenu";
|
||||
import { createFilePathLinkProvider } from "./filePathLinkProvider";
|
||||
@@ -122,6 +124,16 @@ export function supersedes(
|
||||
*/
|
||||
export const OSC8_HOVER_CLASS = "xterm-hover";
|
||||
|
||||
/**
|
||||
* What `cleanSelection` needs to tell Claude's hard wraps from real line
|
||||
* breaks: the width it wrapped to, and where the selection's first row began.
|
||||
* `getSelectionPosition().start.x` is documented as 1-based but xterm 5.5
|
||||
* returns the selection model's 0-based column — which is what is wanted.
|
||||
*/
|
||||
function selectionGeometry(term: Terminal): CopyGeometry {
|
||||
return { cols: term.cols, startCol: term.getSelectionPosition()?.start.x ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failed handoff to the host's browser.
|
||||
*
|
||||
@@ -1069,9 +1081,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
||||
// that matches URLs even if they lack trailing path segments (the default regex
|
||||
// misses OAuth URLs that end mid-line).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
||||
// misses OAuth URLs that end mid-line). `TERMINAL_URL_REGEX` also leaves
|
||||
// trailing prose punctuation and wrapping brackets out of the match, which
|
||||
// is what the addon underlines *and* what it hands the handler below.
|
||||
const urlRegex = TERMINAL_URL_REGEX;
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
// Same gate and same sink as `createOsc8LinkHandler`, because this
|
||||
// reaches the same `openUrlExternal` through the same
|
||||
@@ -1126,15 +1139,17 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
|
||||
// stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw.
|
||||
// Ctrl+Shift+C copies the selection cleaned up for pasting (UI padding,
|
||||
// Claude's gutter markers and hard wraps, and invisible characters removed;
|
||||
// internal indentation preserved — see `copyCleanup.ts`). Ctrl+Shift+Alt+C
|
||||
// copies raw.
|
||||
// Both prevent the keystroke from reaching the container (where Ctrl+C
|
||||
// would send SIGINT and cancel running work).
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
const out = event.altKey ? sel : trimSelection(sel);
|
||||
const out = event.altKey ? sel : cleanSelection(sel, selectionGeometry(term));
|
||||
navigator.clipboard.writeText(out).catch((e) =>
|
||||
console.error("Ctrl+Shift+C clipboard write failed:", e),
|
||||
);
|
||||
@@ -1214,16 +1229,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return false;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return false; // clipboard read request, not supported
|
||||
try {
|
||||
const decoded = atob(payload);
|
||||
navigator.clipboard.writeText(decoded).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("OSC 52 decode failed:", e);
|
||||
if (data.substring(idx + 1) === "?") return false; // clipboard read request, not supported
|
||||
// Decoded as UTF-8 — `atob` alone mojibaked every non-ASCII character.
|
||||
// Only invisible characters are normalized: the program chose this text,
|
||||
// so it is not dedented or reflowed the way a terminal selection is.
|
||||
const decoded = decodeOsc52(data);
|
||||
if (decoded === null) {
|
||||
console.error("OSC 52 decode failed: invalid base64");
|
||||
return true;
|
||||
}
|
||||
navigator.clipboard.writeText(sanitizeInvisibles(decoded)).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1625,7 +1642,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) return;
|
||||
const out = mode === "raw" ? sel : trimSelection(sel);
|
||||
const out = mode === "raw" ? sel : cleanSelection(sel, selectionGeometry(term));
|
||||
navigator.clipboard.writeText(out).catch((e) =>
|
||||
console.error("Context menu clipboard write failed:", e),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
sanitizeInvisibles,
|
||||
stripClaudeGutter,
|
||||
rejoinHardWraps,
|
||||
cleanSelection,
|
||||
cellWidth,
|
||||
} from "./copyCleanup";
|
||||
|
||||
/**
|
||||
* Rows of a real Claude Code 2.1.316 reply, captured from a 70-column pty and
|
||||
* replayed through @xterm/headless 5.5 (what `getSelection()` would return for
|
||||
* a drag from column 0 of the first row to the end of the last). Claude
|
||||
* hard-wraps its own output — a real `\r` + cursor move between rows, never an
|
||||
* xterm soft wrap, so every row has `isWrapped === false` and the selection
|
||||
* carries a `\n` at each of Claude's breaks. Note the URL split mid-token at
|
||||
* exactly column 70, and the code line Claude also wrapped at a space.
|
||||
*/
|
||||
const CLAUDE_ROWS_70 = [
|
||||
"● The café’s naïve résumé handling means the parser — which was",
|
||||
" written years ago by someone who has since left the team — silently",
|
||||
" drops every accented character it cannot map.",
|
||||
"",
|
||||
" See https://example.com/a/very/long/path/that/keeps/going/and/going/",
|
||||
" until/it/wraps/index.html?query=1 for details.",
|
||||
"",
|
||||
" - The first item is a long sentence that describes one important",
|
||||
" consideration about the parser in considerable detail, so that it",
|
||||
" lands near twenty-five words.",
|
||||
" - The second item is another long sentence that explains the second",
|
||||
" consideration for the reader, written to contain roughly the same",
|
||||
" number of words as the first.",
|
||||
"",
|
||||
" curl -fsSL https://example.com/install.sh | bash -s --",
|
||||
" --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
];
|
||||
|
||||
describe("sanitizeInvisibles", () => {
|
||||
it("turns non-breaking and odd-width spaces into plain spaces", () => {
|
||||
expect(sanitizeInvisibles("git\u00A0status")).toBe("git status");
|
||||
expect(sanitizeInvisibles("a\u202Fb\u2007c\u2002d\u2009e\u200Af\u205Fg")).toBe("a b c d e f g");
|
||||
});
|
||||
|
||||
it("removes zero-width spaces, word joiners and BOMs", () => {
|
||||
expect(sanitizeInvisibles("\uFEFFnpm\u200B run\u2060 build")).toBe("npm run build");
|
||||
});
|
||||
|
||||
it("removes ZWJ/ZWNJ next to ASCII, where they only break commands", () => {
|
||||
expect(sanitizeInvisibles("ls\u200D -la\u200C")).toBe("ls -la");
|
||||
});
|
||||
|
||||
it("keeps ZWJ inside emoji sequences and ZWNJ inside non-Latin words", () => {
|
||||
const family = "👨\u200D👩\u200D👧";
|
||||
expect(sanitizeInvisibles(`ok ${family} ok`)).toBe(`ok ${family} ok`);
|
||||
const persian = "می\u200Cخواهم"; // ZWNJ is orthographically required here
|
||||
expect(sanitizeInvisibles(persian)).toBe(persian);
|
||||
const devanagari = "क्\u200Dष";
|
||||
expect(sanitizeInvisibles(devanagari)).toBe(devanagari);
|
||||
});
|
||||
|
||||
it("leaves legitimate non-ASCII text alone", () => {
|
||||
const text = "it’s café — naïve “quotes” 日本語\u3000テキスト 👍🏽 ● ⎿ │";
|
||||
expect(sanitizeInvisibles(text)).toBe(text); // U+3000 ideographic space kept
|
||||
});
|
||||
|
||||
it("turns Unicode line/paragraph separators into newlines", () => {
|
||||
expect(sanitizeInvisibles("a\u2028b\u2029c")).toBe("a\nb\nc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cellWidth", () => {
|
||||
it("counts wide CJK and emoji as two cells and zero-width marks as none", () => {
|
||||
expect(cellWidth("abc")).toBe(3);
|
||||
expect(cellWidth("日本")).toBe(4);
|
||||
expect(cellWidth("👍")).toBe(2);
|
||||
expect(cellWidth("é")).toBe(1);
|
||||
expect(cellWidth("—●⎿")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripClaudeGutter", () => {
|
||||
it("blanks Claude's message bullet at column 0, keeping columns", () => {
|
||||
expect(stripClaudeGutter("● Hello\n world", 0)).toBe(" Hello\n world");
|
||||
});
|
||||
|
||||
it("blanks the tool-result elbow", () => {
|
||||
expect(stripClaudeGutter("● Bash(ls)\n ⎿ a.txt\n b.txt", 0)).toBe(
|
||||
" Bash(ls)\n a.txt\n b.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch a bullet that is not in the gutter", () => {
|
||||
expect(stripClaudeGutter(" ● item", 0)).toBe(" ● item");
|
||||
// Selection started mid-row: the first line's column 0 is not the gutter.
|
||||
expect(stripClaudeGutter("● x", 5)).toBe("● x");
|
||||
});
|
||||
|
||||
it("strips box borders only when every line is inside the box", () => {
|
||||
const box = [
|
||||
"╭────────────────╮",
|
||||
"│ Do you want to │",
|
||||
"│ proceed? │",
|
||||
"╰────────────────╯",
|
||||
].join("\n");
|
||||
expect(stripClaudeGutter(box, 0)).toBe(" Do you want to\n proceed?");
|
||||
});
|
||||
|
||||
it("leaves tables and partial boxes alone", () => {
|
||||
const table = "│ a │ b │\n├───┼───┤\n│ 1 │ 2 │";
|
||||
expect(stripClaudeGutter(table, 0)).toBe(table);
|
||||
const mixed = "│ inside │\noutside";
|
||||
expect(stripClaudeGutter(mixed, 0)).toBe(mixed);
|
||||
});
|
||||
|
||||
it("leaves a selection of only a table's body rows alone", () => {
|
||||
const body = "│ a │ b │\n│ 1 │ 2 │";
|
||||
expect(stripClaudeGutter(body, 0)).toBe(body);
|
||||
expect(stripClaudeGutter("│ only │ row │", 0)).toBe("│ only │ row │");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejoinHardWraps", () => {
|
||||
it("rejoins a real Claude Code reply captured at 70 columns", () => {
|
||||
const sel = stripClaudeGutter(CLAUDE_ROWS_70.join("\n"), 0);
|
||||
expect(rejoinHardWraps(sel, { cols: 70 })).toBe(
|
||||
[
|
||||
" The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
"",
|
||||
" See https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.",
|
||||
"",
|
||||
" - The first item is a long sentence that describes one important consideration about the parser in considerable detail, so that it lands near twenty-five words.",
|
||||
" - The second item is another long sentence that explains the second consideration for the reader, written to contain roughly the same number of words as the first.",
|
||||
"",
|
||||
" curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejoins Claude's echo of a submitted prompt, which wraps one column short", () => {
|
||||
// Same capture: the transcript's echo of the prompt that was typed.
|
||||
const echo = [
|
||||
"❯ Reply with exactly the following markdown and nothing else (no",
|
||||
" tools). First a paragraph: \"The café’s naïve résumé handling means",
|
||||
" the parser — which was written years ago by someone who has since",
|
||||
" left the team — silently drops every accented character it cannot",
|
||||
" map.\" Then a line: \"See https://example.com/a/very/long/path/that/k",
|
||||
" eeps/going/and/going/until/it/wraps/index.html?query=1 for",
|
||||
" details.\" Then a bullet list with two items, each a long sentence",
|
||||
" of about 25 words. Then a bash code block containing: curl -fsSL",
|
||||
" https://example.com/install.sh | bash -s --",
|
||||
" --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
].join("\n");
|
||||
const typed =
|
||||
"Reply with exactly the following markdown and nothing else (no tools). First a paragraph: \"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.\" Then a line: \"See https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\" Then a bullet list with two items, each a long sentence of about 25 words. Then a bash code block containing: curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes";
|
||||
expect(rejoinHardWraps(echo, { cols: 70 })).toBe(`❯ ${typed}`);
|
||||
});
|
||||
|
||||
it("joins a word that exactly filled the row with a space", () => {
|
||||
// 70 cells: the last token ("wordabc") and the next ("next") are short,
|
||||
// so this was a break at a space that happened to land on the edge.
|
||||
const full = " " + "x".repeat(60) + " wordabc";
|
||||
expect(cellWidth(full)).toBe(70);
|
||||
expect(rejoinHardWraps(`${full}\n next words`, { cols: 70 })).toBe(`${full} next words`);
|
||||
});
|
||||
|
||||
it("does not join short lines (code, prose that ended early)", () => {
|
||||
const code = " def foo():\n return 1\n print(foo())";
|
||||
expect(rejoinHardWraps(code, { cols: 70 })).toBe(code);
|
||||
const prose = " This sentence ends well before the edge.\n So does this one.";
|
||||
expect(rejoinHardWraps(prose, { cols: 70 })).toBe(prose);
|
||||
});
|
||||
|
||||
it("does not join a line whose next word would have fitted", () => {
|
||||
const line = " " + "a ".repeat(30).trimEnd(); // 61 cells
|
||||
expect(rejoinHardWraps(`${line}\n bb`, { cols: 70 })).toBe(`${line}\n bb`);
|
||||
});
|
||||
|
||||
it("does not join across a change of indentation", () => {
|
||||
const a = " " + "word ".repeat(13).trimEnd(); // 66 cells
|
||||
expect(rejoinHardWraps(`${a}\n indented-continuation`, { cols: 70 })).toBe(
|
||||
`${a}\n indented-continuation`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not join into the next list item or a border", () => {
|
||||
const a = " - " + "word ".repeat(12).trimEnd(); // 63 cells
|
||||
const next = " - another item with a longword";
|
||||
expect(rejoinHardWraps(`${a}\n${next}`, { cols: 70 })).toBe(`${a}\n${next}`);
|
||||
expect(rejoinHardWraps(`${a}\n │ table`, { cols: 70 })).toBe(`${a}\n │ table`);
|
||||
});
|
||||
|
||||
it("leaves column-0 shell output alone (ls-style columns, plain output)", () => {
|
||||
const ls =
|
||||
"Cargo.lock Cargo.toml README.md build.rs capabilities gen icons s\nsrc target tauri.conf.json";
|
||||
expect(rejoinHardWraps(ls, { cols: 70 })).toBe(ls);
|
||||
const plain = "x".repeat(65) + "\nyyyyyyyyyy";
|
||||
expect(rejoinHardWraps(plain, { cols: 70 })).toBe(plain);
|
||||
});
|
||||
|
||||
it("leaves tabular rows alone even when indented", () => {
|
||||
const t = " NAME STATUS PORTS CREATED AT SIZE\n triple-c-abc running 8080/tcp";
|
||||
expect(rejoinHardWraps(t, { cols: 70 })).toBe(t);
|
||||
});
|
||||
|
||||
it("joins wide-character text without inserting a space", () => {
|
||||
const a = " " + "日本語".repeat(11); // 2 + 66 = 68 cells; the next wide char needs 2 more
|
||||
expect(cellWidth(a)).toBe(68);
|
||||
expect(rejoinHardWraps(`${a}\n の続き`, { cols: 69 })).toBe(`${a}の続き`);
|
||||
});
|
||||
|
||||
it("joins astral wide characters (CJK Ext-B) without inserting a space", () => {
|
||||
const a = " " + "\u{20000}".repeat(33); // 2 + 66 = 68 cells
|
||||
expect(cellWidth(a)).toBe(68);
|
||||
expect(rejoinHardWraps(`${a}\n \u{20001}\u{20002}`, { cols: 69 })).toBe(`${a}\u{20001}\u{20002}`);
|
||||
const e = " " + "\u{1F600}".repeat(33); // emoji, also two cells each
|
||||
expect(rejoinHardWraps(`${e}\n \u{1F601}`, { cols: 69 })).toBe(`${e}\u{1F601}`);
|
||||
});
|
||||
|
||||
it("keeps the space when a short word ended at the edge and a URL that fits was moved down whole", () => {
|
||||
const prev = " " + "x".repeat(64) + " the";
|
||||
expect(cellWidth(prev)).toBe(70);
|
||||
const url = "https://example.com/" + "p".repeat(35) + "/index.html";
|
||||
expect(url.length).toBe(66); // fits in the 68 cells after the gutter
|
||||
expect(rejoinHardWraps(`${prev}\n ${url}`, { cols: 70 })).toBe(`${prev} ${url}`);
|
||||
});
|
||||
|
||||
it("still joins without a space when a row-filling piece of a long token is followed by a URL start", () => {
|
||||
// A redirect URL whose split happened to land just before its inner `https://`.
|
||||
const url = "https://r.example/" + "r".repeat(47) + "?u=https://inner.example/x";
|
||||
const rows = [" " + url.slice(0, 68), " " + url.slice(68) + " ok"];
|
||||
expect(cellWidth(rows[0])).toBe(70);
|
||||
expect(rows[1].startsWith(" https://")).toBe(true);
|
||||
expect(rejoinHardWraps(rows.join("\n"), { cols: 70 })).toBe(` ${url} ok`);
|
||||
});
|
||||
|
||||
it("rejoins a URL split over three rows without spaces", () => {
|
||||
const url = "https://example.com/" + "q".repeat(130) + "/end.html";
|
||||
const rows = [" See " + url.slice(0, 64), " " + url.slice(64, 132), " " + url.slice(132) + " ok"];
|
||||
expect(rows.map(cellWidth).slice(0, 2)).toEqual([70, 70]);
|
||||
expect(rejoinHardWraps(rows.join("\n"), { cols: 70 })).toBe(` See ${url} ok`);
|
||||
});
|
||||
|
||||
it("uses the selection's start column for a first line that began mid-row", () => {
|
||||
// Selection started at column 18, so the first line is only 51
|
||||
// characters but ended at column 69 of its row.
|
||||
const first = "written years ago by someone who has since left the";
|
||||
expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70, startCol: 18 })).toBe(
|
||||
`${first} team — silently`,
|
||||
);
|
||||
// Without the start column it looks short and is left alone.
|
||||
expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70 })).toBe(
|
||||
`${first}\n team — silently`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves text unchanged when cols is unknown", () => {
|
||||
const sel = CLAUDE_ROWS_70.join("\n");
|
||||
expect(rejoinHardWraps(sel, { cols: 0 })).toBe(sel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanSelection", () => {
|
||||
it("produces paste-ready text from a Claude reply (gutter, wraps, dedent)", () => {
|
||||
const out = cleanSelection(CLAUDE_ROWS_70.join("\n"), { cols: 70 });
|
||||
expect(out.split("\n")[0]).toBe(
|
||||
"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
);
|
||||
expect(out).toContain(
|
||||
"\nSee https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\n",
|
||||
);
|
||||
expect(out.endsWith("\ncurl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes Windows line endings from xterm's getSelection", () => {
|
||||
expect(cleanSelection(" a\r\n b\r\n", { cols: 70 })).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("strips invisible characters", () => {
|
||||
expect(cleanSelection(" git\u202Fcommit\u200B -m x", { cols: 70 })).toBe("git commit -m x");
|
||||
});
|
||||
|
||||
it("keeps the old trimSelection behaviour for ordinary text", () => {
|
||||
expect(cleanSelection(" def foo():\n return 1\n", { cols: 80 })).toBe(
|
||||
"def foo():\n return 1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { trimSelection } from "./trimSelection";
|
||||
|
||||
/**
|
||||
* Clean-up applied to text copied out of a terminal, so that pasting it into
|
||||
* an editor, a shell or a chat works.
|
||||
*
|
||||
* Three separate problems, fixed by separate pure functions:
|
||||
*
|
||||
* - **Invisible characters** (`sanitizeInvisibles`). Claude Code prints
|
||||
* `❯` + U+00A0 for its prompt, and model output can carry NBSP / narrow
|
||||
* NBSP / zero-width spaces; pasted into a shell they make `git status` an
|
||||
* unknown command. xterm's `getSelection()` already maps U+00A0 (only that
|
||||
* one) to a space; everything else survived until now.
|
||||
* - **Claude's TUI chrome** (`stripClaudeGutter`): the `●` message bullet and
|
||||
* `⎿` tool-result elbow in the left gutter, and `│ … │` dialog borders.
|
||||
* - **Claude's hard wraps** (`rejoinHardWraps`). Claude Code wraps its own
|
||||
* output to the terminal width and moves the cursor to the next row itself,
|
||||
* so xterm never marks those rows `isWrapped` and a selection carries a real
|
||||
* newline (plus the 2-column gutter indent) at every break — mid-sentence,
|
||||
* and mid-URL when a long token was split at the edge.
|
||||
*
|
||||
* `cleanSelection` composes them with `trimSelection` for the trimmed copy
|
||||
* (Ctrl+Shift+C, context-menu "Copy"). OSC 52 content gets only
|
||||
* `sanitizeInvisibles`: the program chose that text, so it is not reflowed or
|
||||
* dedented — only made safe to paste. Raw copy (Ctrl+Shift+Alt+C) gets none.
|
||||
*/
|
||||
|
||||
// ── invisible characters ────────────────────────────────────────────────────
|
||||
|
||||
/** NBSP, figure space, narrow NBSP, the U+2000–U+200A width spaces, medium
|
||||
* math space. U+3000 (ideographic space) is deliberately absent: it is real
|
||||
* CJK typography and occupies two cells. */
|
||||
const ODD_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F]/g;
|
||||
/** Zero-width space, word joiner, BOM / zero-width no-break space. */
|
||||
const ZERO_WIDTH = /[\u200B\u2060\uFEFF]/g;
|
||||
/** ZWNJ / ZWJ touching ASCII (or a string edge). Between two non-ASCII
|
||||
* characters they are meaningful — emoji ZWJ sequences, Persian ZWNJ,
|
||||
* Indic conjunct control — so only this case is removed. */
|
||||
const JOINER_NEAR_ASCII = /(^|[\x00-\x7F])[\u200C\u200D]+|[\u200C\u200D]+(?=[\x00-\x7F]|$)/g;
|
||||
|
||||
export function sanitizeInvisibles(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/[\u2028\u2029]/g, "\n")
|
||||
.replace(ODD_SPACES, " ")
|
||||
.replace(ZERO_WIDTH, "")
|
||||
.replace(JOINER_NEAR_ASCII, "$1");
|
||||
}
|
||||
|
||||
// ── cell width ──────────────────────────────────────────────────────────────
|
||||
|
||||
const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x1100, 0x115f],
|
||||
[0x2e80, 0x303e],
|
||||
[0x3041, 0x33ff],
|
||||
[0x3400, 0x4dbf],
|
||||
[0x4e00, 0x9fff],
|
||||
[0xa000, 0xa4cf],
|
||||
[0xac00, 0xd7a3],
|
||||
[0xf900, 0xfaff],
|
||||
[0xfe30, 0xfe4f],
|
||||
[0xff00, 0xff60],
|
||||
[0xffe0, 0xffe6],
|
||||
[0x1f300, 0x1f64f],
|
||||
[0x1f900, 0x1f9ff],
|
||||
[0x20000, 0x3fffd],
|
||||
];
|
||||
const ZERO_CELL = /^[\p{M}\u200B-\u200D\u2060\uFE00-\uFE0F\uFEFF]$/u;
|
||||
|
||||
function charWidth(ch: string): number {
|
||||
if (ZERO_CELL.test(ch)) return 0;
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
for (const [lo, hi] of WIDE_RANGES) if (cp >= lo && cp <= hi) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Terminal cells a string occupies — an approximation of xterm's Unicode 6
|
||||
* width table, good enough to tell whether a row reached the right edge. */
|
||||
export function cellWidth(text: string): number {
|
||||
let w = 0;
|
||||
for (const ch of text) w += charWidth(ch);
|
||||
return w;
|
||||
}
|
||||
|
||||
// ── Claude Code's gutter and borders ────────────────────────────────────────
|
||||
|
||||
const BOX_TOP_BOTTOM = /^\s*[╭╰┌└][─━]*[╮╯┐┘]\s*$/;
|
||||
const BOX_ROW = /^(\s*)│(.*)│\s*$/;
|
||||
/** A vertical rule between the borders: a table cell separator, not a box. */
|
||||
const INTERIOR_VERTICAL = /[│┃║]/;
|
||||
const isBoxRow = (l: string) => {
|
||||
const m = BOX_ROW.exec(l);
|
||||
return !!m && !INTERIOR_VERTICAL.test(m[2]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Blanks Claude Code's TUI markers without moving any text: `●` at column 0
|
||||
* and `⎿` at column 2 become spaces (so `rejoinHardWraps` still sees true
|
||||
* columns, and `trimSelection` dedents them away). A selection that is wholly
|
||||
* inside a `╭─╮ │ … │ ╰─╯` box loses the box. Anything less certain — a table
|
||||
* (`├─┼─┤` rows, or body rows alone: `│ a │ b │` has a `│` inside its borders),
|
||||
* a bullet elsewhere, a box mixed with outside text — is left as it is.
|
||||
*
|
||||
* `startCol` is the column the selection began at; when it is not 0 the first
|
||||
* line's column 0 is not the gutter.
|
||||
*/
|
||||
export function stripClaudeGutter(text: string, startCol = 0): string {
|
||||
if (!text) return text;
|
||||
let lines = text.split("\n");
|
||||
|
||||
const content = lines.filter((l) => l.trim() !== "" && !BOX_TOP_BOTTOM.test(l));
|
||||
if (content.length > 0 && content.every(isBoxRow)) {
|
||||
lines = lines
|
||||
.filter((l) => !BOX_TOP_BOTTOM.test(l))
|
||||
.map((l) => l.replace(BOX_ROW, (_m, lead: string, inner: string) => `${lead} ${inner}`.trimEnd()));
|
||||
}
|
||||
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
if (i === 0 && startCol !== 0) return line;
|
||||
if (line.startsWith("● ")) return " " + line.slice(1);
|
||||
if (line.startsWith(" ⎿ ")) return " " + line.slice(3);
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ── Claude Code's hard wraps ────────────────────────────────────────────────
|
||||
|
||||
export interface CopyGeometry {
|
||||
/** Terminal width in columns (`term.cols`). 0 / unknown disables rejoining. */
|
||||
cols: number;
|
||||
/** Column (0-based) where the selection's first row began. */
|
||||
startCol?: number;
|
||||
}
|
||||
|
||||
/** A list marker or Claude prompt glyph, and the spaces after it. */
|
||||
const MARKER = /^([-*+•●❯⎿]|\d{1,3}[.)]) +/;
|
||||
/** A row that starts a border, rule or table line. */
|
||||
const BORDER_START = /^[│┃├┤┌┐└┘╭╮╰╯─━┼]/;
|
||||
/** Two or more spaces between words: column-aligned output, not prose. */
|
||||
const TABULAR = /\S {2,}\S/;
|
||||
/** Claude Code always renders its transcript at least 2 columns in. */
|
||||
const MIN_GUTTER = 2;
|
||||
|
||||
interface Row {
|
||||
text: string;
|
||||
/** Column of the first non-space character. */
|
||||
lead: number;
|
||||
/** Column just past the last character. */
|
||||
end: number;
|
||||
/** Text after the indent. */
|
||||
body: string;
|
||||
/** Column where text after a list marker starts (or `lead` if none). */
|
||||
contentCol: number;
|
||||
hasMarker: boolean;
|
||||
}
|
||||
|
||||
function toRow(text: string, offset: number): Row {
|
||||
const body = text.trimStart();
|
||||
const lead = offset + cellWidth(text.slice(0, text.length - body.length));
|
||||
const marker = body.match(MARKER);
|
||||
return {
|
||||
text,
|
||||
lead,
|
||||
end: offset + cellWidth(text),
|
||||
body,
|
||||
contentCol: lead + (marker ? cellWidth(marker[0]) : 0),
|
||||
hasMarker: !!marker,
|
||||
};
|
||||
}
|
||||
|
||||
const lastToken = (s: string) => s.slice(s.lastIndexOf(" ") + 1);
|
||||
const firstToken = (s: string) => s.split(" ", 1)[0];
|
||||
/** First / last *code point* — an astral character (CJK Ext-B, most emoji) is two UTF-16 units. */
|
||||
const firstChar = (s: string) => (s ? String.fromCodePoint(s.codePointAt(0)!) : "");
|
||||
const lastChar = (s: string) => Array.from(s.slice(-2)).pop() ?? "";
|
||||
const isWide = (ch: string) => !!ch && charWidth(ch) === 2;
|
||||
|
||||
/**
|
||||
* Re-joins rows that Claude Code broke to fit the terminal width.
|
||||
*
|
||||
* A break is undone only when all of these hold — each is a property every
|
||||
* Claude wrap has, so together they are conservative:
|
||||
* - both rows have text, and the next row does not start a list item or a
|
||||
* border/table line;
|
||||
* - the next row is indented exactly to where the logical line's text starts
|
||||
* (the gutter, or the hanging indent after `- ` / `1. `), and at least 2
|
||||
* columns — so column-0 shell output is never touched;
|
||||
* - the previous row is not column-aligned (no double spaces between words);
|
||||
* - the next row's first word would *not* have fitted on the previous row,
|
||||
* i.e. the wrapper had no choice but to break there.
|
||||
*
|
||||
* Joined with a space (Claude drops the space it broke at), except when a long
|
||||
* token such as a URL was split mid-token (see `splitMidToken`) or the break
|
||||
* is between two wide (CJK) characters.
|
||||
*/
|
||||
export function rejoinHardWraps(text: string, { cols, startCol = 0 }: CopyGeometry): string {
|
||||
if (!text || !(cols > 0)) return text;
|
||||
const rows = text.split("\n").map((t, i) => toRow(t, i === 0 ? startCol : 0));
|
||||
if (rows.length < 2) return text;
|
||||
|
||||
const out: string[] = [rows[0].text];
|
||||
let head = rows[0];
|
||||
// A first row that began mid-row: what was left of it is unknown.
|
||||
let headPartial = startCol > 0 && !rows[0].hasMarker;
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const prev = rows[i - 1];
|
||||
const cur = rows[i];
|
||||
const joiner = joinerFor(prev, cur, head, headPartial, cols);
|
||||
if (joiner === null) {
|
||||
out.push(cur.text);
|
||||
head = cur;
|
||||
headPartial = false;
|
||||
} else {
|
||||
out[out.length - 1] += joiner + cur.body;
|
||||
}
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function joinerFor(prev: Row, cur: Row, head: Row, headPartial: boolean, cols: number): string | null {
|
||||
if (!prev.body || !cur.body) return null;
|
||||
if (cur.hasMarker || BORDER_START.test(cur.body)) return null;
|
||||
if (cur.lead < MIN_GUTTER) return null;
|
||||
if (headPartial ? cur.lead > head.lead : cur.lead !== head.contentCol) return null;
|
||||
|
||||
const prevText = prev === head && prev.hasMarker ? prev.body.replace(MARKER, "") : prev.body;
|
||||
if (TABULAR.test(prevText)) return null;
|
||||
|
||||
// Claude's echo of a submitted prompt (`❯ …`) wraps one column short of
|
||||
// its replies — measured on 2.1.316 at 70 columns: rows end at 69, and a
|
||||
// long token is split at column 69.
|
||||
const width = head.body.startsWith("❯") ? cols - 1 : cols;
|
||||
if (prev.end > width) return null;
|
||||
const next = firstToken(cur.body);
|
||||
if (prev.end + 1 + cellWidth(next) <= width) return null;
|
||||
|
||||
if (isWide(lastChar(prev.body)) && isWide(firstChar(cur.body))) return "";
|
||||
return prev.end === width && splitMidToken(prev, cur, next, width) ? "" : " ";
|
||||
}
|
||||
|
||||
/** The start of a URL: a token that begins here was not cut off mid-token. */
|
||||
const URL_START = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
|
||||
/**
|
||||
* Did Claude break a single long token at the edge of `prev` (so the rows
|
||||
* join with no space), rather than moving the next word down whole?
|
||||
*
|
||||
* Ink's wrap-ansi splits a word only when it is wider than the line, so the
|
||||
* combined token must not fit in the line's available width. That alone
|
||||
* cannot tell `the` + a 66-cell URL (two words; the URL fits on a row and was
|
||||
* moved down) from one 69-cell token split after `the` — the lengths are the
|
||||
* same. So: a next-row token that *starts a URL* (`scheme://`) was moved down
|
||||
* whole, unless one of the two pieces fills its entire row, which only the
|
||||
* middle piece of a token longer than a line does.
|
||||
*/
|
||||
function splitMidToken(prev: Row, cur: Row, next: string, width: number): boolean {
|
||||
const tail = cellWidth(lastToken(prev.body));
|
||||
const head = cellWidth(next);
|
||||
if (tail + head <= width - cur.lead) return false; // would have been moved down whole
|
||||
const prevIsPiece = tail >= width - prev.lead;
|
||||
const curIsPiece = cur.end === width && head === cellWidth(cur.body);
|
||||
if (prevIsPiece || curIsPiece) return true;
|
||||
return !URL_START.test(next);
|
||||
}
|
||||
|
||||
// ── the trimmed copy ────────────────────────────────────────────────────────
|
||||
|
||||
/** The full clean-up for a trimmed copy of an xterm selection. */
|
||||
export function cleanSelection(text: string, geometry: CopyGeometry): string {
|
||||
if (!text) return text;
|
||||
const lf = text.replace(/\r\n?/g, "\n"); // getSelection() joins with CRLF on Windows
|
||||
const visible = sanitizeInvisibles(lf);
|
||||
const unframed = stripClaudeGutter(visible, geometry.startCol ?? 0);
|
||||
return trimSelection(rejoinHardWraps(unframed, geometry));
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFilePathLinkProvider } from "./filePathLinkProvider";
|
||||
|
||||
const fakeTerm = (rows: Array<[string, boolean]>) => ({
|
||||
const fakeTerm = (rows: Array<[string, boolean]>, cols?: number) => ({
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] },
|
||||
@@ -40,6 +41,29 @@ describe("createFilePathLinkProvider", () => {
|
||||
expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } });
|
||||
});
|
||||
|
||||
it("links a long /tmp path that Claude Code hard-wrapped, from either row", () => {
|
||||
// Captured from Claude Code at 70 columns: the path is broken mid-word and
|
||||
// the rest drawn two cells in on the next row, with no `isWrapped`.
|
||||
const rows: Array<[string, boolean]> = [
|
||||
["\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e", false],
|
||||
[" 039e2a07562/scratchpad/notes.md ok", false],
|
||||
];
|
||||
const onOpen = vi.fn();
|
||||
const provider = createFilePathLinkProvider(fakeTerm(rows, 70), onOpen, () => true);
|
||||
for (const row of [1, 2]) {
|
||||
const links = vi.fn();
|
||||
provider.provideLinks(row, links);
|
||||
const [list] = links.mock.calls[0];
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].range).toEqual({ start: { x: 18, y: 1 }, end: { x: 33, y: 2 } });
|
||||
list[0].activate(new MouseEvent("click"), list[0].text);
|
||||
}
|
||||
const scratch = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
|
||||
expect(onOpen).toHaveBeenCalledTimes(2);
|
||||
expect(onOpen).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: scratch }));
|
||||
expect(onOpen).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: scratch }));
|
||||
});
|
||||
|
||||
it("answers undefined for a row with nothing", () => {
|
||||
const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true);
|
||||
const links = vi.fn();
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface FilePathHover {
|
||||
}
|
||||
|
||||
export function createFilePathLinkProvider(
|
||||
term: Pick<Terminal, "buffer">,
|
||||
term: Pick<Terminal, "buffer" | "cols">,
|
||||
onOpen: (match: FilePathMatch) => void,
|
||||
gate: (event: MouseEvent) => boolean,
|
||||
hover?: FilePathHover,
|
||||
@@ -27,7 +27,9 @@ export function createFilePathLinkProvider(
|
||||
const row = bufferLineNumber - 1;
|
||||
const line = term.buffer.active.getLine(row);
|
||||
if (!line) return callback(undefined);
|
||||
const joined = joinWrappedRows(term.buffer.active, row);
|
||||
// `cols` lets the join bridge Claude Code's own mid-word wraps, which is
|
||||
// how a long `/tmp/claude-<uid>/...` path reaches the screen.
|
||||
const joined = joinWrappedRows(term.buffer.active, row, { cols: term.cols });
|
||||
// `offsetToCell` has no row to map onto when nothing was joined.
|
||||
if (joined.rowStarts.length === 0) return callback(undefined);
|
||||
const matches = findFilePathLinks(joined.text);
|
||||
|
||||
@@ -1,59 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decodeOsc52 } from "./osc52";
|
||||
|
||||
/**
|
||||
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
|
||||
* Extracted here to validate the decode/write path independently.
|
||||
*/
|
||||
|
||||
// Mirrors the handler registered in TerminalView.tsx
|
||||
function handleOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null;
|
||||
try {
|
||||
return atob(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/** Base64 of the UTF-8 bytes of `text` — what the container shim
|
||||
* (`printf '%s' "$data" | base64`) and Claude Code's own OSC 52 both send. */
|
||||
function utf8Base64(text: string): string {
|
||||
return Buffer.from(text, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
describe("OSC 52 clipboard handler", () => {
|
||||
describe("decodeOsc52", () => {
|
||||
it("decodes a valid clipboard write sequence", () => {
|
||||
// "c;BASE64" where BASE64 encodes "https://example.com"
|
||||
const encoded = btoa("https://example.com");
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe("https://example.com");
|
||||
expect(decodeOsc52(`c;${utf8Base64("https://example.com")}`)).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("decodes multi-line content", () => {
|
||||
const text = "line1\nline2\nline3";
|
||||
const encoded = btoa(text);
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe(text);
|
||||
expect(decodeOsc52(`c;${utf8Base64(text)}`)).toBe(text);
|
||||
});
|
||||
|
||||
it("handles primary selection target (p)", () => {
|
||||
const encoded = btoa("selected text");
|
||||
const result = handleOsc52(`p;${encoded}`);
|
||||
expect(result).toBe("selected text");
|
||||
it("handles primary selection target (p) and an empty target", () => {
|
||||
expect(decodeOsc52(`p;${utf8Base64("selected text")}`)).toBe("selected text");
|
||||
expect(decodeOsc52(`;${utf8Base64("selected text")}`)).toBe("selected text");
|
||||
});
|
||||
|
||||
// The user-visible bug: `atob` returns one char per *byte*, so every
|
||||
// multi-byte UTF-8 sequence came out as Latin-1 mojibake (’ → "â\x80\x99",
|
||||
// é → "é"), which Windows apps render as `’`.
|
||||
it("decodes UTF-8 multi-byte text instead of mojibaking it", () => {
|
||||
const text = "it’s café — naïve 日本語 👍🏽 👨\u200D👩\u200D👧";
|
||||
const decoded = decodeOsc52(`c;${utf8Base64(text)}`);
|
||||
expect(decoded).toBe(text);
|
||||
expect(decoded).not.toContain("Ã");
|
||||
expect(decoded).not.toContain("â");
|
||||
});
|
||||
|
||||
// One bad or truncated byte must not re-mojibake the whole payload: only
|
||||
// the bad bytes become U+FFFD, the valid UTF-8 around them is kept.
|
||||
it("replaces only invalid bytes with U+FFFD, keeping the valid UTF-8", () => {
|
||||
const bytes = Buffer.concat([Buffer.from("it’s ", "utf8"), Buffer.from([0xff]), Buffer.from(" café", "utf8")]);
|
||||
expect(decodeOsc52(`c;${bytes.toString("base64")}`)).toBe("it’s \uFFFD café");
|
||||
});
|
||||
|
||||
it("keeps the text before a multi-byte sequence truncated at the end", () => {
|
||||
const full = Buffer.from("naïve ’", "utf8");
|
||||
const truncated = full.subarray(0, full.length - 1); // drop the last byte of ’
|
||||
expect(decodeOsc52(`c;${truncated.toString("base64")}`)).toBe("naïve \uFFFD");
|
||||
});
|
||||
|
||||
it("does not guess Latin-1 for a payload that is not UTF-8", () => {
|
||||
// Every sender this app sees sends UTF-8; a Latin-1 é is just a bad byte.
|
||||
const latin1 = Buffer.from([0x63, 0x61, 0x66, 0xe9]).toString("base64");
|
||||
expect(decodeOsc52(`c;${latin1}`)).toBe("caf\uFFFD");
|
||||
});
|
||||
|
||||
it("tolerates whitespace/newlines inside the base64 (base64 without -w0)", () => {
|
||||
const b64 = utf8Base64("x".repeat(100));
|
||||
const wrapped = b64.slice(0, 76) + "\n" + b64.slice(76);
|
||||
expect(decodeOsc52(`c;${wrapped}`)).toBe("x".repeat(100));
|
||||
});
|
||||
|
||||
it("returns null for clipboard read request (?)", () => {
|
||||
expect(handleOsc52("c;?")).toBe(null);
|
||||
expect(decodeOsc52("c;?")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for missing semicolon", () => {
|
||||
expect(handleOsc52("invalid")).toBe(null);
|
||||
expect(decodeOsc52("invalid")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", () => {
|
||||
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
expect(decodeOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
});
|
||||
|
||||
it("handles empty payload after selection target", () => {
|
||||
// btoa("") = ""
|
||||
const result = handleOsc52("c;");
|
||||
expect(result).toBe("");
|
||||
expect(decodeOsc52("c;")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Decodes the payload of an OSC 52 clipboard *write* (`Pc ; Pd`, where `Pd` is
|
||||
* base64) into the text to put on the clipboard.
|
||||
*
|
||||
* The base64 carries **bytes**, and every sender this app sees — the
|
||||
* container's xclip/xsel/pbcopy shim (`printf '%s' | base64`) and Claude Code's
|
||||
* own OSC 52 copy — sends UTF-8. `atob` alone returns one UTF-16 code unit per
|
||||
* byte, so it turned `’` into `â\x80\x99` and `é` into `é` (the `’` mojibake
|
||||
* users saw after pasting). The bytes are therefore decoded as UTF-8,
|
||||
* non-fatally: a bad or truncated byte becomes U+FFFD and only that byte is
|
||||
* lost. There is deliberately no Latin-1 fallback — re-reading the *whole*
|
||||
* payload as Latin-1 because of one bad byte brought the mojibake back for
|
||||
* everything else, and no sender here emits Latin-1.
|
||||
*
|
||||
* Returns `null` for a read request (`?`), a malformed sequence, or invalid
|
||||
* base64 — the caller writes nothing in those cases. Pure function.
|
||||
*/
|
||||
const UTF8 = new TextDecoder("utf-8");
|
||||
|
||||
export function decodeOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null; // clipboard read request, not supported
|
||||
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(payload); // tolerates ASCII whitespace, as base64 without -w0 emits
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return UTF8.decode(bytes);
|
||||
}
|
||||
@@ -96,9 +96,27 @@ Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
|
||||
}, [openTerminal, project.id, project.name, fail]);
|
||||
|
||||
/**
|
||||
* Open a shell tab and type `command` into it. Used for [Resume] and for the
|
||||
* capability drawer's "Manage in terminal" — the backend has no
|
||||
* run-a-command entry point, so we drive the shell we just opened.
|
||||
* Open a Claude tab on a saved conversation. A real Claude session, not a
|
||||
* shell with `claude --resume` typed into it: the tab's session type is
|
||||
* what Shift+Enter, the status-bar hint and Send to agent key off, so a
|
||||
* resumed conversation in a "bash" tab submitted on Shift+Enter.
|
||||
*/
|
||||
const resumeClaudeSession = useCallback(
|
||||
async (claudeSessionId: string, sessionLabel?: string) => {
|
||||
try {
|
||||
return await openTerminal(project.id, project.name, "claude", sessionLabel, claudeSessionId);
|
||||
} catch (e) {
|
||||
fail("Could not resume that session", e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[openTerminal, project.id, project.name, fail],
|
||||
);
|
||||
|
||||
/**
|
||||
* Open a shell tab and type `command` into it. Used for the capability
|
||||
* drawer's "Manage in terminal" — the backend has no run-a-command entry
|
||||
* point, so we drive the shell we just opened.
|
||||
*/
|
||||
const openTerminalWithCommand = useCallback(
|
||||
async (command: string, sessionLabel?: string) => {
|
||||
@@ -170,6 +188,7 @@ Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
|
||||
handleBackup,
|
||||
openClaudeTerminal,
|
||||
openShell,
|
||||
resumeClaudeSession,
|
||||
openTerminalWithCommand,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,74 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { renderHook } from "@testing-library/react";
|
||||
import { useAppState } from "../store/appState";
|
||||
import type { Project } from "../lib/types";
|
||||
|
||||
let handler: ((e: { payload: unknown }) => void) | null = null;
|
||||
const unlisten = vi.fn();
|
||||
const listen = vi.fn(async (name: string, cb: (e: { payload: unknown }) => void) => {
|
||||
if (name === "project-container-exited") handler = cb;
|
||||
return unlisten;
|
||||
});
|
||||
vi.mock("@tauri-apps/api/event", () => ({
|
||||
listen: (name: string, cb: (e: { payload: unknown }) => void) => listen(name, cb),
|
||||
}));
|
||||
|
||||
import { useProjectStatusEvents } from "./useProjectStatusEvents";
|
||||
|
||||
const project = (over: Partial<Project>): Project =>
|
||||
({ id: "p1", name: "demo", status: "running", container_id: "c1", ...over }) as Project;
|
||||
|
||||
describe("useProjectStatusEvents", () => {
|
||||
beforeEach(() => {
|
||||
handler = null;
|
||||
unlisten.mockClear();
|
||||
listen.mockClear();
|
||||
useAppState.setState({ projects: [project({}), project({ id: "p2", name: "other" })], toasts: [] });
|
||||
});
|
||||
|
||||
it("applies a live exit and says why the sessions just died", async () => {
|
||||
renderHook(() => useProjectStatusEvents());
|
||||
await vi.waitFor(() => expect(handler).not.toBeNull());
|
||||
|
||||
handler!({ payload: { project: project({ status: "stopped" }), reason: "exited" } });
|
||||
|
||||
const { projects, toasts } = useAppState.getState();
|
||||
expect(projects.find((p) => p.id === "p1")?.status).toBe("stopped");
|
||||
expect(projects.find((p) => p.id === "p2")?.status).toBe("running");
|
||||
expect(toasts).toHaveLength(1);
|
||||
expect(toasts[0].kind).toBe("error");
|
||||
expect(toasts[0].message).toContain("demo");
|
||||
expect(toasts[0].dedupeKey).toBe("container-exited-p1");
|
||||
});
|
||||
|
||||
it("applies a container found already stopped at startup silently", async () => {
|
||||
// After a host reboot every project's container is down before the app
|
||||
// opens; nothing was interrupted this run, so nothing to alarm anyone about.
|
||||
renderHook(() => useProjectStatusEvents());
|
||||
await vi.waitFor(() => expect(handler).not.toBeNull());
|
||||
|
||||
handler!({ payload: { project: project({ status: "stopped" }), reason: "found_stopped" } });
|
||||
|
||||
const { projects, toasts } = useAppState.getState();
|
||||
expect(projects.find((p) => p.id === "p1")?.status).toBe("stopped");
|
||||
expect(toasts).toHaveLength(0);
|
||||
});
|
||||
|
||||
it("stops listening on unmount", async () => {
|
||||
const { unmount } = renderHook(() => useProjectStatusEvents());
|
||||
await vi.waitFor(() => expect(handler).not.toBeNull());
|
||||
unmount();
|
||||
expect(unlisten).toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it("survives listen() rejecting (no unhandled rejection)", async () => {
|
||||
const err = vi.spyOn(console, "error").mockImplementation(() => {});
|
||||
listen.mockImplementationOnce(async () => {
|
||||
throw new Error("no IPC");
|
||||
});
|
||||
const { unmount } = renderHook(() => useProjectStatusEvents());
|
||||
await vi.waitFor(() => expect(err).toHaveBeenCalled());
|
||||
unmount();
|
||||
err.mockRestore();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,51 @@
|
||||
import { useEffect } from "react";
|
||||
import { listen, type UnlistenFn } from "@tauri-apps/api/event";
|
||||
import { useAppState } from "../store/appState";
|
||||
import type { Project } from "../lib/types";
|
||||
|
||||
/** `container_watch.rs`'s `ContainerExited`. */
|
||||
export interface ContainerExitedEvent {
|
||||
project: Project;
|
||||
/**
|
||||
* `exited`: it died while the app was running — sessions were just cut off.
|
||||
* `found_stopped`: found down on the app's first look this run (it stopped
|
||||
* while the app was closed, e.g. a host reboot); nothing was interrupted.
|
||||
*/
|
||||
reason: "exited" | "found_stopped";
|
||||
}
|
||||
|
||||
/**
|
||||
* App-wide: apply `project-container-exited` — `container_watch.rs` marking a
|
||||
* project Stopped because its container exited without the app asking. Before
|
||||
* it, the project kept reading Running, with every terminal dead, until the
|
||||
* app was restarted.
|
||||
*/
|
||||
export function useProjectStatusEvents() {
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
let unlisten: UnlistenFn | null = null;
|
||||
listen<ContainerExitedEvent>("project-container-exited", (event) => {
|
||||
const { project, reason } = event.payload;
|
||||
const state = useAppState.getState();
|
||||
state.updateProjectInList(project);
|
||||
if (reason !== "exited") return;
|
||||
state.pushToast({
|
||||
kind: "error",
|
||||
message: `The container for “${project.name}” stopped`,
|
||||
detail:
|
||||
"It exited without Triple-C stopping it — open sessions were disconnected. " +
|
||||
"Start the project again to reconnect.",
|
||||
dedupeKey: `container-exited-${project.id}`,
|
||||
});
|
||||
})
|
||||
.then((fn) => {
|
||||
if (cancelled) fn();
|
||||
else unlisten = fn;
|
||||
})
|
||||
.catch((e) => console.error("Failed to listen for container exits:", e));
|
||||
return () => {
|
||||
cancelled = true;
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
@@ -2,6 +2,7 @@ import { useCallback, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { encodeWav } from "../lib/wav";
|
||||
import { useAppState } from "../store/appState";
|
||||
import { getMicrophoneStream } from "../lib/get-microphone";
|
||||
|
||||
export type SttState = "idle" | "recording" | "transcribing" | "error";
|
||||
|
||||
@@ -36,11 +37,8 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
};
|
||||
if (deviceId) {
|
||||
audioConstraints.deviceId = { exact: deviceId };
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({ audio: audioConstraints });
|
||||
const stream = await getMicrophoneStream(audioConstraints, deviceId);
|
||||
streamRef.current = stream;
|
||||
|
||||
const audioContext = new AudioContext({ sampleRate: 16000 });
|
||||
@@ -59,7 +57,10 @@ export function useSTT(sessionId: string, sendInput: (sessionId: string, data: s
|
||||
source.connect(processor);
|
||||
processor.connect(audioContext.destination);
|
||||
} catch (e) {
|
||||
const msg = e instanceof Error ? e.message : String(e);
|
||||
// Include the DOMException name: "Invalid constraint" alone doesn't say
|
||||
// whether it was OverconstrainedError, NotAllowedError, NotFoundError, etc.
|
||||
const msg = e instanceof Error ? `${e.name}: ${e.message}` : String(e);
|
||||
console.error("STT startRecording failed:", e);
|
||||
setError(msg);
|
||||
setState("error");
|
||||
}
|
||||
|
||||
@@ -81,3 +81,26 @@ describe("useTerminal input ordering", () => {
|
||||
expect(bySession).toContainEqual(["b", "2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTerminal open", () => {
|
||||
it("opens a resumed conversation as a Claude session", async () => {
|
||||
// Resume used to open a "bash" tab and type `claude --resume` into it, and
|
||||
// Shift+Enter keys off the tab's type — so it submitted instead of adding
|
||||
// a newline. The id has to travel to the backend *and* the tab has to be
|
||||
// registered as "claude".
|
||||
const commands = await import("../lib/tauri-commands");
|
||||
const { useAppState } = await import("../store/appState");
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
const id = await result.current.open("p1", "proj", "claude", "my chat", "e13d312d-2f38");
|
||||
|
||||
expect(commands.openTerminalSession).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
id,
|
||||
"claude",
|
||||
"my chat",
|
||||
"e13d312d-2f38",
|
||||
);
|
||||
expect(useAppState.getState().sessions.find((s) => s.id === id)?.sessionType).toBe("claude");
|
||||
});
|
||||
});
|
||||
@@ -97,9 +97,15 @@ export function useTerminal() {
|
||||
);
|
||||
|
||||
const open = useCallback(
|
||||
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
|
||||
async (
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
sessionType: "claude" | "bash" = "claude",
|
||||
sessionName?: string,
|
||||
resumeSessionId?: string,
|
||||
) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName, resumeSessionId);
|
||||
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import * as commands from "../lib/tauri-commands";
|
||||
import { getMicrophoneStream } from "../lib/get-microphone";
|
||||
|
||||
type VoiceState = "inactive" | "starting" | "active" | "error";
|
||||
|
||||
@@ -27,13 +28,8 @@ export function useVoice(sessionId: string, deviceId?: string | null) {
|
||||
noiseSuppression: true,
|
||||
autoGainControl: true,
|
||||
};
|
||||
if (deviceId) {
|
||||
audioConstraints.deviceId = { exact: deviceId };
|
||||
}
|
||||
|
||||
const stream = await navigator.mediaDevices.getUserMedia({
|
||||
audio: audioConstraints,
|
||||
});
|
||||
const stream = await getMicrophoneStream(audioConstraints, deviceId);
|
||||
streamRef.current = stream;
|
||||
|
||||
// 3. Create AudioContext at 16kHz (browser handles resampling)
|
||||
|
||||
@@ -34,12 +34,23 @@ const KNOWN_BASENAMES = new Set([
|
||||
"README", "CHANGELOG", "PKGBUILD",
|
||||
]);
|
||||
|
||||
/**
|
||||
* The characters a path segment is made of, as a regex character-class body
|
||||
* (no brackets). Shared with `xtermLineJoin`, which must agree with `TOKEN` on
|
||||
* what counts as part of a path when it decides whether a row was broken
|
||||
* mid-path.
|
||||
*/
|
||||
export const PATH_SEGMENT_CLASS = String.raw`A-Za-z0-9_.\-~+@`;
|
||||
|
||||
/**
|
||||
* A candidate token: path characters, optionally starting with `/`, `./`, `../`
|
||||
* or `.` (dotfile). Excludes the wrapping characters the surrounding markdown
|
||||
* leaves (`(`, `)`, `[`, `]`, backtick, quotes) and whitespace.
|
||||
*/
|
||||
const TOKEN = /(?:\.{1,2}\/|\/)?[A-Za-z0-9_.\-~+@]+(?:\/[A-Za-z0-9_.\-~+@]+)*\/?/g;
|
||||
const TOKEN = new RegExp(
|
||||
String.raw`(?:\.{1,2}\/|\/)?[${PATH_SEGMENT_CLASS}]+(?:\/[${PATH_SEGMENT_CLASS}]+)*\/?`,
|
||||
"g",
|
||||
);
|
||||
const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi;
|
||||
const LINE_SUFFIX = /^(?::(\d+)(?::(\d+))?(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)/;
|
||||
const VERSION_LIKE = /^v?\d+(\.\d+)+$/;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
/**
|
||||
* Open the microphone, relaxing constraints until the webview accepts them.
|
||||
*
|
||||
* Attempts, in order:
|
||||
* 1. saved device (exact) + base constraints
|
||||
* 2. base constraints, system default device
|
||||
* 3. bare `audio: true`
|
||||
*
|
||||
* Step 2 covers a stale saved deviceId (unplugged / regenerated IDs). Step 3
|
||||
* covers webviews (notably WebKitGTK on Linux) that reject one of the optional
|
||||
* processing constraints with `OverconstrainedError: Invalid constraint`.
|
||||
* Only constraint/device errors fall through; permission errors still throw.
|
||||
*/
|
||||
const RETRYABLE = new Set(["OverconstrainedError", "NotFoundError"]);
|
||||
|
||||
export async function getMicrophoneStream(
|
||||
base: MediaTrackConstraints,
|
||||
deviceId?: string | null,
|
||||
): Promise<MediaStream> {
|
||||
const attempts: (MediaTrackConstraints | boolean)[] = [];
|
||||
if (deviceId) attempts.push({ ...base, deviceId: { exact: deviceId } });
|
||||
attempts.push(base, true);
|
||||
|
||||
let lastError: unknown;
|
||||
for (const audio of attempts) {
|
||||
try {
|
||||
return await navigator.mediaDevices.getUserMedia({ audio });
|
||||
} catch (e) {
|
||||
lastError = e;
|
||||
if (!(e instanceof DOMException) || !RETRYABLE.has(e.name)) throw e;
|
||||
console.warn("getUserMedia attempt failed, relaxing constraints:", e.name, e.message, audio);
|
||||
}
|
||||
}
|
||||
throw lastError;
|
||||
}
|
||||
@@ -65,8 +65,14 @@ export const awsSsoRefresh = (projectId: string) =>
|
||||
invoke<void>("aws_sso_refresh", { projectId });
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
|
||||
export const openTerminalSession = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
sessionType?: string,
|
||||
sessionName?: string,
|
||||
resumeSessionId?: string,
|
||||
) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName, resumeSessionId });
|
||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("terminal_input", { sessionId, data });
|
||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
@@ -168,8 +174,6 @@ export const runDockerInstall = () => invoke<void>("run_docker_install");
|
||||
// Container introspection — sessions
|
||||
export const listClaudeSessions = (projectId: string) =>
|
||||
invoke<ClaudeSession[]>("list_claude_sessions", { projectId });
|
||||
export const resumeSessionCommand = (projectId: string, sessionId: string) =>
|
||||
invoke<string>("resume_session_command", { projectId, sessionId });
|
||||
|
||||
// Container introspection — capabilities
|
||||
export const listContainerCapabilities = (projectId: string) =>
|
||||
|
||||
@@ -486,6 +486,8 @@ export interface GatewayStatus {
|
||||
running: boolean;
|
||||
port: number;
|
||||
image_exists: boolean;
|
||||
/** The container runs something other than the pinned image. With `image_exists`, Stop + Start recreates it on the pinned image; without, it keeps its image until one is pulled or built. */
|
||||
image_outdated: boolean;
|
||||
model_count: number;
|
||||
/** Presence only — the provider API key never leaves the keychain. */
|
||||
has_api_key: boolean;
|
||||
|
||||
@@ -146,6 +146,62 @@ describe("UrlDetector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("UrlDetector — trailing punctuation", () => {
|
||||
it("leaves a full stop after the link out of the candidate", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `Open ${SIGN_IN_URL}. Then paste the code.\r\n`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("leaves wrapping brackets out of the candidate", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `[Sign in](${SIGN_IN_URL}).\r\nmore\r\n`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("trims a link that ends the buffer once it is confirmed", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `See ${SIGN_IN_URL}.`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("still waits for more when the buffer ends on a full stop", () => {
|
||||
// A `.` at the end of a chunk may be the middle of the URL (`file.tar.gz`)
|
||||
// with the rest still in flight. Whether to wait is decided by where the
|
||||
// raw text ran to, not by where the trimmed URL ends.
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => 1000);
|
||||
const head = "https://example.com/" + "a".repeat(90) + ".";
|
||||
const url = head + "tar.gz";
|
||||
|
||||
d.feed(enc.encode(head));
|
||||
vi.advanceTimersByTime(400); // scanned, but inside the confirmation wait
|
||||
d.feed(enc.encode("tar.gz\r\nnext\r\n"));
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
expect(seen).toEqual([url]);
|
||||
});
|
||||
|
||||
it("measures the length threshold on the trimmed URL", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
const url = "https://example.com/" + "a".repeat(79); // 99 characters
|
||||
|
||||
feed(d, `(${url}).\r\nmore\r\n`);
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usableLink", () => {
|
||||
// Ports `usable_sign_in_link` from `commands/auth_token_commands.rs` — a junk
|
||||
// filter, not the security decision. `sanitizeRelayUrl` is still what stands
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
*/
|
||||
|
||||
import { extendsUrl } from "./urlRelay";
|
||||
import { findUrls } from "./urlMatch";
|
||||
|
||||
const ANSI_RE =
|
||||
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
||||
@@ -297,19 +298,19 @@ export class UrlDetector {
|
||||
// still re-validated by `sanitizeRelayUrl` before it can reach the opener;
|
||||
// stopping the match early only means the legitimate prefix survives
|
||||
// instead of the whole candidate being thrown away.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const urlRe = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = urlRe.exec(flat)) !== null) {
|
||||
const url = m[0];
|
||||
|
||||
//
|
||||
// `findUrls` also trims trailing prose punctuation and wrapping brackets
|
||||
// (`…/authorize?….` → no `.`), the same rule the terminal underline uses
|
||||
// — see `urlMatch.ts`. It reports the raw run's end separately, because
|
||||
// that, not the trimmed URL's end, is what says whether the text may
|
||||
// still be growing: a chunk ending `…/file.` may be followed by `tar.gz`.
|
||||
for (const { url, rawEnd } of findUrls(flat)) {
|
||||
// 4. Filter by length
|
||||
if (url.length < MIN_URL_LENGTH) continue;
|
||||
|
||||
// 5. If the match extends to the very end of the flattened string,
|
||||
// more chunks may still be arriving — defer emission.
|
||||
if (m.index + url.length >= flat.length) {
|
||||
if (rawEnd >= flat.length) {
|
||||
this.pendingUrl = url;
|
||||
this.confirmTimer = setTimeout(() => {
|
||||
this.confirmTimer = null;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { TERMINAL_URL_REGEX, findUrls, trimUrlCandidate } from "./urlMatch";
|
||||
|
||||
/**
|
||||
* Every URL `WebLinksAddon` would underline in `line`.
|
||||
*
|
||||
* Mirrors `LinkComputer.computeLink` in `@xterm/addon-web-links`: it rebuilds
|
||||
* the regex with the `g` flag added to whatever flags it was given and
|
||||
* `exec`s in a loop. Run the same way here so the test exercises the regex as
|
||||
* the addon will, not as a one-off `match` would.
|
||||
*/
|
||||
function underlined(line: string): string[] {
|
||||
const rex = new RegExp(TERMINAL_URL_REGEX.source, (TERMINAL_URL_REGEX.flags || "") + "g");
|
||||
const out: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rex.exec(line))) out.push(m[0]);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("TERMINAL_URL_REGEX", () => {
|
||||
it("carries no g or y flag, which the addon adds itself", () => {
|
||||
expect(TERMINAL_URL_REGEX.flags).not.toMatch(/[gy]/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["See https://example.com/docs.", "https://example.com/docs"],
|
||||
["at https://example.com/docs, then", "https://example.com/docs"],
|
||||
["is it https://example.com/docs?", "https://example.com/docs"],
|
||||
["wow https://example.com/docs!", "https://example.com/docs"],
|
||||
["go to https://example.com/docs: it", "https://example.com/docs"],
|
||||
["https://example.com/docs; and", "https://example.com/docs"],
|
||||
["end https://example.com/docs...", "https://example.com/docs"],
|
||||
["(https://example.com/foo)", "https://example.com/foo"],
|
||||
["(see https://example.com/foo).", "https://example.com/foo"],
|
||||
["[text](https://x.y/z)", "https://x.y/z"],
|
||||
["[text](https://x.y/z).", "https://x.y/z"],
|
||||
["<https://x.example>", "https://x.example"],
|
||||
["[https://x.example/a]", "https://x.example/a"],
|
||||
["{https://x.example/a}", "https://x.example/a"],
|
||||
["\"https://x.example/a\"", "https://x.example/a"],
|
||||
["'https://x.example/a'", "https://x.example/a"],
|
||||
["`https://x.example/a`", "https://x.example/a"],
|
||||
["**https://x.example/a**", "https://x.example/a"],
|
||||
["https://x.example/a/", "https://x.example/a/"],
|
||||
])("leaves the trailing punctuation off %j", (line, url) => {
|
||||
expect(underlined(line)).toEqual([url]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
"https://example.com/search?q=a.b&x=1",
|
||||
"https://example.com/a,b/c",
|
||||
"https://example.com/path/file.tar.gz",
|
||||
"https://example.com/#/route",
|
||||
"https://example.com/a?arr[]=1&arr[]=2",
|
||||
"http://[::1]:8080/x",
|
||||
"https://example.com/a)b",
|
||||
"https://example.com/a(b",
|
||||
"https://example.com/api/{id}/x",
|
||||
// OAuth state and code_challenge are base64url, which can end in `_`, `-`
|
||||
// or `=` padding. None of those may be trimmed.
|
||||
"https://claude.ai/oauth/authorize?code=true&state=abc-_",
|
||||
"https://claude.ai/oauth/authorize?code_challenge=abc-",
|
||||
"https://example.com/x?t=YWJj==",
|
||||
"https://example.com/~user",
|
||||
])("keeps %j whole", (url) => {
|
||||
expect(underlined(url)).toEqual([url]);
|
||||
expect(underlined(`see ${url} now`)).toEqual([url]);
|
||||
});
|
||||
|
||||
it("keeps a balanced paren and drops the one that wraps the link", () => {
|
||||
expect(underlined("(https://en.wikipedia.org/wiki/Foo_(bar))")).toEqual([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
]);
|
||||
expect(underlined("[Foo](https://en.wikipedia.org/wiki/Foo_(bar)).")).toEqual([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds several links on one line", () => {
|
||||
expect(underlined("a https://a.example/1, b (https://b.example/2).")).toEqual([
|
||||
"https://a.example/1",
|
||||
"https://b.example/2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches nothing after a bare scheme", () => {
|
||||
expect(underlined("https://.")).toEqual([]);
|
||||
expect(underlined("https:// x")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still stops at control characters", () => {
|
||||
expect(underlined("https://example.com/a\x1bb")).toEqual(["https://example.com/a"]);
|
||||
expect(underlined("https://example.com/a\x00b")).toEqual(["https://example.com/a"]);
|
||||
expect(underlined("https://example.com/a\x7fb")).toEqual(["https://example.com/a"]);
|
||||
});
|
||||
|
||||
it("does not hang on a long run of punctuation", () => {
|
||||
const line = "https://example.com/" + "(.".repeat(5000) + ")".repeat(5000);
|
||||
const t0 = Date.now();
|
||||
underlined(line);
|
||||
expect(Date.now() - t0).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUrls", () => {
|
||||
it("returns the trimmed URL and the extent of the raw run", () => {
|
||||
expect(findUrls("See https://example.com/docs. next")).toEqual([
|
||||
{ url: "https://example.com/docs", index: 4, rawEnd: 29 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimUrlCandidate", () => {
|
||||
it("trims trailing punctuation and unbalanced closers", () => {
|
||||
expect(trimUrlCandidate("https://example.com/docs.")).toBe("https://example.com/docs");
|
||||
expect(trimUrlCandidate("https://example.com/foo).")).toBe("https://example.com/foo");
|
||||
expect(trimUrlCandidate("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe(
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for something that is not a URL at all", () => {
|
||||
expect(trimUrlCandidate("https://.")).toBeNull();
|
||||
expect(trimUrlCandidate("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Where a plain-text URL in terminal output starts and ends.
|
||||
*
|
||||
* The old pattern, `https?:\/\/[^\s'"`<>\x00-\x20\x7f]+`, ran to the next
|
||||
* whitespace, so the prose around a link came with it: `See https://x/docs.`
|
||||
* underlined and opened `https://x/docs.`, `(https://x/a)` opened
|
||||
* `https://x/a)`, and a markdown link `[t](https://x/a)` the same. This uses
|
||||
* the rules GitHub's autolinker and linkify apply:
|
||||
*
|
||||
* - A URL never *ends* in `.` `,` `;` `:` `!` `?` `*` — those are sentence
|
||||
* punctuation (and markdown emphasis) when they come last. In the middle
|
||||
* (`a.b`, `a,b`, `?q=`) they are kept.
|
||||
* - A closing `)` `]` `}` is kept only when it closes a bracket opened inside
|
||||
* the URL — `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren,
|
||||
* `(https://x/a)` loses the one that wraps it. A closer followed by more URL
|
||||
* (`/a)b`) is kept either way, since it cannot be wrapping anything.
|
||||
* - `-` `_` `~` `=` `/` are never trimmed: OAuth `state` and
|
||||
* `code_challenge` are base64url, which can end in any of the first two,
|
||||
* and `=` is base64 padding.
|
||||
*
|
||||
* It is a single regex rather than a match-then-trim because `WebLinksAddon`
|
||||
* underlines exactly what its regex matches: trimming only the URL handed to
|
||||
* the click handler would still paint the `.` as part of the link. The
|
||||
* bracket rule is one level deep, which covers every real case (Wikipedia
|
||||
* disambiguations, IPv6 hosts, `a[]=` query keys) without needing a parser.
|
||||
*
|
||||
* The set of characters a URL may contain at all is unchanged — whitespace,
|
||||
* quotes, backtick, `<` `>`, the whole C0 range and DEL still end a match —
|
||||
* and nothing here is a security decision: `sanitizeRelayUrl` still decides
|
||||
* what may be opened.
|
||||
*/
|
||||
|
||||
/** Any character a URL may contain, minus brackets (handled separately). */
|
||||
const C = "[^\\s'\"`<>()\\[\\]{}\\x00-\\x20\\x7f]";
|
||||
/** A character a URL may end with: `C` minus trailing punctuation. */
|
||||
const END_CHAR = "[^\\s'\"`<>()\\[\\]{}\\x00-\\x20\\x7f.,;:!?*]";
|
||||
/** A one-level balanced bracket group: `(bar)`, `[::1]`, `[]`, `{id}`. */
|
||||
const GROUP = `\\(${C}*\\)|\\[${C}*\\]|\\{${C}*\\}`;
|
||||
/** An opener that starts no balanced group (`/a(b`), so it is plain text. */
|
||||
const LONE_OPEN = `\\((?!${C}*\\))|\\[(?!${C}*\\])|\\{(?!${C}*\\})`;
|
||||
/** A closer, allowed mid-URL; the final position excludes it unless balanced. */
|
||||
const CLOSE = "[)\\]}]";
|
||||
|
||||
const URL_SOURCE = `https?:\\/\\/(?:${GROUP}|${C}|${LONE_OPEN}|${CLOSE})*(?:${GROUP}|${END_CHAR})`;
|
||||
|
||||
/**
|
||||
* The regex handed to `WebLinksAddon` as `urlRegex`.
|
||||
*
|
||||
* No flags: `LinkComputer.computeLink` rebuilds it with `g` appended to
|
||||
* whatever flags it has, so giving it `g` here would be redundant and `y`
|
||||
* would break the scan.
|
||||
*/
|
||||
export const TERMINAL_URL_REGEX = new RegExp(URL_SOURCE);
|
||||
|
||||
const ANCHORED = new RegExp(`^(?:${URL_SOURCE})`);
|
||||
|
||||
/**
|
||||
* The maximal run of URL characters starting at a scheme — the old pattern.
|
||||
* Still useful for knowing how far the raw text ran, e.g. whether it reached
|
||||
* the end of a buffer that may still be growing.
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const RAW_RUN = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g;
|
||||
|
||||
/**
|
||||
* The URL at the start of `candidate`, with trailing punctuation and
|
||||
* unbalanced closers removed; `null` if nothing URL-shaped is left.
|
||||
*/
|
||||
export function trimUrlCandidate(candidate: string): string | null {
|
||||
const m = ANCHORED.exec(candidate);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
export interface UrlMatch {
|
||||
/** The URL as it should be shown and opened. */
|
||||
url: string;
|
||||
/** Where it starts in the input. */
|
||||
index: number;
|
||||
/** Where the raw run of URL characters ended, trailing punctuation included. */
|
||||
rawEnd: number;
|
||||
}
|
||||
|
||||
/** Every URL in `text`, trimmed, with the extent of the raw run it came from. */
|
||||
export function findUrls(text: string): UrlMatch[] {
|
||||
const out: UrlMatch[] = [];
|
||||
RAW_RUN.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = RAW_RUN.exec(text)) !== null) {
|
||||
const url = trimUrlCandidate(m[0]);
|
||||
if (url) out.push({ url, index: m.index, rawEnd: m.index + m[0].length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const buffer = (rows: Array<[string, boolean]>): RowSource => ({
|
||||
describe("joinWrappedRows", () => {
|
||||
it("returns a single unwrapped row as-is", () => {
|
||||
const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0);
|
||||
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] });
|
||||
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0], rowIndents: [0] });
|
||||
});
|
||||
|
||||
it("walks up to the row that started the wrap and down through continuations", () => {
|
||||
@@ -38,6 +38,120 @@ describe("joinWrappedRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinWrappedRows on a long chain", () => {
|
||||
const cols = 40;
|
||||
/** 1000 hard-wrapped rows: row 0 at column 0, every later row indented 2. */
|
||||
const chainRows = (): Array<[string, boolean]> => {
|
||||
const rows: Array<[string, boolean]> = [["/" + "a".repeat(cols - 1), false]];
|
||||
for (let i = 1; i < 1000; i++) rows.push([" " + String(i).padStart(4, "0") + "b".repeat(cols - 6), false]);
|
||||
return rows;
|
||||
};
|
||||
const counting = (rows: Array<[string, boolean]>) => {
|
||||
const b = buffer(rows);
|
||||
let reads = 0;
|
||||
return {
|
||||
reads: () => reads,
|
||||
source: { getLine: (y: number) => (reads++, b.getLine(y)) } as RowSource,
|
||||
};
|
||||
};
|
||||
|
||||
it("always includes the hovered row, deep in a chain longer than the budget", () => {
|
||||
const rows = chainRows();
|
||||
for (const row of [45, 300, 999]) {
|
||||
const j = joinWrappedRows(buffer(rows), row, { cols });
|
||||
expect(j.firstRow).toBeLessThanOrEqual(row);
|
||||
expect(j.firstRow + j.rowStarts.length).toBeGreaterThan(row);
|
||||
expect(j.text).toContain(String(row).padStart(4, "0"));
|
||||
expect(j.text.length).toBeLessThanOrEqual(MAX_JOINED_LENGTH);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a continuation's indent when the window starts mid-chain", () => {
|
||||
const j = joinWrappedRows(buffer(chainRows()), 500, { cols });
|
||||
expect(j.firstRow).toBeGreaterThan(0);
|
||||
expect(j.rowIndents[0]).toBe(2);
|
||||
expect(j.text.startsWith(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a bounded number of rows, not the whole chain above", () => {
|
||||
const c = counting(chainRows());
|
||||
joinWrappedRows(c.source, 999, { cols });
|
||||
// The window holds at most MAX_JOINED_LENGTH / (cols - 2) rows; each is
|
||||
// read a few times by the walk. Reading all 1000 rows would be far more.
|
||||
expect(c.reads()).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Claude Code wraps its own output: a word longer than the line is broken
|
||||
* mid-word and the rest is drawn on the next row with cursor moves, so xterm
|
||||
* never marks that row `isWrapped`. Captured from Claude Code at 70 columns
|
||||
* (bytes: `...9329-e\r\x1b[2C\x1b[1B039e2a...`).
|
||||
*/
|
||||
const CLAUDE_ROW_1 = "\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e";
|
||||
const CLAUDE_ROW_2 = " 039e2a07562/scratchpad/notes.md ok";
|
||||
const SCRATCH = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
|
||||
|
||||
describe("joinWrappedRows across Claude Code's hard wraps", () => {
|
||||
it("the captured row really does end at the right edge", () => {
|
||||
expect(CLAUDE_ROW_1.length).toBe(70);
|
||||
});
|
||||
|
||||
it("joins a path broken mid-word onto the next row, dropping the indent", () => {
|
||||
const b = buffer([["before", false], [CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false], ["after", false]]);
|
||||
const j = joinWrappedRows(b, 1, { cols: 70 });
|
||||
expect(j.text).toBe(`\u25cf The file is at ${SCRATCH} ok`);
|
||||
expect(j.firstRow).toBe(1);
|
||||
expect(j.rowStarts).toEqual([0, 70]);
|
||||
expect(j.rowIndents).toEqual([0, 2]);
|
||||
// Asked from the continuation row, the same line comes back.
|
||||
expect(joinWrappedRows(b, 2, { cols: 70 })).toEqual(j);
|
||||
// The first character of the continuation is drawn at column 3.
|
||||
expect(offsetToCell(j, 70)).toEqual({ x: 3, y: 3 });
|
||||
});
|
||||
|
||||
it("joins a path broken over three rows", () => {
|
||||
const cols = 30;
|
||||
const path = "/tmp/" + "a".repeat(60) + "/notes.md"; // 74 chars
|
||||
const rows: Array<[string, boolean]> = [
|
||||
[path.slice(0, 30), false],
|
||||
[" " + path.slice(30, 58), false],
|
||||
[" " + path.slice(58), false],
|
||||
];
|
||||
const j = joinWrappedRows(buffer(rows), 2, { cols });
|
||||
expect(j.text).toBe(path);
|
||||
expect(j.rowIndents).toEqual([0, 2, 2]);
|
||||
});
|
||||
|
||||
it("does not glue an ordinary word at the edge to a short word below it", () => {
|
||||
const cols = 40;
|
||||
const row1 = "Run the tests and then go and look at it"; // ends at the edge
|
||||
expect(row1.length).toBe(cols);
|
||||
const b = buffer([[row1, false], [" src/main.rs for details", false]]);
|
||||
expect(joinWrappedRows(b, 0, { cols }).text).toBe(row1);
|
||||
expect(joinWrappedRows(b, 1, { cols }).text).toBe(" src/main.rs for details");
|
||||
});
|
||||
|
||||
it("does not glue two column-0 lines together (continuations are indented)", () => {
|
||||
const cols = 40;
|
||||
const row0 = "/var/log/some/very/long/dir/file-aaa.txt";
|
||||
expect(row0.length).toBe(cols);
|
||||
const b = buffer([[row0, false], ["src/main.rs", false]]);
|
||||
expect(joinWrappedRows(b, 0, { cols }).text).toBe(row0);
|
||||
expect(joinWrappedRows(b, 1, { cols }).text).toBe("src/main.rs");
|
||||
});
|
||||
|
||||
it("does not bridge a row that stops short of the edge", () => {
|
||||
const b = buffer([["/tmp/claude-1000/-workspace/abc", false], ["def/notes.md", false]]);
|
||||
expect(joinWrappedRows(b, 1, { cols: 70 }).text).toBe("def/notes.md");
|
||||
});
|
||||
|
||||
it("does not bridge without knowing the width", () => {
|
||||
const b = buffer([[CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false]]);
|
||||
expect(joinWrappedRows(b, 0).text).toBe(CLAUDE_ROW_1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offsetToCell", () => {
|
||||
it("maps offsets to 1-based cells on the right row", () => {
|
||||
const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] };
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Joins an xterm buffer row with its wrapped continuations.
|
||||
*
|
||||
* Two kinds: xterm's own soft wraps (`isWrapped`), and — when the caller
|
||||
* passes the terminal width — the mid-word breaks Claude Code makes itself,
|
||||
* which xterm cannot see (see `hardWrapIndent`).
|
||||
*
|
||||
* `WebLinksAddon` does the same in its private `LinkComputer`, which the
|
||||
* built package does not export — so the walk is repeated here, with the same
|
||||
* 2048-character budget. Rows are read with `translateToString(true)`, which
|
||||
@@ -13,6 +17,8 @@
|
||||
* lands a cell early; the click still resolves the same link).
|
||||
*/
|
||||
|
||||
import { PATH_SEGMENT_CLASS } from "./filePathLinks";
|
||||
|
||||
export const MAX_JOINED_LENGTH = 2048;
|
||||
|
||||
/** Minimal slice of xterm's IBuffer this needs. */
|
||||
@@ -20,28 +26,118 @@ export interface RowSource {
|
||||
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
|
||||
}
|
||||
|
||||
export interface JoinOptions {
|
||||
/**
|
||||
* The terminal's width. Given, rows that Claude Code broke mid-word are
|
||||
* joined too (see `hardWrapIndent`); omitted, only xterm's soft wraps are.
|
||||
*/
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
export interface JoinedLine {
|
||||
text: string;
|
||||
/** 0-based index of the first buffer row that contributed. */
|
||||
firstRow: number;
|
||||
/** For each contributed row (in order), the string offset at which it starts. */
|
||||
rowStarts: number[];
|
||||
/**
|
||||
* For each contributed row, how many leading cells were left out of `text`:
|
||||
* the indent of a hard-wrapped continuation, 0 for every other row.
|
||||
*/
|
||||
rowIndents: number[];
|
||||
}
|
||||
|
||||
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
|
||||
/** The characters `findFilePathLinks`' token is made of: segment characters and `/`. */
|
||||
const PATH_CLASS = `[${PATH_SEGMENT_CLASS}/]`;
|
||||
const PATH_CHAR = new RegExp(PATH_CLASS);
|
||||
const TRAILING_PATH_RUN = new RegExp(`${PATH_CLASS}+$`);
|
||||
const LEADING_PATH_RUN = new RegExp(`^${PATH_CLASS}+`);
|
||||
/** Cells a full row may stop short of the right edge (Ink boxes pad by one or two). */
|
||||
const EDGE_SLACK = 2;
|
||||
/** Claude Code draws its transcript (and so every continuation row) at least this far in. */
|
||||
const MIN_CONTINUATION_INDENT = 2;
|
||||
|
||||
/**
|
||||
* Is `next` the rest of a word that Claude Code broke at the end of `prev`?
|
||||
* Returns the continuation's indent (cells to skip), or `null`.
|
||||
*
|
||||
* Claude Code wraps its own output (Ink / wrap-ansi) and draws continuation
|
||||
* rows with cursor moves, so xterm never marks them `isWrapped` — captured at
|
||||
* 70 columns: `…/5e4ab145-915e-4902-9329-e\r\x1b[2C\x1b[1B039e2a…/notes.md`.
|
||||
* It breaks *inside* a word only when the word is longer than the line, which
|
||||
* is exactly what a long `/tmp/claude-<uid>/…` path is. So bridge only when
|
||||
* `prev` runs to (within {@link EDGE_SLACK} of) the right edge and ends in a
|
||||
* path character, `next` starts (after an indent of at least
|
||||
* {@link MIN_CONTINUATION_INDENT} cells — so two column-0 lines of shell
|
||||
* output are never glued) with one, and the two runs
|
||||
* together would not have fit on one line — i.e. word wrap could not have
|
||||
* moved the word down whole. That last test is what keeps ordinary prose (a
|
||||
* word that happens to end at the edge above `src/main.rs`) from being glued.
|
||||
*/
|
||||
function hardWrapIndent(prev: string, next: string, cols: number): number | null {
|
||||
if (prev.length < cols - EDGE_SLACK || prev.length > cols) return null;
|
||||
if (!PATH_CHAR.test(prev[prev.length - 1])) return null;
|
||||
const indent = next.search(/\S/);
|
||||
if (indent < MIN_CONTINUATION_INDENT || !PATH_CHAR.test(next[indent])) return null;
|
||||
const tail = TRAILING_PATH_RUN.exec(prev)?.[0].length ?? 0;
|
||||
const head = LEADING_PATH_RUN.exec(next.slice(indent))?.[0].length ?? 0;
|
||||
if (tail + head <= cols - indent - EDGE_SLACK) return null;
|
||||
return indent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cells to skip if row `y` continues row `y - 1`: 0 for an xterm soft wrap,
|
||||
* the indent for a Claude Code hard wrap, `null` if `y` starts a new line.
|
||||
*/
|
||||
function continuationIndent(buffer: RowSource, y: number, cols: number | undefined): number | null {
|
||||
if (y <= 0) return null;
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) return null;
|
||||
if (line.isWrapped) return 0;
|
||||
if (!cols) return null;
|
||||
const prev = buffer.getLine(y - 1);
|
||||
if (!prev) return null;
|
||||
return hardWrapIndent(prev.translateToString(true), line.translateToString(true), cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins `row` with the rows it wraps from and into. A chain longer than
|
||||
* {@link MAX_JOINED_LENGTH} is windowed around `row`: the walk up stops after
|
||||
* half the budget, the walk down after the whole of it, and `row` itself is
|
||||
* always included — so a hover deep in a huge wrapped blob reads a bounded
|
||||
* number of rows and still finds the text under the pointer.
|
||||
*/
|
||||
export function joinWrappedRows(buffer: RowSource, row: number, options: JoinOptions = {}): JoinedLine {
|
||||
const { cols } = options;
|
||||
let top = row;
|
||||
while (top > 0 && buffer.getLine(top)?.isWrapped) top--;
|
||||
let above = 0;
|
||||
while (top > 0 && continuationIndent(buffer, top, cols) !== null) {
|
||||
// Every row counts at least 1, so a run of blank soft-wrapped rows is bounded too.
|
||||
const len = Math.max(1, buffer.getLine(top - 1)?.translateToString(true).length ?? 0);
|
||||
if (above + len > MAX_JOINED_LENGTH / 2) break;
|
||||
above += len;
|
||||
top--;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const rowIndents: number[] = [];
|
||||
let length = 0;
|
||||
let y = top;
|
||||
for (;;) {
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) break;
|
||||
if (y !== top && !line.isWrapped) break;
|
||||
const text = line.translateToString(true);
|
||||
if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break;
|
||||
let indent = 0;
|
||||
const c = continuationIndent(buffer, y, cols);
|
||||
if (y !== top) {
|
||||
if (c === null) break;
|
||||
indent = c;
|
||||
} else if (c !== null) {
|
||||
indent = c; // the window starts mid-chain: drop this row's indent as well
|
||||
}
|
||||
const text = line.translateToString(true).slice(indent);
|
||||
if (length + text.length > MAX_JOINED_LENGTH && y > row) break;
|
||||
parts.push(text);
|
||||
rowIndents.push(indent);
|
||||
length += text.length;
|
||||
y++;
|
||||
}
|
||||
@@ -52,14 +148,18 @@ export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
|
||||
rowStarts.push(offset);
|
||||
offset += p.length;
|
||||
}
|
||||
return { text: parts.join(""), firstRow: top, rowStarts };
|
||||
return { text: parts.join(""), firstRow: top, rowStarts, rowIndents };
|
||||
}
|
||||
|
||||
/** String offset → 1-based {x, y} cell (y is the buffer row + 1). */
|
||||
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } {
|
||||
export function offsetToCell(
|
||||
joined: Pick<JoinedLine, "firstRow" | "rowStarts"> & Partial<Pick<JoinedLine, "rowIndents">>,
|
||||
offset: number,
|
||||
): { x: number; y: number } {
|
||||
let rowIdx = 0;
|
||||
for (let i = 0; i < joined.rowStarts.length; i++) {
|
||||
if (joined.rowStarts[i] <= offset) rowIdx = i;
|
||||
}
|
||||
return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 };
|
||||
const indent = joined.rowIndents?.[rowIdx] ?? 0;
|
||||
return { x: offset - joined.rowStarts[rowIdx] + indent + 1, y: joined.firstRow + rowIdx + 1 };
|
||||
}
|
||||
+25
-2
@@ -28,12 +28,33 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# archive.ubuntu.com returns stale Packages.gz files with mismatched hashes
|
||||
# during hourly resyncs. Clearing /var/lib/apt/lists/* between attempts
|
||||
# forces a fresh fetch.
|
||||
RUN for i in 1 2 3 4 5; do \
|
||||
#
|
||||
# `apt-get upgrade` is what picks up security fixes for packages the
|
||||
# `ubuntu:24.04` image already ships. `install` below never touches those, so
|
||||
# without it a fix that lands in noble-security between two refreshes of the
|
||||
# base tag stays out of this image until the next refresh. Packages the later
|
||||
# layers install come straight from the archive, so this one upgrade is enough.
|
||||
#
|
||||
# CI builds with `cache-from: type=gha`, so this layer (and everything after
|
||||
# it) only re-runs when its cache key changes: the base digest, this layer's
|
||||
# text, or `APT_REFRESH`. CI sets that to the ISO week (`2026-W45`) and also
|
||||
# rebuilds weekly on a schedule, so a security fix published after the last
|
||||
# commit is in the image within a week. An ARG only changes the key of a RUN
|
||||
# that uses it, hence the `echo`. Local builds leave it empty.
|
||||
#
|
||||
# Scanned on 2026-11-02, the published image of 2026-10-16 carried
|
||||
# libbrotli1 1.1.0-2build2 (CVE-2026-111497, HIGH) and zlib1g 1.3.dfsg-3.1ubuntu2.2
|
||||
# (CVE-2026-85091), both with fixed versions in the archive, and pnpm's bundled
|
||||
# undici 6.29.0 (three HIGH CVEs, fixed in the pnpm 12.16.1 a rebuild installs).
|
||||
ARG APT_REFRESH=
|
||||
RUN echo "apt refresh: ${APT_REFRESH:-unset}" \
|
||||
&& for i in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=3 update && break; \
|
||||
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
sleep 10; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=3 upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
@@ -274,7 +295,9 @@ RUN npx --yes playwright@latest --version \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.npm
|
||||
|
||||
# ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
|
||||
RUN for i in 1 2 3 4 5; do \
|
||||
ARG APT_REFRESH=
|
||||
RUN echo "apt refresh: ${APT_REFRESH:-unset}" \
|
||||
&& for i in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=3 update && break; \
|
||||
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
|
||||
+52
-2
@@ -736,6 +736,56 @@ timeout 120 su -s /bin/bash claude -c 'export PATH="/home/claude/.claude/bin:/ho
|
||||
&& echo "entrypoint: Claude Code is up to date" \
|
||||
|| echo "entrypoint: warning — Claude Code update skipped or failed (continuing)"
|
||||
|
||||
# ── Stay alive as claude ─────────────────────────────────────────────────────
|
||||
# ── Stay alive, as root ──────────────────────────────────────────────────────
|
||||
# The container lives exactly as long as this script: docker-init is PID 1 and
|
||||
# exits when its one child does. So what keeps it alive must be something the
|
||||
# claude user cannot kill. This used to end in
|
||||
# exec su -s /bin/bash claude -c "exec sleep infinity"
|
||||
# which hung the container's whole lifetime on a `sleep` owned by claude — and
|
||||
# Claude Code runs as claude and routinely runs `pkill sleep`, `pkill -f …`,
|
||||
# `killall` or `kill -9 -1` while testing something. Any of those stopped the
|
||||
# container: every terminal disconnected and the app still said Running.
|
||||
# Dropping to claude there bought nothing (it dates from the initial commit,
|
||||
# with no stated reason, and nothing reads that process's owner); its one
|
||||
# reader is the app's marketplace readiness probe, which now also matches the
|
||||
# marker below (app/src-tauri/src/marketplace/sync.rs, READY_PROBE).
|
||||
#
|
||||
# * Unprivileged kills get EPERM on both this script and its sleep. Even
|
||||
# `sudo pkill sleep` only kills the child, which the loop replaces; it takes
|
||||
# a root kill of this script itself.
|
||||
# * `docker stop`: docker-init forwards SIGTERM here, and bash runs the trap
|
||||
# as soon as `wait` returns, so the container stops at once rather than
|
||||
# sitting out Docker's grace period and being SIGKILLed.
|
||||
# * The sleep runs as `triple-c-keepalive infinity` (exec -a), a root-owned
|
||||
# command line nothing else has, and it exists only once every step above
|
||||
# has finished — that is the readiness marker. Keep the name in step with
|
||||
# READY_PROBE.
|
||||
# * Respawns are throttled to about one a second. If the sleep cannot start
|
||||
# (fork failing under a pids limit or a fork bomb) or dies at once, an
|
||||
# unthrottled loop would spin a core at 100% exactly when the container is
|
||||
# already in trouble. The pause is `read -t` on a FIFO nobody writes —
|
||||
# a builtin, so it still works when fork does not — with `sleep 1` as the
|
||||
# fallback should the FIFO be unavailable.
|
||||
echo "Triple-C container ready."
|
||||
exec su -s /bin/bash claude -c "exec sleep infinity"
|
||||
keepalive_pid=""
|
||||
stop_keepalive() {
|
||||
[ -n "$keepalive_pid" ] && kill "$keepalive_pid" 2>/dev/null
|
||||
exit 0
|
||||
}
|
||||
trap stop_keepalive TERM INT HUP
|
||||
throttle_fifo=/run/triple-c-keepalive.throttle
|
||||
rm -f "$throttle_fifo"
|
||||
mkfifo -m 600 "$throttle_fifo" 2>/dev/null && exec 3<>"$throttle_fifo"
|
||||
rm -f "$throttle_fifo"
|
||||
respawn_pause() {
|
||||
read -r -t 1 -u 3 _ 2>/dev/null
|
||||
# >128 is read's timeout, i.e. it paused; anything else means no FIFO.
|
||||
[ $? -gt 128 ] || sleep 1
|
||||
}
|
||||
while :; do
|
||||
started=$SECONDS
|
||||
(exec -a triple-c-keepalive sleep infinity 3>&-) &
|
||||
keepalive_pid=$!
|
||||
wait "$keepalive_pid"
|
||||
[ $((SECONDS - started)) -ge 2 ] || respawn_pause
|
||||
done
|
||||
@@ -0,0 +1,625 @@
|
||||
# Triple-C on AWS (ECS on EC2 / Fargate) — design
|
||||
|
||||
Date: 2026-11-13
|
||||
Status: **draft for review** (user decisions recorded 2026-11-13). The user set the direction (local or AWS, login like the AWS CLI,
|
||||
permission check, hosting options, secrets pushed automatically, shared accounts). Everything else
|
||||
here is a proposal.
|
||||
|
||||
## Goal
|
||||
|
||||
A project can run its container on **AWS ECS** instead of the local Docker daemon, so it keeps
|
||||
running for days without the user's machine. The user picks the host per project. Local Docker
|
||||
keeps working exactly as it does today.
|
||||
|
||||
The AWS flow, in the user's words:
|
||||
|
||||
1. Pick **Local Docker** or **AWS** as the host.
|
||||
2. AWS starts with a **login**, by SSO or access keys, the same way the AWS CLI does.
|
||||
3. Triple-C **checks it has the permissions** it needs to set up the workspace.
|
||||
4. The user picks **how it is hosted**: ECS on EC2 or Fargate, ephemeral storage or EFS.
|
||||
5. SSH keys, certificates and similar uploads go to **Secrets Manager or straight to the
|
||||
instance**, without manual steps.
|
||||
6. **A shared AWS account used by several people is the normal case**, not an edge case.
|
||||
|
||||
### Decisions (user, 2026-11-13)
|
||||
|
||||
| Topic | Decision |
|
||||
|---|---|
|
||||
| Who provisions | **Not necessarily an admin.** Triple-C **detects** existing Triple-C infrastructure in the account and offers **join it** or **deploy a new one**. Many users have no admin, so deploying is a first-class path, not an admin-only extra |
|
||||
| WIP push before stop | **On by default** (per project, can be turned off) |
|
||||
| Claude Code login | **Unchanged.** Anthropic login (`claude login`, OAuth through the auth bridge, or the shared token) or Bedrock, exactly as for local projects. The AWS host doesn't change how Claude authenticates |
|
||||
| Tenancy | **Strict from v1, and the default.** Most people will set this up in their own accounts. Strict costs a single user nothing extra (they onboard themselves automatically), and it means the setup is already safe the day a second person joins |
|
||||
| Connection model | **C, hybrid.** A connection is a login (profile or keys) plus a **default region**. Environments are detected per region when needed; each project records `(connection, region, environment)`. See [Connection model](#connection-model) |
|
||||
|
||||
### Non-goals (v1)
|
||||
|
||||
- Kubernetes/EKS, other clouds, or remote Docker hosts. The host abstraction below allows them
|
||||
later.
|
||||
- Per-project snapshot images (`docker commit`) on AWS. See [Snapshots](#snapshots).
|
||||
- Moving an existing local project's state to AWS. A project can be switched to AWS only if its
|
||||
folders are git repos with remotes, and it starts fresh there.
|
||||
- Running the speech-to-text container on AWS. The microphone is on the user's machine, so STT
|
||||
stays local.
|
||||
- Docker Sandboxes, gVisor or Kata. That is a separate design.
|
||||
|
||||
## Terminology
|
||||
|
||||
`Project.backend` (`models/project.rs`) already means the **LLM provider** (Anthropic, Bedrock,
|
||||
Ollama, llama.cpp, OpenAI-compatible). To avoid two meanings of "backend", this design uses:
|
||||
|
||||
| Term | Meaning |
|
||||
|---|---|
|
||||
| **Host** | Where a project's container runs: `Local` (Docker today) or `Aws` |
|
||||
| **AWS connection** | Saved credentials and region (an SSO profile or access keys), stored in app settings |
|
||||
| **Environment** | Shared infrastructure in one AWS account and region: cluster, VPC wiring, ECR, EFS, KMS key, IAM roles, log group. It is provisioned once, often by an admin, and used by many people |
|
||||
| **Workspace** | One user's project inside an environment: task definition, EFS access point, secrets, and the running task |
|
||||
| **Owner** | The AWS principal that created a workspace. Every per-user resource is tagged with it |
|
||||
|
||||
Environment vs. workspace is the central decision. It is what makes shared accounts work: admins
|
||||
own the environment, users only ever create things inside their own workspaces.
|
||||
|
||||
## User flow
|
||||
|
||||
### 1. Settings → Hosts → Add AWS connection
|
||||
|
||||
- **Auth method** (same options as the AWS CLI):
|
||||
- **AWS profile**: any profile in `~/.aws/config`, including SSO profiles. SSO login reuses
|
||||
`commands/aws_commands.rs` (`is_sso_profile`, `check_sso_session`, `run_sso_login`), which
|
||||
already does this for Bedrock.
|
||||
- **Access keys**: access key ID, secret, and an optional session token. Stored in the OS
|
||||
keyring through `storage/secure.rs`, never in `settings.json`.
|
||||
- Later: the SSO device flow through the SDK (`aws-sdk-ssooidc`), so SSO works without the
|
||||
AWS CLI installed.
|
||||
- **Default region.** Used for detection and preselected for new projects. Other regions are
|
||||
scanned only when the user picks one for a project.
|
||||
- When saved, call `sts:GetCallerIdentity` and show **"Signed in as `<arn>`"**. The identity
|
||||
determines the owner ID (see [Multi-user accounts](#multi-user-accounts)).
|
||||
|
||||
AWS API calls go through the Rust SDK (`aws-config` plus the service crates). That way, access-key
|
||||
users don't need the AWS CLI. Only SSO login needs it, until the device flow lands.
|
||||
|
||||
### 2. Detect, then join or deploy
|
||||
|
||||
Triple-C looks for existing Triple-C infrastructure in the region in two ways, so it finds an
|
||||
environment even if one of them is missing:
|
||||
|
||||
- the SSM parameters `/triple-c/environments/*`, written by the environment stack, whose JSON
|
||||
value lists the environment's ARNs and IDs;
|
||||
- CloudFormation stacks tagged `triple-c:component=environment`. This catches a stack whose
|
||||
parameter was deleted, or one that is half-deployed (`*_IN_PROGRESS`, `*_FAILED`). Those are
|
||||
shown with their state and a "Repair or delete" action instead of being silently ignored.
|
||||
|
||||
Detection also reads the template version each stack was deployed from, so an environment deployed
|
||||
by an older Triple-C shows **"Update available"**.
|
||||
|
||||
What the user sees depends on what was found and what they're allowed to do:
|
||||
|
||||
| Found | Can deploy (stack permissions) | Offered |
|
||||
|---|---|---|
|
||||
| Nothing | Yes | **Deploy a new environment** (the default path for a personal account) |
|
||||
| Nothing | No | Explanation, plus the policy JSON and a pre-filled CloudFormation console link to send to whoever owns the account |
|
||||
| One or more | Yes | **Join** `<name>` (default) or **Deploy a new one** (for example a separate region or cost boundary) |
|
||||
| One or more | No | **Join** `<name>` |
|
||||
|
||||
**Deploy** uses the **CloudFormation template that ships with the app**
|
||||
(`aws/triple-c-environment.yaml`). A CloudFormation stack is auditable, an admin can deploy it
|
||||
without Triple-C, and deleting the stack removes everything. The app never creates environment
|
||||
resources one API call at a time. Deploy progress streams from the stack events into the same
|
||||
progress view used for local image pulls.
|
||||
|
||||
**Joining in strict tenancy** (the default) also needs the user's **onboarding stack**: their own
|
||||
task role, EFS access-point policy entry and, if they use EC2, a dedicated capacity provider. The
|
||||
app checks whether the caller already has one:
|
||||
|
||||
- **The caller can deploy it** (always true in their own account): Triple-C deploys
|
||||
`triple-c-user-onboard.yaml` for them as part of joining. For the person who just deployed the
|
||||
environment, this happens in the same flow, so a single user never sees "onboarding" as a step.
|
||||
- **The caller can't**: Triple-C produces an onboarding request for whoever runs the environment —
|
||||
a pre-filled CloudFormation console link plus the exact parameters — and the user's "Join" stays
|
||||
pending until the stack exists. The app re-checks whenever the user opens the dialog.
|
||||
|
||||
### 3. Permission check
|
||||
|
||||
There are three tiers, and the check reports which one the user is in:
|
||||
|
||||
| Tier | Can do |
|
||||
|---|---|
|
||||
| **Environment admin** | Deploy or update the environment stack, onboard users (strict tenancy) |
|
||||
| **Workspace user** | Create, start, stop, connect to and delete **their own** workspaces in an existing environment |
|
||||
| **Insufficient** | Lists exactly which actions are missing, and offers the IAM policy JSON to send to an admin |
|
||||
|
||||
How the check works:
|
||||
|
||||
1. Call `iam:SimulatePrincipalPolicy` for the caller's role or user ARN, against the action list
|
||||
for each tier, using the environment's real resource ARNs and the tag conditions a request
|
||||
would carry.
|
||||
2. If the user can't run the simulation (common for locked-down SSO roles), fall back to read-only
|
||||
probes (`ecs:DescribeClusters`, `ssm:GetParameter`, `secretsmanager:ListSecrets` filtered by
|
||||
tag, `elasticfilesystem:DescribeAccessPoints`). Write actions are then marked **"unverified —
|
||||
will be confirmed on first use"**, not "OK".
|
||||
3. Be honest about the gaps: the simulation does not apply SCPs, permission boundaries or session
|
||||
policies. The first real create is the final check, and its error must name the denied action.
|
||||
|
||||
The exact policies for each tier ship as files with the app (`aws/policies/*.json`). Settings
|
||||
links to them so an admin can grant them without reading this doc.
|
||||
|
||||
### 4. Hosting options (per project, defaults per environment)
|
||||
|
||||
| Option | Choices | Notes |
|
||||
|---|---|---|
|
||||
| Launch type | **Fargate** (default), **ECS on EC2** | EC2 choices are limited by the environment's tenancy mode, see below |
|
||||
| CPU / memory | Fargate sizes (0.25–16 vCPU) or EC2 reservations | The admin can cap these per environment |
|
||||
| Architecture | x86_64 / ARM64 (Graviton) | The image is already built for `linux/amd64` and `linux/arm64` |
|
||||
| Storage | **Hybrid** (default), **Ephemeral**, **EFS** | See [Storage](#storage) |
|
||||
| Ephemeral size | 21–200 GiB (Fargate) | On EC2, the instance volume |
|
||||
| Fargate Spot | off / on | Cheaper. Can be interrupted with 2 minutes' warning |
|
||||
| Idle auto-stop | off / N hours with no terminal or agent activity | The admin can set a maximum |
|
||||
| Repos | git URL + branch + folder name, one per `ProjectPath` | Replaces host paths on AWS |
|
||||
| Secrets delivery | **Secrets Manager** (default), **Copy at connect** | See [Secrets and uploads](#secrets-and-uploads) |
|
||||
|
||||
### 5. Start, connect, stop
|
||||
|
||||
- **Start** calls `ecs:RunTask` (a standalone task, not a service), tags the task with the owner,
|
||||
and polls `DescribeTasks` until the task is `RUNNING` and Triple-C's in-container agent (see
|
||||
[Connectivity](#connectivity)) reports ready. Progress appears in the sidebar the same way as for
|
||||
local projects.
|
||||
- **Connect**: terminals, file viewer, browser view, auth bridge and port mappings all work through
|
||||
the agent tunnel.
|
||||
- **Closing the app does not stop the task.** That is the point of this feature: the scheduler,
|
||||
long agent runs and so on keep going. Reopening the app reconnects to the running tasks.
|
||||
- **Stop** first checks for unsaved work (see [Storage](#storage)), then calls `ecs:StopTask`.
|
||||
- **Delete** removes the task definition revisions, the secrets, and (after confirmation) the
|
||||
workspace's EFS data.
|
||||
|
||||
## Architecture
|
||||
|
||||
### Host abstraction
|
||||
|
||||
The app currently talks to Docker directly: `bollard` via `docker/client.rs`, about 38 exec call
|
||||
sites across terminals, file viewer, auth bridge, browser view, migration and marketplace,
|
||||
`container_watch.rs` on the Docker event stream, and `commit_container_snapshot`. Introduce a
|
||||
`ProjectHost` trait in `app/src-tauri/src/host/` with two implementations:
|
||||
|
||||
```rust
|
||||
#[async_trait]
|
||||
trait ProjectHost {
|
||||
async fn start(&self, p: &Project) -> Result<HostHandle>;
|
||||
async fn stop(&self, h: &HostHandle) -> Result<()>;
|
||||
async fn status(&self, h: &HostHandle) -> Result<HostStatus>;
|
||||
async fn exec(&self, h: &HostHandle, cmd: ExecSpec) -> Result<ExecStream>; // pty or piped
|
||||
async fn read_file(&self, h: &HostHandle, path: &str) -> Result<Bytes>;
|
||||
async fn write_file(&self, h: &HostHandle, path: &str, data: Bytes) -> Result<()>;
|
||||
async fn tunnel(&self, h: &HostHandle, port: u16) -> Result<DuplexStream>; // auth bridge, browser view, ports
|
||||
fn events(&self) -> BoxStream<HostEvent>; // replaces container_watch's die events
|
||||
fn capabilities(&self) -> HostCaps; // see compatibility matrix
|
||||
}
|
||||
```
|
||||
|
||||
- `DockerHost` wraps today's code, with no behaviour change.
|
||||
- `AwsHost` implements the trait with the ECS/EC2/EFS/Secrets Manager SDK calls plus the agent.
|
||||
- **Phase 1 is a pure refactor**: every exec, file and tunnel call site moves onto the trait, with
|
||||
local only. The AWS work then goes behind a stable interface.
|
||||
|
||||
`HostCaps` drives the UI. Options a host can't support (VPN, Docker access, snapshots…) are shown
|
||||
disabled with the reason, instead of failing at start.
|
||||
|
||||
### Connectivity
|
||||
|
||||
ECS Exec (SSM Session Manager) gives a shell in a Fargate task with no inbound ports, no public IP
|
||||
and no SSH. Fargate injects the SSM agent, so the image doesn't need it. Two problems with using it
|
||||
directly:
|
||||
|
||||
- Each `execute-command` opens a new SSM session, which takes about 1 second. That is fine for a
|
||||
terminal, but too slow for the file viewer's polling, the auth bridge's `/proc/net` polling and
|
||||
per-connection tunnels.
|
||||
- The session protocol is a custom websocket framing that the Rust SDK doesn't implement.
|
||||
|
||||
**Design:** a small **in-container agent**, `triple-c-agent`, a static musl Rust binary added to
|
||||
the image.
|
||||
|
||||
- It listens on `127.0.0.1:<port>` inside the task.
|
||||
- It multiplexes, over one authenticated connection: pty exec, piped exec, file
|
||||
read/write/stat/list/watch, TCP tunnels to in-container loopback ports, and liveness/activity
|
||||
for idle auto-stop.
|
||||
- The app reaches it through **one SSM port-forwarding session** (`AWS-StartPortForwardingSession`,
|
||||
target `ecs:<cluster>_<taskId>_<runtimeId>`), opened when the project connects and reopened if
|
||||
it drops.
|
||||
- Authentication uses a random per-task token, delivered as a secret at task start. The SSM
|
||||
session's IAM checks (`ssm:StartSession` and `ecs:ExecuteCommand`, both scoped by the owner tag)
|
||||
are the outer gate; the token is defence in depth.
|
||||
- v1 runs AWS's `session-manager-plugin` as a child process for the port-forward (it is
|
||||
Apache-2.0, so Triple-C can bundle it). A native Rust client for the SSM protocol could replace
|
||||
it later.
|
||||
|
||||
This one channel replaces every `docker exec … socat` tunnel (auth bridge, browser view, port
|
||||
mappings) and every exec call. The web terminal and audio bridge work unchanged, because they sit
|
||||
on top of the terminal and exec layer.
|
||||
|
||||
Network: tasks run in **private subnets** under a security group with **no inbound rules at all**.
|
||||
Outbound access goes through a NAT gateway, or VPC endpoints in restricted environments (ECR, S3,
|
||||
Logs, SSM, Secrets Manager, EFS, Bedrock). Two tasks in the same environment cannot reach each
|
||||
other.
|
||||
|
||||
### Image
|
||||
|
||||
**Same image, same Dockerfile.** No AWS-specific image. Changes:
|
||||
|
||||
1. Add `triple-c-agent` to the image.
|
||||
2. The entrypoint gets an `aws` mode (`TRIPLEC_HOST=aws`) that runs before the existing steps:
|
||||
- **Write secrets to files**: each `TRIPLEC_FILE_<name>` env var injected from Secrets Manager
|
||||
is written to the path the entrypoint **already reads** (`/tmp/.host-ssh/…`,
|
||||
`/tmp/.host-ca/…`, `/tmp/.host-aws/…`). After that, `sync_host_ssh`,
|
||||
`install_corporate_ca` and the AWS config merge run unchanged.
|
||||
- **Clone repos**: clone each repo into `/workspace/<folder>` if it isn't already there
|
||||
(it's already there on EFS after a restart). Git credentials come from the injected git token
|
||||
or SSH key.
|
||||
- **Skip** the UID/GID remap (there are no bind mounts; `claude` stays UID 1000, which the EFS
|
||||
access point matches) and the Docker-socket group setup (no socket).
|
||||
- Start `triple-c-agent` as root, outside the `claude` user's reach, and keep the existing
|
||||
unkillable keepalive.
|
||||
3. **Fargate restrictions** are handled by the host, not the image: no `privileged`, and no added
|
||||
capabilities except `SYS_PTRACE`. So VPN (`NET_ADMIN`) and Docker access are unavailable. The
|
||||
bubblewrap sandbox likely can't create user namespaces on Fargate (**verify**), so the generated
|
||||
Claude Code settings use `enableWeakerNestedSandbox` or turn the sandbox off, with a visible
|
||||
notice.
|
||||
|
||||
**Getting the image into the account:**
|
||||
|
||||
- ECR pull-through cache of `ghcr.io/shadowdao/triple-c-sandbox` (GHCR as an upstream needs a
|
||||
GitHub token stored in Secrets Manager), or
|
||||
- the environment stack's ECR repo, filled by an admin or CI with `crane copy`.
|
||||
|
||||
Pulling straight from GHCR over NAT also works, and is the default when the environment has no ECR mirror. Image
|
||||
updates use the existing "new image available" flow: the next start uses the new digest.
|
||||
|
||||
### Storage
|
||||
|
||||
| Mode | `/workspace` (repos) | `/home/claude` + `.claude` | Survives stop | Speed | Cost |
|
||||
|---|---|---|---|---|---|
|
||||
| **Ephemeral** | task storage | task storage | **No** | Fast | Included (beyond 20 GiB is billed) |
|
||||
| **Hybrid** (default) | task storage | EFS | Settings, sessions, memory, history: yes. Working tree: only what's pushed | Fast builds, fast git | Small EFS footprint |
|
||||
| **EFS** | EFS | EFS | Everything | git, `node_modules` and builds are noticeably slower over NFS | Grows with repo and dependency size |
|
||||
|
||||
- **One EFS file system per environment, one access point per workspace.** Access point root:
|
||||
`/triple-c/<owner>/<project-id>`, with POSIX user and group 1000. The access point is created by
|
||||
the user and tagged with the owner (see policies).
|
||||
- **WIP push is on by default** (`wip_push_on_stop: true`, with a serde default per the
|
||||
`default_full_permissions` pattern) and can be turned off per project.
|
||||
- **Ephemeral and Hybrid have a stop guard.** Before stopping, the app asks the agent for
|
||||
`git status --porcelain` and any unpushed commits in each repo. If anything would be lost, it
|
||||
offers: **Push to a `triple-c/wip/<date>` branch** (the preselected default), **Stop anyway**, or
|
||||
**Cancel**. The same check runs before idle auto-stop. If anything is dirty, idle auto-stop
|
||||
pushes a WIP branch instead of throwing work away.
|
||||
- **Fargate task retirement and Spot interruption** send `SIGTERM` with a short deadline (2 minutes
|
||||
for Spot). The entrypoint's `SIGTERM` trap runs the same WIP push, best-effort, when the project
|
||||
has it enabled.
|
||||
|
||||
#### Snapshots
|
||||
|
||||
`docker commit` doesn't exist on ECS. On AWS:
|
||||
|
||||
- Anything installed outside `/home/claude` (apt packages, for example) is **lost on restart**.
|
||||
- A per-project **setup script** (stored with the project, run by the entrypoint on every start)
|
||||
replaces snapshotting for the AWS host. Its output streams to the start-progress view.
|
||||
- Later, the app could build a per-workspace image from base image + setup script with CodeBuild
|
||||
and push it to ECR. Out of scope for v1.
|
||||
|
||||
### Secrets and uploads
|
||||
|
||||
Things that are host files or keyring values today: SSH key (`ssh_key_path`), CA cert
|
||||
(`ca_cert_path`), git token, AWS config for Bedrock, `CLAUDE_CODE_OAUTH_TOKEN` / API keys, custom
|
||||
env vars marked secret, the agent token.
|
||||
|
||||
**Secrets Manager (default)**
|
||||
|
||||
- When the user picks an SSH key or cert for an AWS project, the app reads it on the host and
|
||||
creates or updates a secret named `triple-c/<env>/<owner>/<project-id>/<name>`. The secret is
|
||||
encrypted with the environment's KMS key and tagged `triple-c:owner`, `triple-c:project`,
|
||||
`triple-c:environment`.
|
||||
- The task definition references the secrets in `containerDefinitions[].secrets`, so **the ECS
|
||||
task execution role** fetches them at start and injects them as env vars. The entrypoint writes
|
||||
them to files as described above.
|
||||
- **The container's own task role has no Secrets Manager permission.** Code running inside a task
|
||||
cannot read another workspace's secrets, even though tasks share a task role in standard
|
||||
tenancy.
|
||||
- Updating a key in the app updates the secret. The next start picks it up, and a running task
|
||||
gets a "restart to apply" prompt.
|
||||
- Secrets Manager's 64 KB per-secret limit covers keys and cert bundles. Anything bigger is
|
||||
rejected with a clear message.
|
||||
|
||||
**Copy at connect**
|
||||
|
||||
- Nothing is stored in AWS. When the app connects, it pushes the files into the running container
|
||||
through the agent, the same way Triple-C's file upload works today, writing to tmpfs paths that
|
||||
the entrypoint re-syncs from.
|
||||
- The task can't start a fresh clone of a **private** repo until the app connects, so the clone is
|
||||
deferred until the first connect. Scheduled jobs can't use the credentials while the app is
|
||||
closed, unless the files were persisted to EFS (an extra opt-in, warned about).
|
||||
- For users or orgs that don't want keys in AWS at all.
|
||||
|
||||
**Claude Code login works the same as locally (decided).** The project's LLM backend setting
|
||||
doesn't change on the AWS host:
|
||||
|
||||
- **Anthropic login**: `claude login` in a terminal. The OAuth callback reaches the container
|
||||
through the auth bridge over the agent tunnel. The credential persists in `~/.claude`, which is
|
||||
on EFS in Hybrid and EFS modes, and lost on stop in Ephemeral mode, where the UI says so. The
|
||||
shared auth token (`use_shared_auth_token` → `CLAUDE_CODE_OAUTH_TOKEN`) is delivered as a
|
||||
Secrets Manager secret instead of an env var.
|
||||
- **Bedrock**: the same Bedrock options as today (static keys, profile, bearer token). Profiles
|
||||
and SSO are delivered as described above, `triple-c-sso-refresh` keeps working, and the
|
||||
existing post-start `sync_bedrock_credentials` moves onto the agent. One optional extra,
|
||||
**off by default**: "Use this workspace's task role for Bedrock". Under strict tenancy the task
|
||||
role is the user's own, so this is safe and needs no credentials in the container. It is an
|
||||
extra choice, not a replacement.
|
||||
|
||||
### Multi-user accounts
|
||||
|
||||
**Owner ID.** `aws:userid` from `GetCallerIdentity`:
|
||||
|
||||
- for an IAM user, the user ID;
|
||||
- for SSO and assumed roles, `AROAxxxx:<session-name>`, where the session name is the SSO
|
||||
username.
|
||||
|
||||
It is stable per person and available as a policy variable, `${aws:userid}`. Every per-user
|
||||
resource is created with `triple-c:owner = ${aws:userid}`.
|
||||
|
||||
**Control-plane isolation (ABAC).** The workspace-user policy only allows:
|
||||
|
||||
- `ecs:RunTask`, with the request tag `triple-c:owner` set to `${aws:userid}`, on the
|
||||
environment's cluster and task definition family prefix, plus `iam:PassRole` **only** for the
|
||||
environment's execution and task roles;
|
||||
- `ecs:StopTask`, `ecs:ExecuteCommand`, `ssm:StartSession` where the resource tag
|
||||
`triple-c:owner` is `${aws:userid}`. Without this, any user could open a shell in anyone's
|
||||
task, so it's the most important rule;
|
||||
- `secretsmanager:CreateSecret`, `PutSecretValue` and `DeleteSecret` with matching request or
|
||||
resource tags, on `triple-c/<env>/*`;
|
||||
- `elasticfilesystem:CreateAccessPoint` and `DeleteAccessPoint` with a matching tag;
|
||||
`kms:Encrypt` and `GenerateDataKey` through Secrets Manager only (`kms:ViaService`);
|
||||
- read-only `Describe*` / `List*`. The app filters results by owner tag for display. Listing is
|
||||
not a secret.
|
||||
|
||||
Wildcard `${aws:userid}` conditions on ARNs and tags behave differently across ECS, EFS and
|
||||
Secrets Manager (`ecs:ResourceTag` vs `aws:ResourceTag`, and support for tag-on-create). **Every
|
||||
statement in `aws/policies/workspace-user.json` needs an integration test against a real account
|
||||
before release.**
|
||||
|
||||
**Data-plane isolation: tenancy modes.** These are set on the environment by the admin.
|
||||
|
||||
| Mode | For | Task role | EC2 options | Isolation |
|
||||
|---|---|---|---|---|
|
||||
| **Strict** (**default, v1**) | Everyone, from a solo account to a team | **Per-user** task role, created by the user's onboarding stack (self-deployed, or by an admin) | Privileged and Docker allowed **only on a per-user capacity provider** (dedicated EC2 instances, never shared with another owner) | Per-user IAM all the way down. EFS file system policy allows each access point only to its owner's task role |
|
||||
| **Standard** (opt-in, later) | Large teams that want fewer IAM roles | Shared, minimal (logs, SSM messages) | **Fargate only**, or EC2 with **no** privileged mode, Docker socket or `NET_ADMIN` | Containers are unprivileged and can't mount NFS, can't read secrets, and can't reach each other over the network |
|
||||
|
||||
There is no separate "personal" mode. A solo user on strict tenancy is simply the only onboarded
|
||||
user, and the account is already safe to share.
|
||||
|
||||
Strict tenancy's per-user `iam:PassRole` is scoped by a tag on the role
|
||||
(`iam:ResourceTag/triple-c:owner = ${aws:userid}`), not by role name, because `aws:userid`
|
||||
contains a `:`, which role names don't allow. **Verify** that `iam:PassRole` evaluates
|
||||
`iam:ResourceTag` as expected before release.
|
||||
|
||||
Why EC2 privileged mode needs dedicated capacity: a privileged container, or one with the Docker
|
||||
socket, has root on its EC2 host, and so access to every other tenant's task on that host
|
||||
(including their injected secrets in `/proc/*/environ`). In standard tenancy the app
|
||||
**doesn't offer** Docker access or VPN for EC2 projects. In strict tenancy it offers them only on
|
||||
the user's dedicated capacity provider.
|
||||
|
||||
**Cost visibility.** All resources carry `triple-c:owner`, `triple-c:project` and
|
||||
`triple-c:environment`. The admin activates them as cost-allocation tags. The app shows an
|
||||
estimated hourly cost per running workspace (Fargate vCPU and GB-hour rates for the region, from
|
||||
the Pricing API, cached).
|
||||
|
||||
**Quotas.** Fargate vCPU and EFS access-point limits are per account and region. When `RunTask`
|
||||
fails because of a quota, the app says so and doesn't retry.
|
||||
|
||||
### ECS on EC2 vs Fargate
|
||||
|
||||
| | Fargate | ECS on EC2 |
|
||||
|---|---|---|
|
||||
| Start time | About 30–90 s (image pull every time; SOCI lazy-loading **to evaluate**) | Fast when the image is cached on a warm instance |
|
||||
| Docker access / privileged / VPN | No | Yes, on dedicated capacity |
|
||||
| GPU | No | Yes |
|
||||
| Ops | None | ASG + capacity provider, defined in the environment stack. The app only picks a capacity provider |
|
||||
| Cost when idle | 0 (task stopped) | Instances keep running unless the ASG scales in |
|
||||
| Max size | 16 vCPU / 120 GB | Instance type |
|
||||
|
||||
ECS on EC2 uses `awsvpc` network mode too, so security groups and the no-inbound rule work the same
|
||||
way.
|
||||
|
||||
### Lifecycle and status
|
||||
|
||||
- `container_watch.rs` listens to Docker `die` events. For AWS, `AwsHost::events()` polls
|
||||
`DescribeTasks` for the owner's running tasks every 15 s while the app is open, and maps
|
||||
`stopCode` / `stoppedReason`:
|
||||
- `EssentialContainerExited` → Stopped (exited on its own);
|
||||
- `SpotInterruption` → Stopped (Spot);
|
||||
- `TaskFailedToStart` → Error, with the reason;
|
||||
- user-initiated → Stopped.
|
||||
|
||||
EventBridge → SQS for push notifications is a later improvement.
|
||||
- **Idle auto-stop** runs **inside** the task: the agent tracks activity (terminal I/O, agent
|
||||
calls, running `claude` processes and scheduler jobs) and stops the task by exiting the
|
||||
entrypoint. It doesn't depend on the app being open. Because a running scheduler job counts as
|
||||
activity, scheduled tasks keep a workspace alive. The admin's maximum lifetime is a hard cap
|
||||
either way.
|
||||
- Logs: `awslogs` driver to the environment's log group, stream `<owner>/<project>/<task>`. The
|
||||
app shows start logs during startup and when a start fails.
|
||||
|
||||
### Feature compatibility (AWS host)
|
||||
|
||||
| Feature | Fargate | EC2 (shared) | EC2 (dedicated) |
|
||||
|---|---|---|---|
|
||||
| Terminals, web terminal, file viewer | Yes (agent) | Yes | Yes |
|
||||
| Auth bridge (OAuth callbacks) | Yes (agent tunnel) | Yes | Yes |
|
||||
| Browser view (Playwright) | Yes (agent tunnel) | Yes | Yes |
|
||||
| Port mappings | Yes, as local forwards through the agent | Yes | Yes |
|
||||
| Speech-to-text | Yes (local STT, audio over the agent) | Yes | Yes |
|
||||
| Scheduler, Mission Control | Yes, and they keep running with the app closed | Yes | Yes |
|
||||
| Marketplace installs | Yes (files copied through the agent) | Yes | Yes |
|
||||
| LLM gateway (LiteLLM) | Sidecar container in the task definition | Sidecar | Sidecar |
|
||||
| Ollama / llama.cpp on the user's machine | **No**, unless the URL is reachable from AWS | No | No |
|
||||
| Claude Code sandbox (bubblewrap) | Weaker nested mode or off (**verify**) | Weaker nested mode | Full |
|
||||
| Docker access | No | No | Yes |
|
||||
| VPN support | No | No | Yes |
|
||||
| Snapshots | Setup script instead | Setup script | Setup script |
|
||||
|
||||
## Fit with existing conventions (CLAUDE.md)
|
||||
|
||||
- **Detecting that a container needs recreating.** Locally, `container_needs_recreation()`
|
||||
compares `triple-c.*` labels. On AWS, the equivalent is the **task definition revision**: the
|
||||
app computes a fingerprint over every setting that shapes the task, stores it as a task
|
||||
definition tag, and registers a new revision when it changes. A running task on an old revision
|
||||
gets the same "restart to apply" signal. The CLAUDE.md rule "never put a secret in a label"
|
||||
applies equally to tags, task definition env vars and `overrides`. Secrets go only through
|
||||
`secrets` ARNs.
|
||||
- **Env vars are visible on EC2.** Injected secrets become container env vars, which anyone with
|
||||
`docker inspect` or `/proc` on the instance can read. This is fine for Fargate and dedicated EC2
|
||||
capacity, and is exactly why standard tenancy bans privileged containers on shared EC2
|
||||
capacity.
|
||||
- **`/home/claude` stays seed-only** in Hybrid and EFS modes: it is persistent storage, the same
|
||||
semantics as the home volume, so the existing "seed at runtime from `/opt` or
|
||||
`/usr/local/bin`" rule carries over unchanged.
|
||||
- **Marketplace sync and Bedrock credential sync** run "after every container start". They move
|
||||
onto `ProjectHost::write_file` / `exec`. Marketplace tokens still never leave the host: sync
|
||||
sends the same pinned tar.
|
||||
- **Settings export/import.** `HostConnection` access keys live in the keychain, so they go into
|
||||
`ExportedSecrets` under the same carve-out as the other keychain secrets. Importing an AWS
|
||||
connection or environment is shown as its own preview warning, alongside the custom-image
|
||||
warning, because it changes **where code runs and who is billed**.
|
||||
- **New IPC commands** (`aws_connect`, `aws_check_permissions`, `aws_list_environments`,
|
||||
`aws_deploy_environment`, …) each need a `generate_handler!` entry and an `allow-*` grant in
|
||||
`capabilities/default.json`. `build.rs` enforces this.
|
||||
- **UI.** The AWS connection goes in `components/settings/` beside the existing AWS panel. The
|
||||
per-project hosting options go in Project Home's Config tab, not a modal. Login and permission
|
||||
check results use the existing `StatusIndicator` (glyph + word).
|
||||
|
||||
## Data model
|
||||
|
||||
```rust
|
||||
// AppSettings
|
||||
pub hosts: Vec<HostConnection>, // Local is implicit and always present
|
||||
|
||||
pub struct HostConnection {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub kind: HostKind, // Aws { auth: AwsAuth, default_region: String }
|
||||
}
|
||||
pub enum AwsAuth { Profile { name: String }, Keys { keyring_ref: String } }
|
||||
|
||||
// Project
|
||||
pub host_id: Option<String>, // None = Local (all existing projects)
|
||||
pub aws_placement: Option<AwsPlacement>, // { region, environment } — set when host_id is an AWS connection
|
||||
pub aws: Option<AwsWorkspaceConfig>,
|
||||
|
||||
pub struct AwsWorkspaceConfig {
|
||||
pub launch_type: LaunchType, // Fargate | Ec2 { capacity_provider }
|
||||
pub cpu: u32, pub memory_mib: u32, pub arch: Arch,
|
||||
pub storage: StorageMode, // Ephemeral | Hybrid | Efs
|
||||
pub ephemeral_gib: u32,
|
||||
pub spot: bool,
|
||||
pub idle_stop_minutes: Option<u32>,
|
||||
pub repos: Vec<RepoSource>, // { url, branch, folder } — parallel to ProjectPath
|
||||
pub secrets_delivery: SecretsDelivery, // SecretsManager | CopyAtConnect
|
||||
pub setup_script: Option<String>,
|
||||
pub wip_push_on_stop: bool,
|
||||
// runtime state (not user-edited)
|
||||
pub task_arn: Option<String>, pub access_point_id: Option<String>,
|
||||
}
|
||||
```
|
||||
|
||||
Existing `projects.json` files deserialize unchanged (`host_id` missing → Local).
|
||||
`Project.container_id` stays Docker-only. AWS uses `aws.task_arn`.
|
||||
|
||||
## Shipped AWS artefacts
|
||||
|
||||
```
|
||||
aws/
|
||||
triple-c-environment.yaml # CloudFormation: cluster, capacity providers, SG, EFS + policy,
|
||||
# KMS key, ECR repo / pull-through rule, log group, exec+task roles,
|
||||
# SSM environment parameter. Params: VPC/subnets (or create),
|
||||
# tenancy mode, limits, Bedrock-on-task-role.
|
||||
triple-c-user-onboard.yaml # strict tenancy: per-user task role + capacity provider
|
||||
policies/
|
||||
environment-admin.json
|
||||
workspace-user.json
|
||||
task-execution-role.json
|
||||
task-role-standard.json
|
||||
```
|
||||
|
||||
## Phases
|
||||
|
||||
1. **Host abstraction refactor.** `ProjectHost` trait, `DockerHost`, every call site moved. No
|
||||
behaviour change, and the existing tests pass.
|
||||
2. **Agent.** `triple-c-agent` in the image. Optionally use it locally too behind a flag, to
|
||||
harden it before AWS depends on it.
|
||||
3. **AWS connection + permission check.** Settings UI, SDK wiring, `GetCallerIdentity`, the tiered
|
||||
simulation and fallback probes, policy files.
|
||||
4. **Environment + onboarding stacks.** CloudFormation templates for both, detection (SSM
|
||||
parameters + tagged stacks, including half-deployed and outdated ones), join or deploy,
|
||||
self-onboarding or an admin request, **strict tenancy**.
|
||||
5. **Fargate workspaces.** Ephemeral and Hybrid storage, Secrets Manager delivery, git bootstrap,
|
||||
start/stop/status, stop guard with WIP push on by default, idle auto-stop, logs.
|
||||
6. **EFS mode, Copy-at-connect, setup scripts, cost estimate.**
|
||||
7. **ECS on EC2.** Per-user capacity providers in the onboarding stack, privileged/Docker/VPN on
|
||||
dedicated capacity.
|
||||
8. **Standard tenancy** (opt-in), only if demand appears.
|
||||
|
||||
## To verify before or during implementation
|
||||
|
||||
- Whether Fargate allows bubblewrap's unprivileged user namespaces, and what Claude Code's sandbox
|
||||
does there.
|
||||
- ABAC condition keys for each action: ECS `RunTask` tag-on-create, `ExecuteCommand`, and
|
||||
`ssm:StartSession` against ECS targets; EFS `CreateAccessPoint` with request tags; Secrets
|
||||
Manager name-prefix plus tag conditions with `${aws:userid}` for SSO sessions.
|
||||
- That `session-manager-plugin` port forwarding to an ECS target is stable for hours-long
|
||||
sessions, and how quickly it reconnects.
|
||||
- Fargate image-pull time for the sandbox image, and whether SOCI indexes help.
|
||||
- That GHCR pull-through cache works for the public image.
|
||||
- That the `SIGTERM` deadline on Fargate retirement and Spot leaves enough time for a WIP push.
|
||||
|
||||
## Connection model
|
||||
|
||||
**Decided: C** (user, 2026-11-13). The comparison below is kept for the record.
|
||||
|
||||
The options:
|
||||
|
||||
- **A. One connection per environment.** Credentials + region + environment, bound together.
|
||||
- **B. One connection per identity.** Credentials only (a profile or keys, like an AWS CLI
|
||||
profile). Each project picks a region and environment.
|
||||
- **C. Hybrid.** A connection is credentials plus a **default** region. Environments are detected
|
||||
per region when needed, and each project records `(connection, region, environment)`.
|
||||
|
||||
| | A. Per environment | B. Per identity | C. Hybrid |
|
||||
|---|---|---|---|
|
||||
| Mental model | Simplest: "my AWS dev box" | Matches the AWS CLI (a profile is an identity; region is a parameter) | Matches the AWS CLI, with a sensible default |
|
||||
| Setup for a solo user, one region | One step | One step, plus picking a region for each project | One step |
|
||||
| Several regions or environments, same login | Duplicate connections with the same credentials. Keys are stored twice; SSO shares a token cache, so it's only clutter | One connection | One connection |
|
||||
| Permission check | Once per connection, cached | Per environment, run lazily the first time a project uses it | Per environment, run lazily |
|
||||
| Detection cost | One region | Every region the user might use (`DescribeRegions` + a scan, slow) or a hand-kept list | Default region straight away; other regions only when the user picks one |
|
||||
| Risk of deploying to the wrong place | Low: the place is in the connection's name | Higher: an easy region-picker mistake leaves a stack in a region nobody looks at | Low: default region preselected, others must be chosen deliberately |
|
||||
| Moving a project to another region or environment | Re-point the project to another connection | Change a field | Change a field |
|
||||
| Settings export/import | Each connection is self-contained | Projects reference environments that may not exist on the importing machine | Same as B |
|
||||
| Data model / UI | Smallest | Largest (environment picker and per-environment status everywhere) | Medium |
|
||||
| Multiple AWS **accounts** | One connection each | One connection each (credentials define the account) | One connection each |
|
||||
|
||||
**Why C:**
|
||||
|
||||
- It costs the solo user, one-region case nothing, because the default region carries it.
|
||||
- It avoids storing the same keys repeatedly the moment someone uses a second region.
|
||||
- It keeps detection cheap and deploying deliberate.
|
||||
|
||||
A is fine as a v1 shortcut if we want the smallest UI. Moving from A to C later is mechanical:
|
||||
connections that share credentials get merged.
|
||||
|
||||
Consequences for implementation:
|
||||
|
||||
- Permission check results are cached per `(connection, region, environment)` and re-run if a
|
||||
call is denied.
|
||||
- The project's Config tab shows the region and environment with the default region preselected.
|
||||
Choosing another region triggers detection there, and offers join or deploy as in step 2.
|
||||
- Settings export carries connections. Projects aren't exported (unchanged scope), so a dangling
|
||||
`aws_placement` can't arrive through an import.
|
||||
|
||||
## Remaining open questions
|
||||
|
||||
None. Everything in "To verify" is an implementation-time check, not a product decision.
|
||||
@@ -28,19 +28,26 @@
|
||||
# to keep it that way: a floating tag re-resolves, and this container ends up
|
||||
# holding an OpenAI API key.
|
||||
#
|
||||
# v1.96.0 is chosen rather than the nearest clean post-incident release (1.83.0)
|
||||
# v1.106.6 is chosen rather than the nearest clean post-incident release (1.83.0)
|
||||
# because the intervening releases fix a run of *ordinary* proxy CVEs that matter
|
||||
# for a gateway published on a host port: SQL injection in API-key verification
|
||||
# (CVE-2026-42208, fixed 1.83.7), auth bypass via Host header injection
|
||||
# (CVE-2026-49468, fixed 1.84.0) and MCP auth bypass (CVE-2026-59822, fixed
|
||||
# 1.84.0). 1.84.0 is the floor; v1.96.0 is the newest release with no open OSV
|
||||
# advisories at the time of writing. Note LiteLLM has retired the `main-*` tag
|
||||
# family (`main-latest` is no longer updated, `main-stable` is deprecated) in
|
||||
# favour of plain semver tags, which is why this is `v1.96.0` and not
|
||||
# `main-v1.96.0-stable`.
|
||||
# (CVE-2026-49468, fixed 1.84.0), MCP auth bypass (CVE-2026-59822, fixed
|
||||
# 1.84.0), authenticated SSRF and provider-credential exfiltration via
|
||||
# unvalidated request-body URLs (CVE-2026-84377 / GHSA-3cv6-jpf6-8222, fixed
|
||||
# 1.96.2 on the line this file previously pinned) and privilege escalation to
|
||||
# proxy admin via cross-domain reuse of the salt key (CVE-2026-114612 /
|
||||
# GHSA-7hp6-4w63-5g45, fixed 1.103.1; no 1.96.x fix). The SSRF one is the
|
||||
# sharpest for Triple-C: this container holds a billed provider key. v1.106.6 is
|
||||
# the newest stable release with no open OSV advisories at the time of writing
|
||||
# (2026-11-02), and its digest was checked with
|
||||
# `cosign verify --key https://raw.githubusercontent.com/BerriAI/litellm/e82a3c646908ba0a6ee0beddc4310d7a6cac4065/cosign.pub`.
|
||||
# Note LiteLLM has retired the `main-*` tag family (`main-latest` is no longer
|
||||
# updated, `main-stable` is deprecated) in favour of plain semver tags, which is
|
||||
# why this is `v1.106.6` and not `main-v1.106.6-stable`.
|
||||
#
|
||||
# Bump deliberately, never automatically, and re-check the digest when you do.
|
||||
FROM ghcr.io/berriai/litellm:v1.96.0@sha256:90d8de0ea6fbb3cad145d1019d00a0149ae400b1e18e2011a60f1988f143f672
|
||||
FROM ghcr.io/berriai/litellm:v1.106.6@sha256:37e2dafb088a7723beb998590682957386dfc83ffea1848486f8e0e5cfac0b06
|
||||
|
||||
# A placeholder config so the image is runnable on its own. Triple-C overwrites
|
||||
# /etc/litellm/config.yaml (a named volume) with the generated one before the
|
||||
|
||||
@@ -55,6 +55,28 @@
|
||||
# lands last: a fallback, never an override.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# 1b. The bundled libpipewire (microphone capture)
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
# `bundleMediaFramework` puts the GStreamer plugins in the image, including
|
||||
# libgstpipewire.so, which is what WebKitGTK uses to reach the microphone. That
|
||||
# plugin pulls libpipewire-0.3.so.0 in with it, and the copy linuxdeploy
|
||||
# bundles has the *builder's* plugin directories compiled in
|
||||
# (/usr/lib/x86_64-linux-gnu/spa-0.2 and .../pipewire-0.3). Neither is bundled
|
||||
# and neither exists on, say, Arch (/usr/lib/spa-0.2), so on the host:
|
||||
#
|
||||
# [E] pw.loop [loop.c:67 pw_loop_new()] can't make support.system handle:
|
||||
# No such file or directory
|
||||
#
|
||||
# and the microphone never opens. libpipewire is host-coupled in the same way
|
||||
# libwayland-client is: it has to agree with the SPA plugins, client config and
|
||||
# daemon actually running. Its client ABI is backward compatible, so the
|
||||
# bundled libgstpipewire.so links happily against a newer host copy. Unlike the
|
||||
# wayland case there is no useful fallback — without a host PipeWire daemon
|
||||
# there is nothing to capture from — so the bundled copy is simply removed;
|
||||
# GStreamer skips a plugin whose library will not load and the app still starts.
|
||||
#
|
||||
# ---------------------------------------------------------------------------
|
||||
# 2. Metadata an AppImage manager needs
|
||||
# ---------------------------------------------------------------------------
|
||||
#
|
||||
@@ -202,6 +224,17 @@ else
|
||||
echo "$LIB is not bundled — nothing to demote."
|
||||
fi
|
||||
|
||||
# Independent of the wayland demotion above, and equally unconditional on it.
|
||||
PW_LIB="libpipewire-0.3.so.0"
|
||||
pw_removed=false
|
||||
if [ -e "$root/usr/lib/$PW_LIB" ]; then
|
||||
rm -f "$root/usr/lib/$PW_LIB"
|
||||
pw_removed=true
|
||||
echo "Removed bundled $PW_LIB (the host's is used)."
|
||||
else
|
||||
echo "$PW_LIB is not bundled — nothing to remove."
|
||||
fi
|
||||
|
||||
# --- metadata -------------------------------------------------------------
|
||||
|
||||
# Version comes from the artifact rather than a second source that could drift.
|
||||
@@ -235,6 +268,16 @@ done
|
||||
|
||||
echo "Repacking."
|
||||
|
||||
# The AppDir must be readable (and its directories and programs executable) by
|
||||
# everyone: the image is mounted with these modes and run by users other than
|
||||
# the packager. `--appimage-extract` writes it under the runner's umask, which
|
||||
# on some runners is 007 — the extracted root came out 0700 and AppRun.wrapped
|
||||
# 0770, and we used to repack exactly that. appimagetool's continuous build of
|
||||
# 2026-11-01 started refusing such an AppDir, which is right, so fix the modes
|
||||
# rather than pass --no-check-permissions. `a+rX` adds x only where some x bit
|
||||
# is already set (directories and programs), and grants no write to anyone.
|
||||
chmod -R a+rX "$root"
|
||||
|
||||
tool="$work/appimagetool"
|
||||
curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL"
|
||||
chmod +x "$tool"
|
||||
@@ -269,8 +312,20 @@ if [ "$demoted" = true ]; then
|
||||
[ -e "$out/$HOOK" ] || fail "the fallback hook is missing."
|
||||
grep -q "triple-c-wayland-fallback" "$out/AppRun" || fail "AppRun does not source the hook."
|
||||
fi
|
||||
if [ "$pw_removed" = true ]; then
|
||||
[ -e "$out/usr/lib/$PW_LIB" ] && fail "$PW_LIB is still bundled."
|
||||
fi
|
||||
[ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c."
|
||||
|
||||
# Every file readable by all, every program executable by all — what the
|
||||
# `chmod -R a+rX` before the repack promised (AppRun.wrapped used to ship 0770).
|
||||
# Files only: the AppImage runtime's --appimage-extract creates every
|
||||
# *directory* 0700 whatever the image stores (verified by packing a 0755
|
||||
# AppDir and extracting it under umask 022), so directory modes cannot be
|
||||
# checked from an extraction. appimagetool's own check covers them pre-pack.
|
||||
unreadable="$(find "$out" -type f \( ! -perm -a+r -o \( -perm /111 ! -perm -a+x \) \) -print -quit)"
|
||||
[ -z "$unreadable" ] || fail "${unreadable#"$out"/} is not readable/executable by all users."
|
||||
|
||||
# An empty Categories or missing metadata ships an image a manager cannot file
|
||||
# or describe, and both fail silently at runtime rather than at build time.
|
||||
# Asserted positively, over every entry: the earlier form checked only that no
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
# The python:3.11 images freeze the pip and setuptools that shipped with that
|
||||
# CPython branch (pip 24.0, setuptools 79), and both have since collected
|
||||
# advisories: pip path traversal / tar-symlink / index-URL handling
|
||||
# (CVE-2025-8869, CVE-2026-1703, CVE-2026-3219, CVE-2026-6357, CVE-2026-8643,
|
||||
# CVE-2026-13346) and setuptools sdist/legacy-wheel path issues
|
||||
# (CVE-2026-59890, GHSA-xmj3-9gf7-h52w). Upgrade them first, so the installs
|
||||
# below also run under a patched pip.
|
||||
RUN pip install --no-cache-dir --upgrade "pip>=26.2" "setuptools>=84.1" \
|
||||
&& pip install --no-cache-dir \
|
||||
faster-whisper \
|
||||
fastapi \
|
||||
uvicorn[standard] \
|
||||
|
||||
Reference in new issue
Block a user