Compare commits
33
Commits
v0.4.35
...
v0.4.42-win
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
c2aa9c3be2 | ||
|
|
36a6c869c9 | ||
|
|
114f463bf5 | ||
|
|
2a46f34fca | ||
|
|
fe889a7f16 | ||
|
|
fffc6492f1 | ||
|
|
0f638c7c4e | ||
|
|
a068ce27bc | ||
|
|
5d03bea544 | ||
|
|
d8c5b501f3 | ||
|
|
ebaafd8cfc | ||
|
|
55ed2d17d0 | ||
|
|
48c60ef89c | ||
|
|
20a25fc518 | ||
|
|
086abd5713 | ||
|
|
7c004fed46 | ||
|
|
45e55b5f7c | ||
|
|
83d63e756d | ||
|
|
cc973c8291 | ||
|
|
3ec100e06c | ||
|
|
a19643e0e8 | ||
|
|
32a4d345f2 | ||
|
|
229dc2cefc | ||
|
|
41010a9c50 | ||
|
|
737a608dd5 | ||
|
|
209b8855a4 | ||
|
|
3a4e298849 | ||
|
|
1118cfa458 | ||
|
|
d4a14d2ded | ||
|
|
4532412869 | ||
|
|
e5b73d8fa5 | ||
|
|
1fc1f44c35 | ||
|
|
e223a3f64b |
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
|
||||
@@ -77,6 +84,9 @@ on:
|
||||
- "app/**"
|
||||
- "VERSION"
|
||||
- ".gitea/workflows/build-app-preview.yml"
|
||||
# The app embeds the agent's sources for its local image build, and the
|
||||
# agent's own tests run in the `test` job below.
|
||||
- "container/triple-c-agent/**"
|
||||
workflow_dispatch:
|
||||
|
||||
jobs:
|
||||
@@ -262,7 +272,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 +283,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 \
|
||||
@@ -321,6 +335,16 @@ jobs:
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo test --locked
|
||||
|
||||
# The in-container agent is its own crate (container/triple-c-agent): a
|
||||
# real server on loopback, exercised over HTTP/WebSocket as the current
|
||||
# user. Its root-only test is #[ignore]d. The Docker-parity test against
|
||||
# a real container lives in the app (host::agent::live_tests, ignored).
|
||||
- name: Agent tests (cargo test)
|
||||
working-directory: ./container/triple-c-agent
|
||||
run: |
|
||||
export PATH="$HOME/.cargo/bin:$PATH"
|
||||
cargo test --locked
|
||||
|
||||
build-linux:
|
||||
runs-on: ubuntu-latest
|
||||
needs: [compute-version, create-release]
|
||||
@@ -344,7 +368,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 +391,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 +683,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 }}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
node_modules/
|
||||
app/dist/
|
||||
app/src-tauri/target/
|
||||
container/triple-c-agent/target/
|
||||
# Written by build.rs (tauri-build AppManifest); gen/schemas/acl-manifests.json is the
|
||||
# tracked, reviewable form of the same information.
|
||||
app/src-tauri/permissions/autogenerated/
|
||||
|
||||
@@ -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,23 @@ 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`
|
||||
(`Docker`, and `Agent` — reached through the in-container `triple-c-agent` by `agent.rs`, per
|
||||
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`; AWS planned —
|
||||
`docs/superpowers/specs/2026-11-13-aws-ecs-host-design.md`)
|
||||
wraps execs, file transfer and `is_running`; `sessions.rs` holds `ExecSessionManager`.
|
||||
`agent.rs` reuses `docker/exec.rs`'s `OneshotOutput`, host-file reader and uid probe so the two
|
||||
variants cannot drift; an `Agent` target's token never appears in `Debug` or error text.
|
||||
**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. `lifecycle.rs`'s `ProjectHost` owns create/start/stop/find/remove and
|
||||
discarding a project's persistent state; the project commands keep only host-neutral steps
|
||||
(validation, status, sessions, auth bridge, syncs). `watch.rs` gives `container_watch` its
|
||||
death reports and positive-exit check. `no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer`
|
||||
fails on lifecycle calls or `ContainerTarget::docker(id)` in non-test code outside `host/` and
|
||||
`docker/`; get a target from `ContainerTarget::for_project`, `ProjectHost`, or a session
|
||||
(`ExecSessionManager::get_target`). Base-image migration is the one Docker-only exception
|
||||
(`DOCKER_ONLY`); shrink it, never grow it.
|
||||
- **`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,8 +330,27 @@ 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
|
||||
- **`triple-c-agent/`** — the in-container agent (its own Rust crate; protocol in
|
||||
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`). Built static by the Dockerfile's
|
||||
`agent-build` stage, started by `entrypoint.sh` **only when a token is configured**, last, as root
|
||||
under a supervisor loop. **Its contract is Docker parity**: an `ExecSpec` must behave through the
|
||||
agent exactly as through `docker exec`. Any change to exec semantics on either side is checked by
|
||||
`host::agent::live_tests::the_agent_answers_exactly_as_docker_exec_does` (ignored; needs an image
|
||||
with the agent — see the crate README). The token never stays in the environment and never touches the
|
||||
writable layer: the entrypoint moves it to a root-only file on `/dev/shm` (tmpfs — a file in the
|
||||
writable layer would ride `docker commit` into the snapshot image) before anything runs, and the
|
||||
agent refuses a token file others can read. The agent is started from an env/cwd snapshot taken
|
||||
at the top of the entrypoint and held **in memory only** (it contains every API key in the env),
|
||||
so execs see the container's configured env, not the script's. The app treats the agent as
|
||||
untrusted: no redirects, capped bodies, timeouts on every small call (`host/agent.rs`).
|
||||
|
||||
**`/home/claude` in the image is seed-only.** It is the mount point of the named volume
|
||||
`triple-c-home-{projectId}`, so after a project's *first* start the image's copy of that directory
|
||||
@@ -568,6 +615,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 +726,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
+118
-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",
|
||||
@@ -6583,9 +6642,12 @@ dependencies = [
|
||||
"tauri-plugin-opener",
|
||||
"tempfile",
|
||||
"tokio",
|
||||
"tokio-tungstenite",
|
||||
"tokio-util",
|
||||
"tower-http",
|
||||
"url",
|
||||
"uuid",
|
||||
"webkit2gtk",
|
||||
"zeroize",
|
||||
]
|
||||
|
||||
@@ -6606,7 +6668,7 @@ dependencies = [
|
||||
"http",
|
||||
"httparse",
|
||||
"log",
|
||||
"rand 0.9.2",
|
||||
"rand 0.9.5",
|
||||
"sha1",
|
||||
"thiserror 2.0.18",
|
||||
"utf-8",
|
||||
@@ -7045,18 +7107,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 +7181,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"
|
||||
@@ -32,6 +33,13 @@ reqwest = { version = "0.12", default-features = false, features = ["json", "rus
|
||||
iana-time-zone = "0.1"
|
||||
sha2 = "0.10"
|
||||
axum = { version = "0.8", features = ["ws"] }
|
||||
# Client side of triple-c-agent's two WebSocket endpoints (host/agent.rs). The
|
||||
# same version axum's `ws` already pulls in; plain ws:// only (the transport is
|
||||
# loopback or an SSM port-forward), so no TLS features.
|
||||
tokio-tungstenite = { version = "0.28", default-features = false, features = ["connect"] }
|
||||
# `PollSender`: the agent attach's stdin `AsyncWrite` over a bounded channel.
|
||||
# Already in the tree through tokio's ecosystem.
|
||||
tokio-util = "0.7"
|
||||
tower-http = { version = "0.6", features = ["cors"] }
|
||||
base64 = "0.22"
|
||||
rand = "0.9"
|
||||
@@ -48,6 +56,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.
|
||||
|
||||
@@ -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;
|
||||
@@ -178,7 +177,7 @@ impl AuthBridgeManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) {
|
||||
@@ -200,12 +199,12 @@ impl AuthBridgeManager {
|
||||
log::info!(
|
||||
"Auth bridge: starting for project {} (container {})",
|
||||
project_id,
|
||||
&container_id[..container_id.len().min(12)]
|
||||
&container.id()[..container.id().len().min(12)]
|
||||
);
|
||||
|
||||
let poller = tokio::spawn(poll_loop(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
container,
|
||||
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_connection_with_prelude(container, target, stream, port, head.into_buffer()).await
|
||||
}
|
||||
|
||||
/// What the first bytes of an accepted connection turned out to be.
|
||||
@@ -512,7 +515,7 @@ pub(crate) fn web_request_verdict(head: &str) -> Verdict {
|
||||
/// back on the wire. Passing them here keeps the byte stream exact, rather than
|
||||
/// re-serialising a parsed request.
|
||||
pub async fn tunnel_connection_with_prelude(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
stream: TcpStream,
|
||||
port: u16,
|
||||
@@ -524,7 +527,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 +590,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,
|
||||
|
||||
@@ -6,6 +6,7 @@ use tauri::{AppHandle, State};
|
||||
|
||||
use crate::browser_view::install::{self, BrowserSetupOutcome};
|
||||
use crate::browser_view::{manager, page, popout, BrowserViewState, BrowserViewStatus};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the pane on or off for a project.
|
||||
@@ -57,12 +58,12 @@ pub async fn set_browser_view_enabled(
|
||||
return Ok(manager().status(&project_id, false).await);
|
||||
}
|
||||
|
||||
let container_id = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
let container = running_container(&state, &project_id, "opening the browser view").await?;
|
||||
|
||||
manager()
|
||||
.start(
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -106,8 +107,8 @@ pub async fn check_browser_view_support(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<crate::browser_view::detect::PlaywrightDetection, String> {
|
||||
let container_id = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||
crate::browser_view::detect::detect(&container_id).await
|
||||
let container = running_container(&state, &project_id, "checking for Playwright").await?;
|
||||
crate::browser_view::detect::detect(&container).await
|
||||
}
|
||||
|
||||
/// Install `playwright` and `@playwright/cli` into the container.
|
||||
@@ -125,8 +126,8 @@ pub async fn install_browser_view_support(
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let container_id = running_container(&state, &project_id, "installing Playwright").await?;
|
||||
install::install_packages(&app_handle, &project_id, &container_id).await
|
||||
let container = running_container(&state, &project_id, "installing Playwright").await?;
|
||||
install::install_packages(&app_handle, &project_id, &container).await
|
||||
}
|
||||
|
||||
/// Install a browser — `chromium` (Playwright's own build, for scripts that
|
||||
@@ -143,8 +144,8 @@ pub async fn install_browser_view_browser(
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let target = install::BrowserTarget::parse(&browser)?;
|
||||
let container_id = running_container(&state, &project_id, "installing a browser").await?;
|
||||
install::install_browser(&app_handle, &project_id, &container_id, target).await
|
||||
let container = running_container(&state, &project_id, "installing a browser").await?;
|
||||
install::install_browser(&app_handle, &project_id, &container, target).await
|
||||
}
|
||||
|
||||
/// Detach the view into a window of its own, or raise the one already open.
|
||||
@@ -240,17 +241,17 @@ pub async fn open_page_in_container_browser(
|
||||
if !(trimmed.starts_with("http://") || trimmed.starts_with("https://")) {
|
||||
return Err("Only http:// and https:// URLs can be opened in the browser.".to_string());
|
||||
}
|
||||
let container_id = running_container(&state, &project_id, "opening a page").await?;
|
||||
let container = running_container(&state, &project_id, "opening a page").await?;
|
||||
crate::commands::project_commands::emit_progress(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
"Checking the container for Playwright…",
|
||||
);
|
||||
let detection = crate::browser_view::detect::detect(&container_id).await?;
|
||||
let detection = crate::browser_view::detect::detect(&container).await?;
|
||||
let opened = page::open(
|
||||
&app_handle,
|
||||
&project_id,
|
||||
&container_id,
|
||||
&container,
|
||||
&detection,
|
||||
trimmed,
|
||||
page::Viewport::sane(width, height),
|
||||
@@ -273,7 +274,7 @@ pub async fn open_page_in_container_browser(
|
||||
manager()
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
container,
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -309,8 +310,8 @@ pub async fn set_container_page_viewport(
|
||||
height: u32,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = running_container(&state, &project_id, "resizing the page").await?;
|
||||
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||
let container = running_container(&state, &project_id, "resizing the page").await?;
|
||||
page::set_viewport(&container, page::Viewport::sane(width, height)).await
|
||||
}
|
||||
|
||||
/// State of the page this opened, if any. Never fails: "no page" is an answer.
|
||||
@@ -319,10 +320,10 @@ pub async fn get_container_page_state(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<page::PageState, String> {
|
||||
let Ok(container_id) = running_container(&state, &project_id, "reading the page").await else {
|
||||
let Ok(container) = running_container(&state, &project_id, "reading the page").await else {
|
||||
return Ok(page::PageState::default());
|
||||
};
|
||||
Ok(page::state(&container_id).await)
|
||||
Ok(page::state(&container).await)
|
||||
}
|
||||
|
||||
/// Close the page this opened, leaving the view itself running.
|
||||
@@ -331,8 +332,8 @@ pub async fn close_container_page(
|
||||
project_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
let container_id = running_container(&state, &project_id, "closing the page").await?;
|
||||
page::close(&container_id).await;
|
||||
let container = running_container(&state, &project_id, "closing the page").await?;
|
||||
page::close(&container).await;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -356,8 +357,8 @@ pub async fn set_browser_view_match_window(
|
||||
let Some((width, height)) = popout::inner_size(&app_handle, &project_id) else {
|
||||
return Ok(());
|
||||
};
|
||||
let container_id = running_container(&state, &project_id, "matching the window").await?;
|
||||
page::set_viewport(&container_id, page::Viewport::sane(width, height)).await
|
||||
let container = running_container(&state, &project_id, "matching the window").await?;
|
||||
page::set_viewport(&container, page::Viewport::sane(width, height)).await
|
||||
}
|
||||
|
||||
/// Whether match-window mode is on. Read on mount, like the rest of the
|
||||
@@ -391,28 +392,25 @@ async fn running_container(
|
||||
state: &State<'_, AppState>,
|
||||
project_id: &str,
|
||||
action: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let Some(container_id) = project.container_id.clone() else {
|
||||
let Some(container) = ContainerTarget::for_project(&project) else {
|
||||
return Err(format!(
|
||||
"This project has no container yet. Start it before {}.",
|
||||
action
|
||||
));
|
||||
};
|
||||
if !crate::docker::container::is_container_running(&container_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if !container.is_running().await.unwrap_or(false) {
|
||||
return Err(format!(
|
||||
"The container for “{}” isn't running. Start it before {}.",
|
||||
project.name, action
|
||||
));
|
||||
}
|
||||
Ok(container_id)
|
||||
Ok(container)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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.
|
||||
@@ -277,12 +277,14 @@ impl PlaywrightDetection {
|
||||
/// Playwright installed with `npm i -g`, or merely *run* once through
|
||||
/// `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?;
|
||||
pub async fn detect(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};
|
||||
|
||||
@@ -240,7 +238,7 @@ pub struct BrowserSetupOutcome {
|
||||
pub async fn install_packages(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
emit_progress(
|
||||
app,
|
||||
@@ -248,7 +246,7 @@ pub async fn install_packages(
|
||||
&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 +263,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 +277,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(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 +329,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 +346,7 @@ async fn npm_install(
|
||||
run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
"claude",
|
||||
INSTALL_DIR,
|
||||
cmd,
|
||||
@@ -363,20 +361,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
|
||||
@@ -411,10 +409,10 @@ fn merge_logs(first: String, second: String) -> String {
|
||||
pub async fn install_browser(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
target: BrowserTarget,
|
||||
) -> Result<BrowserSetupOutcome, String> {
|
||||
let before = detect::detect(container_id).await?;
|
||||
let before = detect::detect(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 +456,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 +480,7 @@ pub async fn install_browser(
|
||||
let deps = run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
"root",
|
||||
"/tmp",
|
||||
vec![
|
||||
@@ -530,7 +528,7 @@ pub async fn install_browser(
|
||||
let dl = run_step(
|
||||
app,
|
||||
project_id,
|
||||
container_id,
|
||||
container,
|
||||
user,
|
||||
workdir,
|
||||
vec![
|
||||
@@ -559,7 +557,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 +588,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(container).await?;
|
||||
|
||||
Ok(BrowserSetupOutcome {
|
||||
detection,
|
||||
@@ -642,9 +640,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 +709,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 +725,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 +884,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 +900,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 +951,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;
|
||||
@@ -282,7 +281,7 @@ impl BrowserViewManager {
|
||||
pub async fn start(
|
||||
&self,
|
||||
project_id: String,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
app: AppHandle,
|
||||
store: Arc<ProjectsStore>,
|
||||
) -> Result<BrowserViewStatus, String> {
|
||||
@@ -300,7 +299,7 @@ impl BrowserViewManager {
|
||||
return Ok(self.status(&project_id, true).await);
|
||||
}
|
||||
|
||||
let detection = detect::detect(&container_id).await?;
|
||||
let detection = detect::detect(&container).await?;
|
||||
if !detection.is_usable() {
|
||||
let blocker = detection.blocker().unwrap_or_else(|| {
|
||||
"Playwright is present but incomplete in this container.".to_string()
|
||||
@@ -320,15 +319,15 @@ 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
|
||||
// no need to go back to /proc/net to work it out.
|
||||
let proxy = match BrowserViewProxy::bind(
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
container_port,
|
||||
PortFamily::V4,
|
||||
token.clone(),
|
||||
@@ -337,7 +336,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 +353,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 +426,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 +442,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 +458,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 +476,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 +537,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 +552,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 +568,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 +603,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 +647,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 +671,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 +696,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 +708,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 +725,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 +754,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 +769,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;
|
||||
|
||||
@@ -91,7 +91,7 @@ pub struct PageState {
|
||||
pub async fn open(
|
||||
app: &AppHandle,
|
||||
project_id: &str,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
detection: &PlaywrightDetection,
|
||||
url: &str,
|
||||
viewport: Viewport,
|
||||
@@ -115,17 +115,17 @@ pub async fn open(
|
||||
// Reuse a helper that is already up. Relaunching would throw away the
|
||||
// browser's cookies and storage — which for the auth case means signing in
|
||||
// again to reach the second page, having just signed in on the first.
|
||||
if state(container_id).await.ready {
|
||||
if state(container).await.ready {
|
||||
emit_progress(app, project_id, "Navigating the container's browser…");
|
||||
set_viewport(container_id, viewport).await?;
|
||||
navigate(container_id, url).await?;
|
||||
if let Some(state) = wait_for_url(container_id, url).await {
|
||||
set_viewport(container, viewport).await?;
|
||||
navigate(container, url).await?;
|
||||
if let Some(state) = wait_for_url(container, url).await {
|
||||
return Ok(state);
|
||||
}
|
||||
// It stopped answering; fall through and start a fresh one.
|
||||
}
|
||||
|
||||
close(container_id).await;
|
||||
close(container).await;
|
||||
// Cold start: a browser launch plus a page load, which is the several
|
||||
// seconds the user would otherwise spend wondering whether the click
|
||||
// registered.
|
||||
@@ -150,52 +150,52 @@ 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))?;
|
||||
container
|
||||
.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
|
||||
wait_until_ready(container).await
|
||||
}
|
||||
|
||||
/// Resize the open page. Cheap enough to call from a window-resize handler.
|
||||
pub async fn set_viewport(container_id: &str, viewport: Viewport) -> Result<(), String> {
|
||||
pub async fn set_viewport(container: &ContainerTarget, viewport: Viewport) -> Result<(), String> {
|
||||
write_control(
|
||||
container_id,
|
||||
container,
|
||||
serde_json::json!({ "viewport": viewport }).to_string(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Navigate the open page without relaunching the browser.
|
||||
pub async fn navigate(container_id: &str, url: &str) -> Result<(), String> {
|
||||
write_control(container_id, serde_json::json!({ "url": url }).to_string()).await
|
||||
pub async fn navigate(container: &ContainerTarget, url: &str) -> Result<(), String> {
|
||||
write_control(container, serde_json::json!({ "url": url }).to_string()).await
|
||||
}
|
||||
|
||||
/// Ask the helper to shut down. Best effort: a container that has none is the
|
||||
/// normal case, and the caller is usually about to start one anyway.
|
||||
pub async fn close(container_id: &str) {
|
||||
let _ = write_control(container_id, serde_json::json!({ "close": true }).to_string()).await;
|
||||
pub async fn close(container: &ContainerTarget) {
|
||||
let _ = write_control(container, serde_json::json!({ "close": true }).to_string()).await;
|
||||
}
|
||||
|
||||
/// Current state, or a default when no helper has ever run here.
|
||||
pub async fn state(container_id: &str) -> PageState {
|
||||
pub async fn state(container: &ContainerTarget) -> PageState {
|
||||
let script = format!(
|
||||
"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, _)) = container
|
||||
.exec_oneshot_as(
|
||||
"claude",
|
||||
vec!["node".to_string(), "-e".to_string(), script],
|
||||
Vec::new(),
|
||||
)
|
||||
.await
|
||||
else {
|
||||
return PageState::default();
|
||||
};
|
||||
@@ -204,20 +204,20 @@ pub async fn state(container_id: &str) -> PageState {
|
||||
|
||||
/// Write the control file through Node rather than a shell redirect, so a URL
|
||||
/// is never interpreted by `sh`.
|
||||
async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||
async fn write_control(container: &ContainerTarget, json: String) -> Result<(), String> {
|
||||
let script = format!(
|
||||
"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))
|
||||
container
|
||||
.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.
|
||||
@@ -225,10 +225,10 @@ async fn write_control(container_id: &str, json: String) -> Result<(), String> {
|
||||
/// Bounded much tighter than a cold start: the browser is already up, so this
|
||||
/// is one navigation. `None` means it stopped answering, and the caller starts
|
||||
/// a fresh helper rather than reporting a page that isn't there.
|
||||
async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||
async fn wait_for_url(container: &ContainerTarget, url: &str) -> Option<PageState> {
|
||||
let deadline = std::time::Instant::now() + REUSE_TIMEOUT;
|
||||
loop {
|
||||
let state = state(container_id).await;
|
||||
let state = state(container).await;
|
||||
if state.ready && state.url.as_deref() == Some(url) {
|
||||
return Some(state);
|
||||
}
|
||||
@@ -240,10 +240,10 @@ async fn wait_for_url(container_id: &str, url: &str) -> Option<PageState> {
|
||||
}
|
||||
|
||||
/// Poll the state file until the helper says the page is up, or says why not.
|
||||
async fn wait_until_ready(container_id: &str) -> Result<PageState, String> {
|
||||
async fn wait_until_ready(container: &ContainerTarget) -> Result<PageState, String> {
|
||||
let deadline = std::time::Instant::now() + READY_TIMEOUT;
|
||||
loop {
|
||||
let state = state(container_id).await;
|
||||
let state = state(container).await;
|
||||
if let Some(error) = state.error.clone() {
|
||||
return Err(error);
|
||||
}
|
||||
|
||||
@@ -255,15 +255,15 @@ fn on_resized(app: &AppHandle, project_id: &str, width: u32, height: u32) {
|
||||
}
|
||||
|
||||
let state = app.state::<crate::AppState>();
|
||||
let Some(container_id) = state
|
||||
let Some(container) = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.and_then(|p| p.container_id)
|
||||
.and_then(|p| crate::host::ContainerTarget::for_project(&p))
|
||||
else {
|
||||
return;
|
||||
};
|
||||
if let Err(e) = super::page::set_viewport(
|
||||
&container_id,
|
||||
&container,
|
||||
super::page::Viewport::sane(width, height),
|
||||
)
|
||||
.await
|
||||
|
||||
@@ -62,6 +62,7 @@ use tokio::task::{JoinHandle, JoinSet};
|
||||
|
||||
use crate::auth_bridge::proc_net::PortFamily;
|
||||
use crate::auth_bridge::tunnel::tunnel_connection_with_prelude;
|
||||
use crate::host::ContainerTarget;
|
||||
|
||||
/// Host loopback ports the pane may be served on, and therefore the exact set of
|
||||
/// origins enumerated in the app's `frame-src`. Keep the two in sync: adding a
|
||||
@@ -103,9 +104,9 @@ impl Drop for BrowserViewProxy {
|
||||
|
||||
impl BrowserViewProxy {
|
||||
/// Take the first free port in [`PROXY_PORTS`] on the host loopback and
|
||||
/// start gating connections into `container_id`'s `container_port`.
|
||||
/// start gating connections into `container`'s `container_port`.
|
||||
pub async fn bind(
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
container_port: u16,
|
||||
family: PortFamily,
|
||||
token: String,
|
||||
@@ -121,7 +122,7 @@ impl BrowserViewProxy {
|
||||
Ok(listener) => {
|
||||
let task = tokio::spawn(accept_loop(
|
||||
listener,
|
||||
container_id,
|
||||
container,
|
||||
family.socat_target(container_port),
|
||||
container_port,
|
||||
token,
|
||||
@@ -172,7 +173,7 @@ fn host_authorities(port: u16) -> Vec<String> {
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn accept_loop(
|
||||
listener: TcpListener,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
container_port: u16,
|
||||
token: String,
|
||||
@@ -195,7 +196,7 @@ async fn accept_loop(
|
||||
let _ = stream.set_nodelay(true);
|
||||
conns.spawn(serve_connection(
|
||||
stream,
|
||||
container_id.clone(),
|
||||
container.clone(),
|
||||
target.clone(),
|
||||
container_port,
|
||||
token.clone(),
|
||||
@@ -214,7 +215,7 @@ async fn accept_loop(
|
||||
#[allow(clippy::too_many_arguments)]
|
||||
async fn serve_connection(
|
||||
mut stream: TcpStream,
|
||||
container_id: String,
|
||||
container: ContainerTarget,
|
||||
target: String,
|
||||
container_port: u16,
|
||||
token: String,
|
||||
@@ -249,7 +250,7 @@ async fn serve_connection(
|
||||
|
||||
// Authorized: hand the socket to the same socat-over-Docker-exec tunnel the
|
||||
// auth bridge uses, replaying the head we had to buffer to make the call.
|
||||
tunnel_connection_with_prelude(container_id, target, stream, container_port, head).await;
|
||||
tunnel_connection_with_prelude(container, target, stream, container_port, head).await;
|
||||
}
|
||||
|
||||
/// Read bytes until the end of the HTTP request head (`\r\n\r\n`), or fail.
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
use tauri::{AppHandle, State};
|
||||
|
||||
use crate::auth_bridge::AuthBridgeStatus;
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::AppState;
|
||||
|
||||
/// Turn the bridge on or off for a project and return the resulting status.
|
||||
@@ -29,16 +30,13 @@ pub async fn set_auth_bridge_enabled(
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
if let Some(container_id) = project.container_id {
|
||||
if crate::docker::container::is_container_running(&container_id)
|
||||
.await
|
||||
.unwrap_or(false)
|
||||
{
|
||||
if let Some(container) = ContainerTarget::for_project(&project) {
|
||||
if container.is_running().await.unwrap_or(false) {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id,
|
||||
container,
|
||||
app_handle,
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
@@ -1203,18 +1203,11 @@ pub(crate) struct FetchedFile {
|
||||
/// read — landed in host RAM twice. This function buffers, so every caller of
|
||||
/// it must name a ceiling.
|
||||
pub(crate) async fn fetch_container_file(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
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 = container.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 +1290,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, &path, cap).await?;
|
||||
|
||||
Ok(FileContents {
|
||||
contents_base64: BASE64.encode(&fetched.bytes),
|
||||
@@ -1333,9 +1324,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 +1338,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 +1353,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 +1363,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 +1404,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 +1443,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.
|
||||
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);
|
||||
pub(crate) async fn require_running(
|
||||
container: &ContainerTarget,
|
||||
action: &str,
|
||||
) -> Result<(), String> {
|
||||
// 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 = container.is_running().await?;
|
||||
if running {
|
||||
return Ok(());
|
||||
}
|
||||
@@ -1543,12 +1526,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, "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 +1545,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 +1606,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 +1664,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 +1695,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 +1728,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 +1862,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 +1879,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, "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 +1896,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 +1917,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 +1932,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 +1956,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 +1966,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 +2235,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, "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 +2283,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 +2347,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 +2376,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 +3510,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 +3522,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 +3535,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 +3554,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,9 +15,10 @@ 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::host::ContainerTarget;
|
||||
use crate::models::Project;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -113,13 +114,11 @@ fn project_of(state: &AppState, project_id: &str) -> Result<Project, String> {
|
||||
}
|
||||
|
||||
/// `action` completes "Start the project before …", e.g. "saving this file".
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<String, String> {
|
||||
let container_id = project
|
||||
.container_id
|
||||
.clone()
|
||||
async fn running_container_of(project: &Project, action: &str) -> Result<ContainerTarget, String> {
|
||||
let container = ContainerTarget::for_project(project)
|
||||
.ok_or_else(|| not_running_message(action, "files live in its container"))?;
|
||||
require_running(&container_id, action).await?;
|
||||
Ok(container_id)
|
||||
require_running(&container, action).await?;
|
||||
Ok(container)
|
||||
}
|
||||
|
||||
/// The container of the project a viewer window belongs to, if it is running.
|
||||
@@ -127,7 +126,7 @@ async fn running_container_for(
|
||||
state: &AppState,
|
||||
target: &ViewerTarget,
|
||||
action: &str,
|
||||
) -> Result<String, String> {
|
||||
) -> Result<ContainerTarget, String> {
|
||||
running_container_of(&project_of(state, &target.project_id)?, action).await
|
||||
}
|
||||
|
||||
@@ -156,11 +155,20 @@ pub async fn open_file_viewer(
|
||||
) -> Result<(), String> {
|
||||
require_main(window.label())?;
|
||||
let project = project_of(&state, &project_id)?;
|
||||
let container_id = running_container_of(&project, "opening files").await?;
|
||||
let container = 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, &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, &fallbacks).await?;
|
||||
candidates.extend(fallbacks);
|
||||
}
|
||||
}
|
||||
let initial = Location { line, col, end_line };
|
||||
|
||||
let target_state = match matches.len() {
|
||||
@@ -244,9 +252,9 @@ pub async fn viewer_read_file(
|
||||
) -> Result<ViewerFile, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
let container_id = running_container_for(&state, &target, "opening files").await?;
|
||||
let container = running_container_for(&state, &target, "opening files").await?;
|
||||
let cap = max_bytes.clamp(1, MAX_READ_BYTES);
|
||||
let fetched = fetch_container_file(&container_id, &path, cap).await?;
|
||||
let fetched = fetch_container_file(&container, &path, cap).await?;
|
||||
let (editable, readonly_reason) = match validate_container_write_path("File", &path) {
|
||||
Ok(()) => (true, None),
|
||||
Err(reason) => (false, Some(reason)),
|
||||
@@ -269,8 +277,8 @@ pub async fn viewer_poll_file(
|
||||
) -> Result<ViewerPoll, String> {
|
||||
let (_label, target) = own_target(&window, ®istry)?;
|
||||
let path = resolved_path(&target)?;
|
||||
let container_id = running_container_for(&state, &target, "checking this file for changes").await?;
|
||||
poll_file(&container_id, &path).await
|
||||
let container = running_container_for(&state, &target, "checking this file for changes").await?;
|
||||
poll_file(&container, &path).await
|
||||
}
|
||||
|
||||
/// Errors from `write_file` pass through unchanged: the frontend matches the
|
||||
@@ -293,8 +301,8 @@ pub async fn viewer_write_file(
|
||||
let bytes = BASE64
|
||||
.decode(contents_base64.as_bytes())
|
||||
.map_err(|_| "The editor sent malformed content.".to_string())?;
|
||||
let container_id = running_container_for(&state, &target, "saving this file").await?;
|
||||
write_file(&container_id, &state.exec_manager, &path, &bytes, &base_hash).await
|
||||
let container = running_container_for(&state, &target, "saving this file").await?;
|
||||
write_file(&container, &state.exec_manager, &path, &bytes, &base_hash).await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
|
||||
@@ -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)
|
||||
@@ -316,11 +313,11 @@ pub(crate) 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
|
||||
@@ -567,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"))
|
||||
}
|
||||
|
||||
@@ -682,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
|
||||
@@ -732,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(),
|
||||
@@ -757,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
|
||||
@@ -1141,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: {}",
|
||||
@@ -1201,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: {}",
|
||||
@@ -1212,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
|
||||
@@ -1225,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
|
||||
@@ -1254,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?;
|
||||
@@ -1278,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))?;
|
||||
|
||||
@@ -1318,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))
|
||||
}
|
||||
@@ -1331,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?;
|
||||
@@ -1347,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,11 +944,11 @@ 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),
|
||||
None => false,
|
||||
let running = match ContainerTarget::for_project(&p) {
|
||||
Some(target) if target.is_running().await.unwrap_or(false) => Some(target),
|
||||
_ => None,
|
||||
};
|
||||
if !running {
|
||||
let Some(target) = running else {
|
||||
if project_id.is_some() {
|
||||
return Err(format!(
|
||||
"\"{}\" is not running. Its marketplace items are applied when it starts.",
|
||||
@@ -956,9 +956,8 @@ pub async fn apply_marketplace_now(
|
||||
));
|
||||
}
|
||||
continue;
|
||||
}
|
||||
let cid = p.container_id.clone().unwrap_or_default();
|
||||
let report = mk::sync_project(&state.marketplace, &settings, &p, &cid).await;
|
||||
};
|
||||
let report = mk::sync_project(&state.marketplace, &settings, &p, &target).await;
|
||||
let _ = app_handle.emit(
|
||||
mk::SYNC_FINISHED_EVENT,
|
||||
serde_json::json!({ "project_id": p.id, "report": report }),
|
||||
@@ -1051,13 +1050,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 +1069,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?;
|
||||
|
||||
@@ -55,7 +55,8 @@
|
||||
|
||||
use tauri::State;
|
||||
|
||||
use crate::commands::project_commands::{create_container_for_project, emit_progress};
|
||||
use crate::commands::project_commands::emit_progress;
|
||||
use crate::docker::{create_container_for_project, default_docker_socket};
|
||||
use crate::docker;
|
||||
use crate::docker::migration::{self as mig, Recovery};
|
||||
use crate::models::{
|
||||
@@ -1810,14 +1811,6 @@ pub(crate) async fn purge_migration_artifacts(project_id: &str) {
|
||||
migration_store::clear_ownerless_for_project(project_id);
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
fn human_bytes(n: u64) -> String {
|
||||
const UNITS: [&str; 5] = ["B", "KiB", "MiB", "GiB", "TiB"];
|
||||
let mut v = n as f64;
|
||||
@@ -1860,7 +1853,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 +1906,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 +1972,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 +2000,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(_) => {}
|
||||
|
||||
@@ -2,7 +2,9 @@ use tauri::{Emitter, State};
|
||||
|
||||
use crate::commands::aws_commands;
|
||||
use crate::docker;
|
||||
use crate::models::{container_config, AppSettings, Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::host::lifecycle::{DiscardLeftovers, ProjectHost};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{Backend, BedrockAuthMethod, Project, ProjectPath, ProjectRemovalReport, ProjectResetOutcome, ProjectStatus};
|
||||
use crate::storage::secure;
|
||||
use crate::AppState;
|
||||
|
||||
@@ -114,48 +116,6 @@ fn store_secrets_for_project(project: &Project, explicitly_cleared: &[&str]) ->
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Create the project's container, threading every global setting through.
|
||||
///
|
||||
/// Exists so that the two ordinary create paths below and base-image migration
|
||||
/// cannot drift apart — a container created by a migration must be
|
||||
/// indistinguishable from one created by a normal start, or the next
|
||||
/// `container_needs_recreation` would immediately throw it away.
|
||||
///
|
||||
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||
/// order to tell those two apart when it stamps the lineage labels.
|
||||
pub(crate) async fn create_container_for_project(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
docker_socket: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
create_image: &str,
|
||||
base_image_name: &str,
|
||||
extras: docker::CreateExtras<'_>,
|
||||
) -> Result<String, String> {
|
||||
docker::create_container(
|
||||
project,
|
||||
docker_socket,
|
||||
create_image,
|
||||
base_image_name,
|
||||
extras,
|
||||
aws_config_path,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
/// Populate secret fields on a project struct from the OS keychain.
|
||||
pub(crate) fn load_secrets_for_project(project: &mut Project) {
|
||||
project.git_token = secure::get_project_secret(&project.id, "git-token")
|
||||
@@ -774,7 +734,8 @@ pub async fn remove_project(
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = match docker::find_existing_container(project).await {
|
||||
let host = ProjectHost::for_project(project);
|
||||
let container_ref = match host.find_current(project).await {
|
||||
Ok(found) => found,
|
||||
Err(e) => {
|
||||
log::warn!(
|
||||
@@ -785,15 +746,14 @@ pub async fn remove_project(
|
||||
None
|
||||
}
|
||||
};
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
if let Some(ref container) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container.id()) {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
if let Err(e) = docker::remove_container(container_id).await {
|
||||
if let Err(e) = host.remove(container).await {
|
||||
log::warn!(
|
||||
"Failed to remove container {} for project {}: {}",
|
||||
container_id, project_id, e
|
||||
container.id(), project_id, e
|
||||
);
|
||||
// Recorded by name, not id: the name is the stable handle a
|
||||
// later retry can still resolve (Docker's remove-container
|
||||
@@ -805,15 +765,12 @@ pub async fn remove_project(
|
||||
|
||||
// Legacy MCP cleanup (pre-MCP-removal installs): drop any leftover MCP
|
||||
// containers first, then the per-project network they were attached to.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
host.remove_legacy_leftovers(project).await;
|
||||
|
||||
// Clean up the snapshot image + volumes
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
report.image = Some(docker::get_snapshot_image_name(project));
|
||||
}
|
||||
report.volumes = docker::remove_project_volumes(project).await;
|
||||
let left = host.discard_project_state(project).await;
|
||||
report.image = left.image;
|
||||
report.volumes = left.volumes;
|
||||
}
|
||||
|
||||
// Clean up keychain secrets for this project
|
||||
@@ -1204,9 +1161,9 @@ async fn start_project_container_locked(
|
||||
// in memory when building environment variables for the container.
|
||||
load_secrets_for_project(&mut project);
|
||||
|
||||
// Load settings for image resolution and global AWS
|
||||
// Load settings: backend validation below, and the host's start (image
|
||||
// resolution, global AWS, …).
|
||||
let settings = state.settings_store.get();
|
||||
let image_name = container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// Validate backend requirements
|
||||
if project.backend == Backend::Bedrock {
|
||||
@@ -1322,140 +1279,18 @@ async fn start_project_container_locked(
|
||||
}
|
||||
|
||||
// Wrap container operations so that any failure resets status to Stopped.
|
||||
let result: Result<String, String> = async {
|
||||
// Ensure image exists
|
||||
emit_progress(&app_handle, &project_id, "Checking image...");
|
||||
if !docker::image_exists(&image_name).await? {
|
||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||
}
|
||||
|
||||
// Determine docker socket path
|
||||
let docker_socket = settings.docker_socket_path
|
||||
.as_deref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| default_docker_socket());
|
||||
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// What we would create this container from *right now*: the project's
|
||||
// snapshot when one exists, else the configured base. This is the value
|
||||
// `container_needs_recreation` compares against the container's
|
||||
// `triple-c.create-image` label — the check that replaced the old
|
||||
// tautological one. It is resolved *before* the commit below, so it
|
||||
// describes the pre-commit world the existing container was born into.
|
||||
let snapshot_image = docker::get_snapshot_image_name(&project);
|
||||
let expected_create_image =
|
||||
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(&project).await? {
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
&project,
|
||||
&expected_create_image,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if needs_recreate {
|
||||
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||
// Snapshot the filesystem before destroying
|
||||
emit_progress(&app_handle, &project_id, "Saving container state...");
|
||||
if let Err(e) = docker::commit_container_snapshot(&existing_id, &project).await {
|
||||
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||
}
|
||||
emit_progress(&app_handle, &project_id, "Recreating container...");
|
||||
let _ = docker::stop_container(&existing_id).await;
|
||||
docker::remove_container(&existing_id).await?;
|
||||
|
||||
// Legacy MCP cleanup: the old container may have been attached to
|
||||
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||
// that network now, before the replacement is created without it.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes).
|
||||
// Re-resolved after the commit above: when no snapshot existed
|
||||
// before, one does now, and creating from the base instead
|
||||
// would throw away the state that was just saved.
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
|
||||
// The commit above moved `:latest` and orphaned the image it
|
||||
// used to point at; the container holding that image open was
|
||||
// removed a few lines up, so now is when Docker will actually
|
||||
// let it go. Detached because this is housekeeping and the
|
||||
// project is already running — and it sweeps every orphan, not
|
||||
// just this one, so recreations that happened before the sweep
|
||||
// existed are cleaned up too.
|
||||
tauri::async_runtime::spawn(async {
|
||||
docker::sweep_orphaned_snapshots_logged("after recreation").await;
|
||||
});
|
||||
|
||||
new_id
|
||||
} else {
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&existing_id).await?;
|
||||
existing_id
|
||||
}
|
||||
} else {
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
if expected_create_image == snapshot_image {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
}
|
||||
let create_image = expected_create_image.clone();
|
||||
|
||||
emit_progress(&app_handle, &project_id, "Creating container...");
|
||||
let new_id = create_container_for_project(
|
||||
&project,
|
||||
&settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
emit_progress(&app_handle, &project_id, "Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
};
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let result: Result<ContainerTarget, String> = async {
|
||||
let target = host
|
||||
.ensure_started(&project, &settings, &|msg: &str| {
|
||||
emit_progress(&app_handle, &project_id, msg)
|
||||
})
|
||||
.await?;
|
||||
|
||||
// Sync Bedrock credentials on every start: refresh static/session creds
|
||||
// so rotated keys are picked up without a full container recreation, and
|
||||
// clear stale creds when the project no longer uses static-cred Bedrock.
|
||||
if let Err(e) = docker::sync_bedrock_credentials(&container_id, &project).await {
|
||||
if let Err(e) = docker::sync_bedrock_credentials(&target, &project).await {
|
||||
log::warn!("Failed to sync AWS credentials for project {}: {}", project.id, e);
|
||||
}
|
||||
|
||||
@@ -1466,10 +1301,10 @@ async fn start_project_container_locked(
|
||||
state.marketplace.clone(),
|
||||
state.settings_store.get(),
|
||||
project.clone(),
|
||||
container_id.clone(),
|
||||
target.clone(),
|
||||
);
|
||||
|
||||
Ok(container_id)
|
||||
Ok(target)
|
||||
}.await;
|
||||
|
||||
// On failure, reset status to Stopped so the project doesn't get stuck.
|
||||
@@ -1478,7 +1313,8 @@ async fn start_project_container_locked(
|
||||
let _ = state.projects_store.update_status(&project_id, ProjectStatus::Stopped);
|
||||
}
|
||||
|
||||
let container_id = result?;
|
||||
let target = result?;
|
||||
let container_id = target.id().to_string();
|
||||
|
||||
// Update project with container info using granular methods (Issue 14: TOCTOU)
|
||||
state.projects_store.set_container_id(&project_id, Some(container_id.clone()))?;
|
||||
@@ -1491,7 +1327,7 @@ async fn start_project_container_locked(
|
||||
.auth_bridge
|
||||
.start(
|
||||
project_id.clone(),
|
||||
container_id.clone(),
|
||||
target.clone(),
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -1527,13 +1363,13 @@ pub async fn stop_project_container(
|
||||
// Drop host listeners first: they only make sense while the container runs.
|
||||
state.auth_bridge.stop(&project_id).await;
|
||||
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
if let Some(container) = ContainerTarget::for_project(&project) {
|
||||
// Close exec sessions for this project
|
||||
emit_progress(&app_handle, &project_id, "Stopping container...");
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
|
||||
if let Err(e) = docker::stop_container(container_id).await {
|
||||
log::warn!("Docker stop failed for container {} (project {}): {} — resetting to Stopped anyway", container_id, project_id, e);
|
||||
if let Err(e) = ProjectHost::for_project(&project).stop(&container).await {
|
||||
log::warn!("Stop failed for container {} (project {}): {} — resetting to Stopped anyway", container.id(), project_id, e);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1588,13 +1424,13 @@ pub async fn rebuild_project_container(
|
||||
if let Some(ref stored_id) = project.container_id {
|
||||
state.exec_manager.close_sessions_for_container(stored_id).await;
|
||||
}
|
||||
let container_ref = docker::find_existing_container(&project).await?;
|
||||
if let Some(ref container_id) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container_id.as_str()) {
|
||||
state.exec_manager.close_sessions_for_container(container_id).await;
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let container_ref = host.find_current(&project).await?;
|
||||
if let Some(ref container) = container_ref {
|
||||
if project.container_id.as_deref() != Some(container.id()) {
|
||||
state.exec_manager.close_sessions_for_container(container.id()).await;
|
||||
}
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await?;
|
||||
host.remove(container).await?;
|
||||
state.projects_store.set_container_id(&project_id, None)?;
|
||||
}
|
||||
|
||||
@@ -1607,12 +1443,10 @@ pub async fn rebuild_project_container(
|
||||
// discard. No pending-cleanup record for either: unlike `remove_project`,
|
||||
// Reset keeps the project record, so a later Reset attempt can retry
|
||||
// these itself rather than needing startup housekeeping to do it.
|
||||
let mut leftover_image = None;
|
||||
if let Err(e) = docker::remove_snapshot_image(&project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project_id, e);
|
||||
leftover_image = Some(docker::get_snapshot_image_name(&project));
|
||||
}
|
||||
let leftover_volumes = docker::remove_project_volumes(&project).await;
|
||||
let DiscardLeftovers {
|
||||
image: leftover_image,
|
||||
volumes: leftover_volumes,
|
||||
} = host.discard_project_state(&project).await;
|
||||
if leftover_image.is_some() || !leftover_volumes.is_empty() {
|
||||
log::warn!(
|
||||
"Reset for project {} could not fully clean up — image: {:?}, volumes: {:?} — the \
|
||||
@@ -1679,8 +1513,8 @@ pub async fn reconcile_project_statuses(
|
||||
continue;
|
||||
}
|
||||
|
||||
let is_running = if let Some(ref container_id) = project.container_id {
|
||||
docker::is_container_running(container_id).await.unwrap_or(false)
|
||||
let is_running = if let Some(container) = ContainerTarget::for_project(project) {
|
||||
container.is_running().await.unwrap_or(false)
|
||||
} else {
|
||||
false
|
||||
};
|
||||
@@ -1695,12 +1529,12 @@ pub async fn reconcile_project_statuses(
|
||||
// bridge lives in this process, so re-arm it here. `start` is
|
||||
// idempotent, so a bridge that is already polling is untouched.
|
||||
if project.auth_bridge_enabled {
|
||||
if let Some(ref container_id) = project.container_id {
|
||||
if let Some(target) = crate::host::ContainerTarget::for_project(&project) {
|
||||
state
|
||||
.auth_bridge
|
||||
.start(
|
||||
project.id.clone(),
|
||||
container_id.clone(),
|
||||
target,
|
||||
app_handle.clone(),
|
||||
state.projects_store.clone(),
|
||||
)
|
||||
@@ -1720,13 +1554,6 @@ pub async fn reconcile_project_statuses(
|
||||
Ok(state.projects_store.list())
|
||||
}
|
||||
|
||||
fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -159,9 +160,7 @@ 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() {
|
||||
@@ -189,7 +188,7 @@ pub async fn open_terminal_session(
|
||||
state
|
||||
.exec_manager
|
||||
.create_session(
|
||||
container_id,
|
||||
&target,
|
||||
&session_id,
|
||||
cmd,
|
||||
move |data| {
|
||||
@@ -240,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)
|
||||
@@ -250,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
|
||||
}
|
||||
|
||||
@@ -283,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
|
||||
@@ -313,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.",
|
||||
@@ -326,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]
|
||||
@@ -347,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
|
||||
@@ -362,7 +359,7 @@ pub async fn start_audio_bridge(
|
||||
state
|
||||
.exec_manager
|
||||
.create_session_with_tty(
|
||||
&container_id,
|
||||
&target,
|
||||
&audio_session_id,
|
||||
cmd,
|
||||
false,
|
||||
|
||||
@@ -0,0 +1,328 @@
|
||||
//! 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 the host's death reports (`host::watch`; for Docker,
|
||||
//! the daemon's event stream filtered 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::future::Future;
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::StreamExt;
|
||||
use serde::Serialize;
|
||||
use tauri::{AppHandle, Emitter};
|
||||
use tokio::sync::watch;
|
||||
|
||||
use crate::host::watch::ExitSource;
|
||||
use crate::host::ContainerTarget;
|
||||
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,
|
||||
}
|
||||
|
||||
fn project_busy(project_id: &str) -> bool {
|
||||
crate::project_lock::held(project_id).is_some()
|
||||
}
|
||||
|
||||
/// Confirm `container` really stopped and, if a project was Running on it,
|
||||
/// flip that project. Returns what changed.
|
||||
async fn settle(store: &ProjectsStore, container: &ContainerTarget) -> Option<Project> {
|
||||
let container_id = container.id();
|
||||
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)?;
|
||||
// Positive exit only: a host that can't answer must not mark every
|
||||
// project Stopped.
|
||||
if !container.has_exited().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 the host 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(store: &ProjectsStore) -> Vec<Project> {
|
||||
let mut changed = Vec::new();
|
||||
for p in store.list() {
|
||||
if p.status != ProjectStatus::Running {
|
||||
continue;
|
||||
}
|
||||
let Some(container) = ContainerTarget::for_project(&p) else { continue };
|
||||
if let Some(c) = settle(store, &container).await {
|
||||
changed.push(c);
|
||||
}
|
||||
}
|
||||
changed
|
||||
}
|
||||
|
||||
/// 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 host 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 source = ExitSource::Docker;
|
||||
if !unless_cancelled(cancel, source.reachable()).await? {
|
||||
return Some(false);
|
||||
}
|
||||
// A second's overlap: the host 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;
|
||||
for p in unless_cancelled(cancel, sweep(store)).await? {
|
||||
on_change(&p, sweep_reason);
|
||||
}
|
||||
let mut deaths = source.deaths(since);
|
||||
let connected = Instant::now();
|
||||
let mut delivered = false;
|
||||
loop {
|
||||
match unless_cancelled(cancel, deaths.next()).await? {
|
||||
Some(Ok(container)) => {
|
||||
delivered = true;
|
||||
if let Some(p) = unless_cancelled(cancel, settle(store, &container)).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::*;
|
||||
|
||||
#[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 host 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:?}");
|
||||
}
|
||||
}
|
||||
@@ -26,3 +26,13 @@ pub async fn check_docker_available() -> Result<bool, String> {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The daemon socket a project container gets bind-mounted when Docker access
|
||||
/// is enabled and no path is configured.
|
||||
pub fn default_docker_socket() -> String {
|
||||
if cfg!(target_os = "windows") {
|
||||
"//./pipe/docker_engine".to_string()
|
||||
} else {
|
||||
"/var/run/docker.sock".to_string()
|
||||
}
|
||||
}
|
||||
@@ -9,6 +9,7 @@ use sha2::{Sha256, Digest};
|
||||
|
||||
use super::ca_certs;
|
||||
use super::client::get_docker;
|
||||
use crate::models::AppSettings;
|
||||
use crate::models::{Backend, BedrockAuthMethod, ClaudeCodeSettings, ContainerInfo, EnvVar, GlobalAwsSettings, GlobalLlamaCppSettings, GlobalOllamaSettings, GlobalOpenAiCompatibleSettings, PortMapping, Project, ProjectPath};
|
||||
|
||||
const SCHEDULER_INSTRUCTIONS: &str = r#"## Scheduled Tasks
|
||||
@@ -124,6 +125,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 +174,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),
|
||||
@@ -2000,7 +2016,7 @@ pub const CONFIG_VOLUME_PREFIX: &str = "triple-c-claude-config-";
|
||||
/// `aws_config_path` is configured), since the entrypoint already refreshes
|
||||
/// `~/.aws` from the host on every start in that case.
|
||||
pub async fn sync_bedrock_credentials(
|
||||
container_id: &str,
|
||||
target: &crate::host::ContainerTarget,
|
||||
project: &Project,
|
||||
) -> Result<(), String> {
|
||||
let static_bedrock = if project.backend == Backend::Bedrock {
|
||||
@@ -2023,10 +2039,10 @@ pub async fn sync_bedrock_credentials(
|
||||
let script = r#"if [ ! -d /tmp/.host-aws ]; then rm -f "$HOME/.aws/credentials"; fi"#;
|
||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
let env = vec!["HOME=/home/claude".to_string()];
|
||||
if let Err(e) = crate::docker::exec::exec_oneshot_env(container_id, cmd, env).await {
|
||||
if let Err(e) = target.exec_oneshot_env(cmd, env).await {
|
||||
log::warn!(
|
||||
"Failed to clear stale AWS credentials in container {}: {}",
|
||||
container_id,
|
||||
target.id(),
|
||||
e
|
||||
);
|
||||
}
|
||||
@@ -2078,7 +2094,7 @@ chmod 600 "$HOME/.aws/credentials""#;
|
||||
|
||||
let cmd = vec!["sh".to_string(), "-c".to_string(), script.to_string()];
|
||||
let (output, exit_code) =
|
||||
crate::docker::exec::exec_oneshot_env_status(container_id, cmd, env)
|
||||
target.exec_oneshot_env_status(cmd, env)
|
||||
.await
|
||||
.map_err(|e| format!("Failed to write AWS credentials into container: {}", e))?;
|
||||
if exit_code != 0 {
|
||||
@@ -2089,7 +2105,7 @@ chmod 600 "$HOME/.aws/credentials""#;
|
||||
));
|
||||
}
|
||||
|
||||
log::info!("Wrote Bedrock static credentials into container {}", container_id);
|
||||
log::info!("Wrote Bedrock static credentials into container {}", target.id());
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -4128,6 +4144,49 @@ pub async fn is_container_running(container_id: &str) -> Result<bool, String> {
|
||||
}
|
||||
|
||||
|
||||
|
||||
/// Create the project's container, threading every global setting through.
|
||||
///
|
||||
/// Exists so that the ordinary create paths (`host::lifecycle`) and base-image migration
|
||||
/// cannot drift apart — a container created by a migration must be
|
||||
/// indistinguishable from one created by a normal start, or the next
|
||||
/// `container_needs_recreation` would immediately throw it away.
|
||||
///
|
||||
/// `create_image` is what to create *from* (the snapshot or the base);
|
||||
/// `base_image_name` is the configured base, which `create_container` needs in
|
||||
/// order to tell those two apart when it stamps the lineage labels.
|
||||
pub async fn create_container_for_project(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
docker_socket: &str,
|
||||
aws_config_path: Option<&str>,
|
||||
create_image: &str,
|
||||
base_image_name: &str,
|
||||
extras: CreateExtras<'_>,
|
||||
) -> Result<String, String> {
|
||||
create_container(
|
||||
project,
|
||||
docker_socket,
|
||||
create_image,
|
||||
base_image_name,
|
||||
extras,
|
||||
aws_config_path,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
)
|
||||
.await
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
@@ -4137,6 +4196,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)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,114 @@
|
||||
//! The Docker half of `container_watch`: reachability, the daemon's container
|
||||
//! `die` event stream, and the "has it positively exited" check. Everything
|
||||
//! about *what to do* with a death (confirm, compare-and-set the store, tell
|
||||
//! the frontend, back off) is host-neutral and stays in `container_watch.rs`,
|
||||
//! which reaches these through `host::watch`.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::pin::Pin;
|
||||
|
||||
use bollard::errors::Error as DockerError;
|
||||
use bollard::models::{EventMessage, EventMessageTypeEnum};
|
||||
use bollard::system::EventsOptions;
|
||||
use futures_util::{Stream, StreamExt};
|
||||
|
||||
use super::client::get_docker;
|
||||
|
||||
/// Whether the daemon answers at all.
|
||||
pub async fn ping() -> bool {
|
||||
match get_docker() {
|
||||
Ok(docker) => docker.ping().await.is_ok(),
|
||||
Err(_) => false,
|
||||
}
|
||||
}
|
||||
|
||||
/// 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())
|
||||
}
|
||||
|
||||
fn die_filter() -> HashMap<String, Vec<String>> {
|
||||
HashMap::from([
|
||||
("type".to_string(), vec!["container".to_string()]),
|
||||
("event".to_string(), vec!["die".to_string()]),
|
||||
])
|
||||
}
|
||||
|
||||
/// Container ids as Docker reports them dying, replayed from `since` (unix
|
||||
/// seconds). Filtered daemon-side to container `die` events, so an idle daemon
|
||||
/// costs nothing. Anything else that comes through is dropped here. An `Err`
|
||||
/// item, or the stream ending, means the connection is gone.
|
||||
pub fn container_deaths(since: i64) -> Pin<Box<dyn Stream<Item = Result<String, String>> + Send>> {
|
||||
let docker = match get_docker() {
|
||||
Ok(d) => d,
|
||||
Err(e) => return Box::pin(futures_util::stream::once(async move { Err(e) })),
|
||||
};
|
||||
let events = docker.events(Some(EventsOptions::<String> {
|
||||
since: Some(since.to_string()),
|
||||
until: None,
|
||||
filters: die_filter(),
|
||||
}));
|
||||
Box::pin(events.filter_map(|item| async move {
|
||||
match item {
|
||||
Ok(msg) => died_container(&msg).map(|id| Ok(id.to_string())),
|
||||
Err(e) => Some(Err(e.to_string())),
|
||||
}
|
||||
}))
|
||||
}
|
||||
|
||||
/// `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" — `is_container_running` maps every error to `false`, which is right
|
||||
/// for its callers and wrong for the watcher, where it would mark every project
|
||||
/// Stopped the moment the daemon hiccups.
|
||||
pub async fn has_exited(container_id: &str) -> bool {
|
||||
let Ok(docker) = get_docker() else { return false };
|
||||
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,
|
||||
}
|
||||
}
|
||||
|
||||
#[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);
|
||||
}
|
||||
}
|
||||
+240
-363
@@ -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.
|
||||
@@ -376,31 +206,8 @@ pub async fn upload_host_file_with_ids(
|
||||
let mtime = now_epoch_secs();
|
||||
|
||||
let tar_buf = tokio::task::spawn_blocking(move || -> Result<Vec<u8>, String> {
|
||||
// The caller resolved this path (`resolve_host_read_path`); opening it
|
||||
// is a second trip through the same directories, so the descriptor is
|
||||
// checked against the path that was validated before its bytes are
|
||||
// packed into anything. Two paths reach here: the terminal's drop
|
||||
// target, and the Files pane's upload via `upload_host_file_with_ids`.
|
||||
// Between them they are how host bytes enter a container.
|
||||
let file = std::fs::File::open(&host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
crate::commands::file_commands::verify_opened_path(
|
||||
&file,
|
||||
std::path::Path::new(&host_path),
|
||||
)?;
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(
|
||||
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||
&mut data,
|
||||
)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
if data.len() as u64 > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to upload (limit {} MB)",
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
build_single_file_tar(&dest_for_blk, &data[..], 0o644, uid, gid, mtime)
|
||||
let data = read_host_file_for_upload(&host_path)?;
|
||||
build_single_file_tar(&dest_for_blk, &data[..], HOST_FILE_UPLOAD_MODE, uid, gid, mtime)
|
||||
})
|
||||
.await
|
||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||
@@ -421,13 +228,46 @@ pub async fn upload_host_file_with_ids(
|
||||
Ok(container_join(dest_dir, &dest_name))
|
||||
}
|
||||
|
||||
/// The mode a host file lands with in the container, on every host.
|
||||
pub(crate) const HOST_FILE_UPLOAD_MODE: u32 = 0o644;
|
||||
|
||||
/// Read a host file that is about to be copied into a container. Blocking:
|
||||
/// call it from `spawn_blocking`. Shared by every host's upload so the checks
|
||||
/// cannot drift apart.
|
||||
///
|
||||
/// The caller resolved this path (`resolve_host_read_path`); opening it is a
|
||||
/// second trip through the same directories, so the descriptor is checked
|
||||
/// against the path that was validated before its bytes are packed into
|
||||
/// anything. Two paths reach here: the terminal's drop target, and the Files
|
||||
/// pane's upload via `upload_host_file_with_ids`. Between them they are how
|
||||
/// host bytes enter a container. [`MAX_DROP_BYTES`] is applied to what the
|
||||
/// open descriptor actually yields, not to an earlier `stat`.
|
||||
pub(crate) fn read_host_file_for_upload(host_path: &str) -> Result<Vec<u8>, String> {
|
||||
let file = std::fs::File::open(host_path)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
crate::commands::file_commands::verify_opened_path(&file, std::path::Path::new(host_path))?;
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(
|
||||
&mut std::io::Read::take(file, MAX_DROP_BYTES.saturating_add(1)),
|
||||
&mut data,
|
||||
)
|
||||
.map_err(|e| format!("Failed to read {}: {}", host_path, e))?;
|
||||
if data.len() as u64 > MAX_DROP_BYTES {
|
||||
return Err(format!(
|
||||
"File too large to upload (limit {} MB)",
|
||||
MAX_DROP_BYTES / (1024 * 1024)
|
||||
));
|
||||
}
|
||||
Ok(data)
|
||||
}
|
||||
|
||||
/// Join a container directory to a name that may itself carry separators.
|
||||
///
|
||||
/// Only the *reported* path — the bytes have already landed by the time this is
|
||||
/// called — but that path is what the terminal echoes and what the Files pane
|
||||
/// puts in its toast, so `/tmp//x` reading back as a different file than `/tmp/x`
|
||||
/// is worth the four lines. `"/"` trims to `""` and yields `/x`.
|
||||
fn container_join(dir: &str, name: &str) -> String {
|
||||
pub(crate) fn container_join(dir: &str, name: &str) -> String {
|
||||
format!("{}/{}", dir.trim_end_matches('/'), name.trim_start_matches('/'))
|
||||
}
|
||||
|
||||
@@ -444,14 +284,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,14 +369,29 @@ 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,
|
||||
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()],
|
||||
256,
|
||||
"claude",
|
||||
user_ids_cmd(),
|
||||
USER_IDS_OUTPUT_LIMIT,
|
||||
)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
parse_user_ids(&out)
|
||||
}
|
||||
|
||||
/// The command [`container_user_ids`] runs (as `claude`, under
|
||||
/// [`USER_IDS_OUTPUT_LIMIT`]); every host asks the same question.
|
||||
pub(crate) fn user_ids_cmd() -> Vec<String> {
|
||||
vec!["sh".to_string(), "-c".to_string(), "id -u; id -g".to_string()]
|
||||
}
|
||||
|
||||
/// Output ceiling for the uid probe: two short numbers.
|
||||
pub(crate) const USER_IDS_OUTPUT_LIMIT: usize = 256;
|
||||
|
||||
/// `id -u; id -g` output -> `(uid, gid)`, falling back to the image's
|
||||
/// build-time `(1000, 1000)`.
|
||||
pub(crate) fn parse_user_ids(out: &str) -> (u64, u64) {
|
||||
let mut ids = out.lines().filter_map(|l| l.trim().parse::<u64>().ok());
|
||||
match (ids.next(), ids.next()) {
|
||||
(Some(uid), Some(gid)) => (uid, gid),
|
||||
@@ -543,9 +406,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 +452,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
|
||||
@@ -649,21 +519,54 @@ pub async fn exec_oneshot_as(
|
||||
/// fail". `stdout_ranges` indexes the parts of it that came from stdout, so a
|
||||
/// caller that is *parsing* output can have just that without the buffer being
|
||||
/// held twice.
|
||||
struct OneshotOutput {
|
||||
pub(crate) struct OneshotOutput {
|
||||
combined: Vec<u8>,
|
||||
stdout_ranges: Vec<(usize, usize)>,
|
||||
exit_code: i64,
|
||||
pub(crate) exit_code: i64,
|
||||
}
|
||||
|
||||
/// The refusal for output past `limit`, starting with [`OUTPUT_LIMIT_MARKER`].
|
||||
pub(crate) fn output_limit_error(limit: usize) -> String {
|
||||
format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, limit
|
||||
)
|
||||
}
|
||||
|
||||
impl OneshotOutput {
|
||||
pub(crate) fn new() -> Self {
|
||||
Self {
|
||||
combined: Vec::new(),
|
||||
stdout_ranges: Vec::new(),
|
||||
exit_code: 0,
|
||||
}
|
||||
}
|
||||
|
||||
/// Append one chunk in arrival order, or refuse it (appending nothing)
|
||||
/// once the combined output would pass `limit`. The caller must stop
|
||||
/// reading on an error rather than truncate silently: every caller parses
|
||||
/// this output, and a half-read manifest or JSON array is worse than an
|
||||
/// error.
|
||||
pub(crate) fn push(&mut self, from_stdout: bool, bytes: &[u8], limit: usize) -> Result<(), String> {
|
||||
match push_capped(&mut self.combined, bytes, limit) {
|
||||
Some(range) => {
|
||||
if from_stdout {
|
||||
self.stdout_ranges.push(range);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
None => Err(output_limit_error(limit)),
|
||||
}
|
||||
}
|
||||
|
||||
/// Everything the command printed, in the order it printed it.
|
||||
fn text(&self) -> String {
|
||||
pub(crate) fn text(&self) -> String {
|
||||
String::from_utf8_lossy(&self.combined).into_owned()
|
||||
}
|
||||
|
||||
/// stdout alone — for callers that parse it, where a diagnostic spliced in
|
||||
/// mid-record is a parse error at best.
|
||||
fn stdout(&self) -> String {
|
||||
pub(crate) fn stdout(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
out.extend_from_slice(&self.combined[*start..*end]);
|
||||
@@ -672,7 +575,7 @@ impl OneshotOutput {
|
||||
}
|
||||
|
||||
/// stderr alone — the complement of [`Self::stdout`], i.e. the diagnostics.
|
||||
fn stderr(&self) -> String {
|
||||
pub(crate) fn stderr(&self) -> String {
|
||||
let mut out = Vec::with_capacity(self.combined.len());
|
||||
let mut cursor = 0usize;
|
||||
for (start, end) in &self.stdout_ranges {
|
||||
@@ -741,8 +644,7 @@ async fn exec_oneshot_raw(
|
||||
.await
|
||||
.map_err(|e| format!("Failed to start exec: {}", e))?;
|
||||
|
||||
let mut combined: Vec<u8> = Vec::new();
|
||||
let mut stdout_ranges: Vec<(usize, usize)> = Vec::new();
|
||||
let mut out = OneshotOutput::new();
|
||||
match result {
|
||||
StartExecResults::Attached { mut output, .. } => {
|
||||
while let Some(msg) = output.next().await {
|
||||
@@ -750,23 +652,9 @@ async fn exec_oneshot_raw(
|
||||
Ok(data) => {
|
||||
let from_stdout = matches!(data, LogOutput::StdOut { .. });
|
||||
let bytes = data.into_bytes();
|
||||
match push_capped(&mut combined, &bytes, limit) {
|
||||
Some(range) => {
|
||||
if from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
// Stop reading rather than truncate silently: every
|
||||
// caller parses this output, and a half-read
|
||||
// manifest or JSON array is worse than an error.
|
||||
// Dropping `output` kills the exec's stream.
|
||||
None => {
|
||||
return Err(format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, limit
|
||||
))
|
||||
}
|
||||
}
|
||||
// Over the limit: returning drops `output`, which
|
||||
// kills the exec's stream.
|
||||
out.push(from_stdout, &bytes, limit)?;
|
||||
}
|
||||
Err(e) => return Err(format!("Exec output error: {}", e)),
|
||||
}
|
||||
@@ -777,13 +665,8 @@ async fn exec_oneshot_raw(
|
||||
|
||||
// The output stream draining doesn't strictly guarantee inspect_exec has the
|
||||
// final exit_code populated yet, so poll until the exec reports finished.
|
||||
let exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
|
||||
|
||||
Ok(OneshotOutput {
|
||||
combined,
|
||||
stdout_ranges,
|
||||
exit_code,
|
||||
})
|
||||
out.exit_code = require_exit_code(wait_for_exec_exit(&exec.id).await)?;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Turn "the exit code could not be determined" into an error rather than a 0.
|
||||
@@ -816,7 +699,7 @@ fn require_exit_code(code: Option<i64>) -> Result<i64, String> {
|
||||
/// spurious "the rename failed" and a real one.
|
||||
pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
let docker = get_docker().ok()?;
|
||||
for _ in 0..200 {
|
||||
for _ in 0..EXEC_EXIT_POLLS {
|
||||
match docker.inspect_exec(exec_id).await {
|
||||
Ok(info) => {
|
||||
if info.running != Some(true) {
|
||||
@@ -835,30 +718,27 @@ pub async fn wait_for_exec_exit(exec_id: &str) -> Option<i64> {
|
||||
}
|
||||
Err(_) => return None,
|
||||
}
|
||||
tokio::time::sleep(std::time::Duration::from_millis(25)).await;
|
||||
tokio::time::sleep(EXEC_EXIT_POLL_INTERVAL).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// [`wait_for_exec_exit`]'s budget: polls, and the pause between them. Every
|
||||
/// host's `wait_exec_exit` uses the same numbers.
|
||||
pub(crate) const EXEC_EXIT_POLLS: usize = 200;
|
||||
pub(crate) const EXEC_EXIT_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(25);
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// The frames a demultiplexed exec hands back, as `(is_stdout, bytes)`.
|
||||
fn collect(frames: &[(bool, &[u8])]) -> OneshotOutput {
|
||||
let mut combined = Vec::new();
|
||||
let mut stdout_ranges = Vec::new();
|
||||
let mut out = OneshotOutput::new();
|
||||
for (from_stdout, bytes) in frames {
|
||||
let range = push_capped(&mut combined, bytes, usize::MAX).unwrap();
|
||||
if *from_stdout {
|
||||
stdout_ranges.push(range);
|
||||
}
|
||||
}
|
||||
OneshotOutput {
|
||||
combined,
|
||||
stdout_ranges,
|
||||
exit_code: 0,
|
||||
out.push(*from_stdout, bytes, usize::MAX).unwrap();
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
#[test]
|
||||
@@ -917,10 +797,7 @@ mod tests {
|
||||
// The marker is what lets `list_container_files` say "too many entries"
|
||||
// instead, which is the thing that actually happened.
|
||||
assert!(!OUTPUT_LIMIT_MARKER.is_empty());
|
||||
let refusal = format!(
|
||||
"{}: Command output exceeded {} bytes and was abandoned",
|
||||
OUTPUT_LIMIT_MARKER, MAX_ONESHOT_OUTPUT
|
||||
);
|
||||
let refusal = output_limit_error(MAX_ONESHOT_OUTPUT);
|
||||
assert!(refusal.starts_with(OUTPUT_LIMIT_MARKER));
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -15,8 +15,16 @@ const TASK_RUNNER: &str = include_str!("../../../../container/triple-c-task-runn
|
||||
const OSC52_CLIPBOARD: &str = include_str!("../../../../container/osc52-clipboard");
|
||||
const AUDIO_SHIM: &str = include_str!("../../../../container/audio-shim");
|
||||
const SSO_REFRESH: &str = include_str!("../../../../container/triple-c-sso-refresh");
|
||||
const TRIPLE_C_OPEN: &str = include_str!("../../../../container/triple-c-open");
|
||||
const PLAYWRIGHT_HEAL: &str = include_str!("../../../../container/triple-c-playwright-heal");
|
||||
|
||||
static MISSION_CONTROL_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/mission-control");
|
||||
static SKILLS_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/skills");
|
||||
// The agent crate's sources only — never the whole crate directory, which can
|
||||
// hold a multi-hundred-megabyte `target/` from a local build.
|
||||
const AGENT_CARGO_TOML: &str = include_str!("../../../../container/triple-c-agent/Cargo.toml");
|
||||
const AGENT_CARGO_LOCK: &str = include_str!("../../../../container/triple-c-agent/Cargo.lock");
|
||||
static AGENT_SRC_DIR: Dir = include_dir!("$CARGO_MANIFEST_DIR/../../container/triple-c-agent/src");
|
||||
|
||||
pub async fn image_exists(image_name: &str) -> Result<bool, String> {
|
||||
let docker = get_docker()?;
|
||||
@@ -69,25 +77,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,
|
||||
@@ -130,10 +145,28 @@ where
|
||||
|
||||
let tar_bytes = create_build_context().map_err(|e| format!("Failed to create build context: {}", e))?;
|
||||
|
||||
// The Dockerfile builds `triple-c-agent` in a stage pinned to
|
||||
// `$BUILDPLATFORM` and cross-compiles for `$TARGETARCH`, so CI's arm64
|
||||
// image compiles natively instead of under QEMU. BuildKit predefines both;
|
||||
// the classic builder this API call uses does not, and an empty
|
||||
// `--platform=` fails the build outright (verified, Engine 29.9). So pass
|
||||
// them, describing the daemon's own platform: a local build is always
|
||||
// native.
|
||||
let arch = docker
|
||||
.version()
|
||||
.await
|
||||
.ok()
|
||||
.and_then(|v| v.arch)
|
||||
.filter(|a| !a.is_empty())
|
||||
.unwrap_or_else(|| native_docker_arch().to_string());
|
||||
let build_platform = format!("linux/{}", arch);
|
||||
let buildargs = local_build_args(&build_platform, &arch);
|
||||
|
||||
let options = BuildImageOptions {
|
||||
t: full_name.as_str(),
|
||||
rm: true,
|
||||
forcerm: true,
|
||||
buildargs,
|
||||
..Default::default()
|
||||
};
|
||||
|
||||
@@ -184,6 +217,20 @@ fn append_embedded_dir(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Docker's name for this machine's architecture, for when the daemon does
|
||||
/// not say.
|
||||
fn native_docker_arch() -> &'static str {
|
||||
match std::env::consts::ARCH {
|
||||
"aarch64" => "arm64",
|
||||
"x86_64" => "amd64",
|
||||
other => other,
|
||||
}
|
||||
}
|
||||
|
||||
fn local_build_args<'a>(build_platform: &'a str, arch: &'a str) -> HashMap<&'a str, &'a str> {
|
||||
HashMap::from([("BUILDPLATFORM", build_platform), ("TARGETARCH", arch)])
|
||||
}
|
||||
|
||||
fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
let mut buf = Vec::new();
|
||||
{
|
||||
@@ -196,8 +243,14 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
append_file_to_archive(&mut archive, "osc52-clipboard", OSC52_CLIPBOARD.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "audio-shim", AUDIO_SHIM.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-sso-refresh", SSO_REFRESH.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-open", TRIPLE_C_OPEN.as_bytes(), 0o755)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-playwright-heal", PLAYWRIGHT_HEAL.as_bytes(), 0o755)?;
|
||||
|
||||
append_embedded_dir(&mut archive, &MISSION_CONTROL_DIR, "mission-control")?;
|
||||
append_embedded_dir(&mut archive, &SKILLS_DIR, "skills")?;
|
||||
append_file_to_archive(&mut archive, "triple-c-agent/Cargo.toml", AGENT_CARGO_TOML.as_bytes(), 0o644)?;
|
||||
append_file_to_archive(&mut archive, "triple-c-agent/Cargo.lock", AGENT_CARGO_LOCK.as_bytes(), 0o644)?;
|
||||
append_embedded_dir(&mut archive, &AGENT_SRC_DIR, "triple-c-agent/src")?;
|
||||
|
||||
archive.finish()?;
|
||||
}
|
||||
@@ -206,6 +259,46 @@ fn create_build_context() -> Result<Vec<u8>, std::io::Error> {
|
||||
Ok(buf)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod build_context_tests {
|
||||
use super::{create_build_context, DOCKERFILE};
|
||||
|
||||
/// The local build (`ImageSource::LocalBuild`) ships the Dockerfile with a
|
||||
/// hand-assembled context, and nothing else checks the two agree: CI builds
|
||||
/// from the real `container/` directory, so a `COPY` of something missing
|
||||
/// here only fails for users who build locally. That went unnoticed for
|
||||
/// `skills`, `triple-c-open` and `triple-c-playwright-heal`. Every `COPY`
|
||||
/// source (not `--from=` stages) must be present as a file or directory
|
||||
/// prefix in the archive.
|
||||
#[test]
|
||||
fn every_dockerfile_copy_source_is_in_the_local_build_context() {
|
||||
let tar_bytes = create_build_context().expect("context builds");
|
||||
let mut archive = tar::Archive::new(&tar_bytes[..]);
|
||||
let paths: Vec<String> = archive
|
||||
.entries()
|
||||
.unwrap()
|
||||
.map(|e| e.unwrap().path().unwrap().to_string_lossy().into_owned())
|
||||
.collect();
|
||||
let mut missing = Vec::new();
|
||||
for line in DOCKERFILE.lines() {
|
||||
let line = line.trim();
|
||||
let Some(rest) = line.strip_prefix("COPY ") else { continue };
|
||||
let args: Vec<&str> = rest.split_whitespace().filter(|a| !a.starts_with("--")).collect();
|
||||
if rest.contains("--from=") || args.len() < 2 {
|
||||
continue;
|
||||
}
|
||||
for src in &args[..args.len() - 1] {
|
||||
let src = src.trim_end_matches('/');
|
||||
let present = paths.iter().any(|p| p == src || p.starts_with(&format!("{}/", src)));
|
||||
if !present {
|
||||
missing.push(src.to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(missing.is_empty(), "COPY sources missing from the local build context: {:?}", missing);
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(all(test, unix))]
|
||||
mod tests {
|
||||
use super::ENTRYPOINT;
|
||||
@@ -339,3 +432,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) ─────────────────────────────────────────────
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
pub mod ca_certs;
|
||||
pub mod client;
|
||||
pub mod events;
|
||||
pub mod container;
|
||||
pub mod image;
|
||||
pub mod exec;
|
||||
@@ -8,6 +9,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 {
|
||||
@@ -40,7 +40,10 @@ pub fn parse_poll_output(code: i64, stdout: &str) -> ViewerPoll {
|
||||
ViewerPoll { exists: true, hash, size }
|
||||
}
|
||||
|
||||
pub async fn poll_file(container_id: &str, container_path: &str) -> Result<ViewerPoll, String> {
|
||||
pub async fn poll_file(
|
||||
container: &ContainerTarget,
|
||||
container_path: &str,
|
||||
) -> Result<ViewerPoll, String> {
|
||||
let cmd = vec![
|
||||
"sh".to_string(),
|
||||
"-c".to_string(),
|
||||
@@ -48,8 +51,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) = container
|
||||
.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('/');
|
||||
@@ -90,13 +131,14 @@ pub fn parse_probe_output(stdout: &str) -> Vec<String> {
|
||||
}
|
||||
|
||||
pub async fn probe_candidates(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
candidates: &[String],
|
||||
) -> 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) = container
|
||||
.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;
|
||||
@@ -172,7 +172,7 @@ fn saved_file(outcome: WriteOutcome, bytes: &[u8]) -> Result<SavedFile, String>
|
||||
}
|
||||
|
||||
pub async fn write_file(
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
exec_manager: &ExecSessionManager,
|
||||
target: &str,
|
||||
bytes: &[u8],
|
||||
@@ -181,11 +181,12 @@ pub async fn write_file(
|
||||
check_write_input(bytes.len(), base_hash)?;
|
||||
let tmp_name = format!("triple-c-viewer-{}", uuid::Uuid::new_v4().simple());
|
||||
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,915 @@
|
||||
//! The app's side of `triple-c-agent`, the small server that runs inside a
|
||||
//! project container and does over HTTP what the Docker variant does through
|
||||
//! the Docker API.
|
||||
//!
|
||||
//! Wire protocol: `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`.
|
||||
//! Its "Client mapping" table is what this file implements, one function per
|
||||
//! [`ContainerTarget`](super::ContainerTarget) method. The contract is Docker
|
||||
//! parity: every function here returns exactly what its `docker/exec.rs`
|
||||
//! counterpart returns for the same command, including the shape of one-shot
|
||||
//! output (combined in arrival order, or stdout/stderr split), the
|
||||
//! `OUTPUT_LIMIT` refusal text, and the path an upload reports.
|
||||
//!
|
||||
//! The token never leaves this file except as the `Authorization` header:
|
||||
//! [`AgentEndpoint`]'s `Debug` redacts it and no error string is built from a
|
||||
//! request (reqwest and tungstenite errors name the URL, which carries no
|
||||
//! secret, never the headers).
|
||||
|
||||
use std::collections::{HashMap, VecDeque};
|
||||
use std::io;
|
||||
use std::pin::Pin;
|
||||
use std::sync::{Mutex, OnceLock};
|
||||
use std::task::{Context, Poll};
|
||||
use std::time::Duration;
|
||||
|
||||
use base64::Engine;
|
||||
use bytes::Bytes;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncWrite;
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::http::HeaderValue;
|
||||
use crate::host::OUTPUT_LIMIT_MARKER;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use tokio_util::sync::PollSender;
|
||||
|
||||
use super::{ArchiveStream, AttachedExec, ExecChunk, ExecSpec};
|
||||
use crate::docker::exec::{
|
||||
container_join, output_limit_error, parse_user_ids, read_host_file_for_upload, user_ids_cmd,
|
||||
OneshotOutput, EXEC_EXIT_POLLS, EXEC_EXIT_POLL_INTERVAL, HOST_FILE_UPLOAD_MODE,
|
||||
MAX_ONESHOT_OUTPUT, USER_IDS_OUTPUT_LIMIT,
|
||||
};
|
||||
|
||||
/// Connecting (TCP, and the WebSocket handshake) must not hang a caller on an
|
||||
/// agent that is not there. Nothing else has an overall timeout: a one-shot
|
||||
/// exec, an attach or an archive download can legitimately run for minutes.
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(5);
|
||||
/// `is_running` is a liveness probe; it answers quickly or says "no".
|
||||
const HEALTH_TIMEOUT: Duration = Duration::from_secs(3);
|
||||
/// Whole-request ceiling for the small calls (detached start, exec status,
|
||||
/// resize) and for reading any small body. The agent is not trusted: one that
|
||||
/// accepts a connection and then says nothing must not hang the app. One-shot
|
||||
/// execs, attach and archive have *no* overall timeout — commands can
|
||||
/// legitimately run long and send nothing until they finish, as with Docker.
|
||||
#[cfg(not(test))]
|
||||
const SMALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(15);
|
||||
/// Short in tests, so the hostile-agent tests finish quickly.
|
||||
#[cfg(test)]
|
||||
const SMALL_REQUEST_TIMEOUT: Duration = Duration::from_secs(1);
|
||||
/// Largest WebSocket message either side may send; the agent enforces the same
|
||||
/// (its output frames are at most 32 KiB). Stdin is split to fit.
|
||||
const WS_MAX_MESSAGE: usize = 1024 * 1024;
|
||||
const STDIN_FRAME_MAX: usize = 64 * 1024;
|
||||
/// How long an attach waits for the agent's `started` reply after the
|
||||
/// handshake. Spawning a process is fast; this only bounds a wedged agent.
|
||||
const ATTACH_START_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
/// In-flight stdin frames per attach before `poll_write` applies backpressure.
|
||||
const STDIN_QUEUE: usize = 64;
|
||||
|
||||
/// Where an agent listens and the bearer token it wants.
|
||||
///
|
||||
/// `base_url` is `http://127.0.0.1:<port>/`: the local end of an SSM
|
||||
/// port-forward on AWS, or a published loopback port in the local harness.
|
||||
#[derive(Clone, PartialEq, Eq, Hash)]
|
||||
pub struct AgentEndpoint {
|
||||
pub base_url: url::Url,
|
||||
token: String,
|
||||
}
|
||||
|
||||
impl std::fmt::Debug for AgentEndpoint {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
f.debug_struct("AgentEndpoint")
|
||||
.field("base_url", &self.base_url.as_str())
|
||||
.field("token", &"<redacted>")
|
||||
.finish()
|
||||
}
|
||||
}
|
||||
|
||||
impl AgentEndpoint {
|
||||
/// An endpoint at `base_url`, which must be a plain `http://` URL with a
|
||||
/// host. A trailing `/` is added so endpoint paths join beneath it.
|
||||
pub fn new(mut base_url: url::Url, token: impl Into<String>) -> Result<Self, String> {
|
||||
if base_url.scheme() != "http" || base_url.host().is_none() {
|
||||
return Err(format!(
|
||||
"An agent endpoint must be an http:// URL with a host, not {}",
|
||||
base_url
|
||||
));
|
||||
}
|
||||
if !base_url.path().ends_with('/') {
|
||||
let path = format!("{}/", base_url.path());
|
||||
base_url.set_path(&path);
|
||||
}
|
||||
base_url.set_query(None);
|
||||
base_url.set_fragment(None);
|
||||
let token = token.into();
|
||||
// It goes in an HTTP header; refuse anything that cannot, here, once,
|
||||
// rather than failing (or silently degrading) on every request.
|
||||
if token.is_empty() || !token.bytes().all(|b| b.is_ascii_graphic()) {
|
||||
return Err("An agent token must be non-empty printable ASCII with no spaces".to_string());
|
||||
}
|
||||
Ok(Self {
|
||||
base_url,
|
||||
token,
|
||||
})
|
||||
}
|
||||
|
||||
/// The agent on `127.0.0.1:<port>`.
|
||||
pub fn loopback(port: u16, token: impl Into<String>) -> Self {
|
||||
let url = url::Url::parse(&format!("http://127.0.0.1:{}/", port))
|
||||
.expect("a loopback URL always parses");
|
||||
Self::new(url, token).expect("a loopback URL is a valid endpoint")
|
||||
}
|
||||
|
||||
fn url(&self, path: &str) -> url::Url {
|
||||
self.base_url
|
||||
.join(path)
|
||||
.expect("endpoint paths are fixed relative paths")
|
||||
}
|
||||
|
||||
fn ws_url(&self, path: &str) -> url::Url {
|
||||
let mut u = self.url(path);
|
||||
u.set_scheme("ws").expect("http -> ws is a valid scheme change");
|
||||
u
|
||||
}
|
||||
|
||||
fn bearer(&self) -> String {
|
||||
format!("Bearer {}", self.token)
|
||||
}
|
||||
|
||||
/// The `Authorization` header, marked sensitive so it is never logged or
|
||||
/// shown by `Debug` (reqwest/hyper honour the flag).
|
||||
fn auth(&self) -> HeaderValue {
|
||||
let mut v = HeaderValue::from_str(&self.bearer())
|
||||
.unwrap_or_else(|_| HeaderValue::from_static("Bearer invalid-token"));
|
||||
v.set_sensitive(true);
|
||||
v
|
||||
}
|
||||
}
|
||||
|
||||
// ── HTTP plumbing ────────────────────────────────────────────────────────────
|
||||
|
||||
/// One client for every agent: connection pooling is per host, and building a
|
||||
/// client per call would redo TLS-less setup for nothing. No proxy — the agent
|
||||
/// is always on loopback, and a corporate `HTTP_PROXY` must not capture it.
|
||||
fn http() -> &'static reqwest::Client {
|
||||
static CLIENT: OnceLock<reqwest::Client> = OnceLock::new();
|
||||
CLIENT.get_or_init(|| {
|
||||
reqwest::Client::builder()
|
||||
.connect_timeout(CONNECT_TIMEOUT)
|
||||
.no_proxy()
|
||||
// The agent lives inside the container, which is not trusted with
|
||||
// the host: a redirect would let it point this client — running in
|
||||
// the host app — at any other URL. A 3xx is an error (it fails the
|
||||
// `is_success` checks like any other non-2xx).
|
||||
.redirect(reqwest::redirect::Policy::none())
|
||||
.build()
|
||||
.expect("a reqwest client with no TLS config always builds")
|
||||
})
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ErrorBody {
|
||||
error: String,
|
||||
}
|
||||
|
||||
/// Ceiling on any small JSON body from the agent (errors, exec ids, exec
|
||||
/// status). The agent is inside the container, so a body it sends must not be
|
||||
/// able to grow host memory without bound; real ones are tens of bytes.
|
||||
const SMALL_BODY_CAP: usize = 64 * 1024;
|
||||
|
||||
/// Ceiling on error text passed through to the UI.
|
||||
const ERROR_TEXT_CAP: usize = 4 * 1024;
|
||||
|
||||
/// Parse a small JSON body, reading at most [`SMALL_BODY_CAP`] bytes.
|
||||
async fn small_json<T: serde::de::DeserializeOwned>(resp: reqwest::Response) -> Option<T> {
|
||||
let raw = tokio::time::timeout(SMALL_REQUEST_TIMEOUT, read_capped(resp, SMALL_BODY_CAP))
|
||||
.await
|
||||
.ok()?
|
||||
.ok()??;
|
||||
serde_json::from_slice(&raw).ok()
|
||||
}
|
||||
|
||||
/// The agent's `{"error": …}` text, if the body has one — bounded in both
|
||||
/// bytes read and length returned.
|
||||
async fn error_text(resp: reqwest::Response) -> Option<String> {
|
||||
let mut e = small_json::<ErrorBody>(resp).await?.error;
|
||||
if e.len() > ERROR_TEXT_CAP {
|
||||
let mut cut = ERROR_TEXT_CAP;
|
||||
while !e.is_char_boundary(cut) {
|
||||
cut -= 1;
|
||||
}
|
||||
e.truncate(cut);
|
||||
e.push('…');
|
||||
}
|
||||
Some(e)
|
||||
}
|
||||
|
||||
/// `"<context>: <why>"` for a non-success response.
|
||||
async fn status_error(context: &str, resp: reqwest::Response) -> String {
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::UNAUTHORIZED {
|
||||
return format!("{}: the agent rejected the credentials (401)", context);
|
||||
}
|
||||
match error_text(resp).await {
|
||||
Some(e) => format!("{}: {}", context, e),
|
||||
None => format!("{}: agent returned HTTP {}", context, status.as_u16()),
|
||||
}
|
||||
}
|
||||
|
||||
async fn send(
|
||||
context: &str,
|
||||
req: reqwest::RequestBuilder,
|
||||
) -> Result<reqwest::Response, String> {
|
||||
req.send().await.map_err(|e| format!("{}: {}", context, e))
|
||||
}
|
||||
|
||||
/// Read a response body into memory, refusing past `cap` bytes.
|
||||
async fn read_capped(mut resp: reqwest::Response, cap: usize) -> Result<Option<Vec<u8>>, String> {
|
||||
let mut body = Vec::new();
|
||||
while let Some(chunk) = resp
|
||||
.chunk()
|
||||
.await
|
||||
.map_err(|e| format!("Exec output error: {}", e))?
|
||||
{
|
||||
if body.len() + chunk.len() > cap {
|
||||
return Ok(None);
|
||||
}
|
||||
body.extend_from_slice(&chunk);
|
||||
}
|
||||
Ok(Some(body))
|
||||
}
|
||||
|
||||
/// ExecSpec as the protocol spells it.
|
||||
fn spec_json(spec: &ExecSpec) -> serde_json::Map<String, serde_json::Value> {
|
||||
let v = serde_json::json!({
|
||||
"cmd": spec.cmd,
|
||||
"user": spec.user,
|
||||
"working_dir": spec.working_dir,
|
||||
"env": spec.env,
|
||||
"tty": spec.tty,
|
||||
});
|
||||
match v {
|
||||
serde_json::Value::Object(m) => m,
|
||||
_ => unreachable!("json! of an object literal is an object"),
|
||||
}
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OneshotChunk {
|
||||
s: String,
|
||||
d: String,
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct OneshotResponse {
|
||||
chunks: Vec<OneshotChunk>,
|
||||
exit_code: i64,
|
||||
}
|
||||
|
||||
/// `POST /v1/exec`, reassembled into the same [`OneshotOutput`] the Docker
|
||||
/// path builds. Like Docker's one-shot, there is no tty, stdin is empty, and
|
||||
/// the working directory is the container's default.
|
||||
async fn exec_oneshot_raw(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<OneshotOutput, String> {
|
||||
const CONTEXT: &str = "Failed to start exec";
|
||||
let spec = ExecSpec::new(cmd).user(user).working_dir(None).env(env).tty(false);
|
||||
let mut body = spec_json(&spec);
|
||||
body.insert("limit".into(), serde_json::json!(limit));
|
||||
|
||||
let resp = send(
|
||||
CONTEXT,
|
||||
http()
|
||||
.post(ep.url("v1/exec"))
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.json(&body),
|
||||
)
|
||||
.await?;
|
||||
|
||||
let status = resp.status();
|
||||
if status == reqwest::StatusCode::PAYLOAD_TOO_LARGE {
|
||||
// Verbatim when it is the limit message (callers match on
|
||||
// OUTPUT_LIMIT_MARKER); anything else the agent puts in a 413 is not
|
||||
// trusted to speak for it.
|
||||
return Err(error_text(resp)
|
||||
.await
|
||||
.filter(|e| e.starts_with(OUTPUT_LIMIT_MARKER))
|
||||
.unwrap_or_else(|| output_limit_error(limit)));
|
||||
}
|
||||
if !status.is_success() {
|
||||
return Err(status_error(CONTEXT, resp).await);
|
||||
}
|
||||
|
||||
// The agent enforces `limit`, but this is container-controlled input
|
||||
// being buffered into the host, so the client bounds it too: base64 is
|
||||
// 4/3 of the payload and each chunk carries a few bytes of JSON, so a
|
||||
// body past this is over the limit however it was chunked.
|
||||
let cap = limit.saturating_mul(8).saturating_add(64 * 1024);
|
||||
let raw = read_capped(resp, cap)
|
||||
.await?
|
||||
.ok_or_else(|| output_limit_error(limit))?;
|
||||
let parsed: OneshotResponse = serde_json::from_slice(&raw)
|
||||
.map_err(|e| format!("Exec output error: malformed agent response: {}", e))?;
|
||||
|
||||
let mut out = OneshotOutput::new();
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
for chunk in parsed.chunks {
|
||||
let from_stdout = match chunk.s.as_str() {
|
||||
"out" => true,
|
||||
"err" => false,
|
||||
other => {
|
||||
return Err(format!(
|
||||
"Exec output error: unknown stream {:?} in agent response",
|
||||
other
|
||||
))
|
||||
}
|
||||
};
|
||||
let bytes = b64
|
||||
.decode(chunk.d.as_bytes())
|
||||
.map_err(|e| format!("Exec output error: bad base64 from agent: {}", e))?;
|
||||
out.push(from_stdout, &bytes, limit)?;
|
||||
}
|
||||
out.exit_code = parsed.exit_code;
|
||||
Ok(out)
|
||||
}
|
||||
|
||||
/// Combined output and exit code under `limit` — `exec_oneshot_limited` and,
|
||||
/// with [`MAX_ONESHOT_OUTPUT`], every other combined-output variant.
|
||||
pub async fn exec_oneshot_limited(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
limit: usize,
|
||||
) -> Result<(String, i64), String> {
|
||||
let out = exec_oneshot_raw(ep, user, cmd, env, limit).await?;
|
||||
Ok((out.text(), out.exit_code))
|
||||
}
|
||||
|
||||
/// `exec_oneshot_as`: combined output under [`MAX_ONESHOT_OUTPUT`].
|
||||
pub async fn exec_oneshot_as(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, i64), String> {
|
||||
exec_oneshot_limited(ep, user, cmd, env, MAX_ONESHOT_OUTPUT).await
|
||||
}
|
||||
|
||||
/// `exec_oneshot_streams_as`: `(stdout, stderr, exit_code)`.
|
||||
pub async fn exec_oneshot_streams_as(
|
||||
ep: &AgentEndpoint,
|
||||
user: &str,
|
||||
cmd: Vec<String>,
|
||||
env: Vec<String>,
|
||||
) -> Result<(String, String, i64), String> {
|
||||
let out = exec_oneshot_raw(ep, user, cmd, env, MAX_ONESHOT_OUTPUT).await?;
|
||||
Ok((out.stdout(), out.stderr(), out.exit_code))
|
||||
}
|
||||
|
||||
// ── detached execs, status, resize ───────────────────────────────────────────
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExecIdResponse {
|
||||
exec_id: String,
|
||||
}
|
||||
|
||||
pub async fn exec_detached(ep: &AgentEndpoint, spec: &ExecSpec) -> Result<String, String> {
|
||||
const CONTEXT: &str = "Failed to start exec";
|
||||
let resp = send(
|
||||
CONTEXT,
|
||||
http()
|
||||
.post(ep.url("v1/exec/detached"))
|
||||
.timeout(SMALL_REQUEST_TIMEOUT)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.json(&spec_json(spec)),
|
||||
)
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(status_error(CONTEXT, resp).await);
|
||||
}
|
||||
small_json::<ExecIdResponse>(resp)
|
||||
.await
|
||||
.map(|r| r.exec_id)
|
||||
.ok_or_else(|| format!("{}: malformed agent response", CONTEXT))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ExecStatus {
|
||||
running: bool,
|
||||
exit_code: Option<i64>,
|
||||
}
|
||||
|
||||
/// Exit codes attach streams saw in their `exit` frame, so `wait_exec_exit`
|
||||
/// can answer without a round trip. Bounded: a long-running app opens many
|
||||
/// sessions, and the agent keeps the same answer for ten minutes anyway.
|
||||
struct ExitBook {
|
||||
codes: HashMap<(String, String), i64>,
|
||||
order: VecDeque<(String, String)>,
|
||||
}
|
||||
|
||||
const EXIT_BOOK_CAP: usize = 256;
|
||||
|
||||
fn exit_book() -> &'static Mutex<ExitBook> {
|
||||
static BOOK: OnceLock<Mutex<ExitBook>> = OnceLock::new();
|
||||
BOOK.get_or_init(|| {
|
||||
Mutex::new(ExitBook {
|
||||
codes: HashMap::new(),
|
||||
order: VecDeque::new(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn record_exit(base_url: &str, exec_id: &str, code: i64) {
|
||||
let key = (base_url.to_string(), exec_id.to_string());
|
||||
let mut book = exit_book().lock().unwrap_or_else(|p| p.into_inner());
|
||||
if book.codes.insert(key.clone(), code).is_none() {
|
||||
book.order.push_back(key);
|
||||
while book.order.len() > EXIT_BOOK_CAP {
|
||||
if let Some(old) = book.order.pop_front() {
|
||||
book.codes.remove(&old);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn recorded_exit(base_url: &str, exec_id: &str) -> Option<i64> {
|
||||
let book = exit_book().lock().unwrap_or_else(|p| p.into_inner());
|
||||
book.codes
|
||||
.get(&(base_url.to_string(), exec_id.to_string()))
|
||||
.copied()
|
||||
}
|
||||
|
||||
/// Poll `GET /v1/exec/{id}` with Docker's budget. `None` on any error, an
|
||||
/// unknown id, a finished exec with no code, or the budget running out —
|
||||
/// exactly where `docker::exec::wait_for_exec_exit` says `None`.
|
||||
pub async fn wait_exec_exit(ep: &AgentEndpoint, exec_id: &str) -> Option<i64> {
|
||||
// Docker's budget is a number of polls; bound the wall clock too, so a
|
||||
// slow-answering agent cannot stretch it (each poll is also bounded).
|
||||
let budget = EXEC_EXIT_POLL_INTERVAL * EXEC_EXIT_POLLS as u32 + SMALL_REQUEST_TIMEOUT;
|
||||
tokio::time::timeout(budget, wait_exec_exit_polls(ep, exec_id)).await.ok()?
|
||||
}
|
||||
|
||||
async fn wait_exec_exit_polls(ep: &AgentEndpoint, exec_id: &str) -> Option<i64> {
|
||||
if let Some(code) = recorded_exit(ep.base_url.as_str(), exec_id) {
|
||||
return Some(code);
|
||||
}
|
||||
let url = ep.url(&format!("v1/exec/{}", urlencode_segment(exec_id)));
|
||||
for _ in 0..EXEC_EXIT_POLLS {
|
||||
if let Some(code) = recorded_exit(ep.base_url.as_str(), exec_id) {
|
||||
return Some(code);
|
||||
}
|
||||
let resp = http()
|
||||
.get(url.clone())
|
||||
.timeout(SMALL_REQUEST_TIMEOUT)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.send()
|
||||
.await
|
||||
.ok()?;
|
||||
if !resp.status().is_success() {
|
||||
return None;
|
||||
}
|
||||
let status: ExecStatus = small_json(resp).await?;
|
||||
if !status.running {
|
||||
return status.exit_code;
|
||||
}
|
||||
tokio::time::sleep(EXEC_EXIT_POLL_INTERVAL).await;
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
pub async fn resize_exec(ep: &AgentEndpoint, exec_id: &str, cols: u16, rows: u16) -> Result<(), String> {
|
||||
const CONTEXT: &str = "Failed to resize exec";
|
||||
let resp = send(
|
||||
CONTEXT,
|
||||
http()
|
||||
.post(ep.url(&format!("v1/exec/{}/resize", urlencode_segment(exec_id))))
|
||||
.timeout(SMALL_REQUEST_TIMEOUT)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.json(&serde_json::json!({ "cols": cols, "rows": rows })),
|
||||
)
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(status_error(CONTEXT, resp).await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Exec ids come from the agent; escape them anyway so one can never turn
|
||||
/// into a different path.
|
||||
fn urlencode_segment(s: &str) -> String {
|
||||
url::form_urlencoded::byte_serialize(s.as_bytes()).collect()
|
||||
}
|
||||
|
||||
// ── attach ───────────────────────────────────────────────────────────────────
|
||||
|
||||
type Ws = tokio_tungstenite::WebSocketStream<
|
||||
tokio_tungstenite::MaybeTlsStream<tokio::net::TcpStream>,
|
||||
>;
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct ControlFrame {
|
||||
#[serde(rename = "type")]
|
||||
kind: String,
|
||||
exec_id: Option<String>,
|
||||
code: Option<i64>,
|
||||
error: Option<String>,
|
||||
}
|
||||
|
||||
/// What the stdin writer hands the socket task.
|
||||
enum StdinCmd {
|
||||
Data(Bytes),
|
||||
Close,
|
||||
}
|
||||
|
||||
/// Open `GET /v1/attach` and authenticate. Shared by attach (and, later,
|
||||
/// tunnels).
|
||||
async fn connect_ws(ep: &AgentEndpoint, path: &str, context: &str) -> Result<Ws, String> {
|
||||
let mut req = ep
|
||||
.ws_url(path)
|
||||
.as_str()
|
||||
.into_client_request()
|
||||
.map_err(|e| format!("{}: {}", context, e))?;
|
||||
let auth = ep.auth();
|
||||
req.headers_mut()
|
||||
.insert(tokio_tungstenite::tungstenite::http::header::AUTHORIZATION, auth);
|
||||
let config = tokio_tungstenite::tungstenite::protocol::WebSocketConfig::default()
|
||||
.max_message_size(Some(WS_MAX_MESSAGE))
|
||||
.max_frame_size(Some(WS_MAX_MESSAGE));
|
||||
match tokio::time::timeout(
|
||||
CONNECT_TIMEOUT,
|
||||
tokio_tungstenite::connect_async_with_config(req, Some(config), false),
|
||||
)
|
||||
.await
|
||||
{
|
||||
Err(_) => Err(format!("{}: timed out connecting to the agent", context)),
|
||||
Ok(Err(tokio_tungstenite::tungstenite::Error::Http(resp)))
|
||||
if resp.status() == tokio_tungstenite::tungstenite::http::StatusCode::UNAUTHORIZED =>
|
||||
{
|
||||
Err(format!("{}: the agent rejected the credentials (401)", context))
|
||||
}
|
||||
Ok(Err(e)) => Err(format!("{}: {}", context, e)),
|
||||
Ok(Ok((ws, _))) => Ok(ws),
|
||||
}
|
||||
}
|
||||
|
||||
/// `GET /v1/attach`: the same [`AttachedExec`] Docker hands back.
|
||||
///
|
||||
/// Output: channel 1 → [`ExecChunk::StdOut`], 2 → [`ExecChunk::StdErr`]; the
|
||||
/// `exit` frame ends the stream and is remembered for [`wait_exec_exit`].
|
||||
/// Input: each write is one binary frame; `shutdown` sends `close_stdin`.
|
||||
/// Dropping both halves closes the socket.
|
||||
pub async fn attach(ep: &AgentEndpoint, spec: &ExecSpec) -> Result<AttachedExec, String> {
|
||||
const CONTEXT: &str = "Failed to start exec";
|
||||
let mut ws = connect_ws(ep, "v1/attach", CONTEXT).await?;
|
||||
|
||||
let mut hello = spec_json(spec);
|
||||
hello.insert("attach_stdin".into(), serde_json::json!(spec.attach_stdin));
|
||||
let hello = serde_json::Value::Object(hello).to_string();
|
||||
ws.send(Message::text(hello))
|
||||
.await
|
||||
.map_err(|e| format!("{}: {}", CONTEXT, e))?;
|
||||
|
||||
let exec_id = match tokio::time::timeout(ATTACH_START_TIMEOUT, wait_started(&mut ws)).await {
|
||||
Err(_) => return Err(format!("{}: the agent did not start the command", CONTEXT)),
|
||||
Ok(r) => r.map_err(|e| format!("{}: {}", CONTEXT, e))?,
|
||||
};
|
||||
|
||||
let (mut sink, stream) = ws.split();
|
||||
|
||||
// stdin: a bounded queue drained by one task that owns the socket's write
|
||||
// half. The task ends — dropping the write half — when the writer is
|
||||
// dropped or the socket fails.
|
||||
let (tx, mut rx) = tokio::sync::mpsc::channel::<StdinCmd>(STDIN_QUEUE);
|
||||
tokio::spawn(async move {
|
||||
while let Some(cmd) = rx.recv().await {
|
||||
let msg = match cmd {
|
||||
StdinCmd::Data(b) => Message::binary(b),
|
||||
StdinCmd::Close => Message::text(r#"{"type":"close_stdin"}"#),
|
||||
};
|
||||
if sink.send(msg).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
let base_url = ep.base_url.as_str().to_string();
|
||||
let id_for_stream = exec_id.clone();
|
||||
let output = futures_util::stream::unfold(Some(stream), move |state| {
|
||||
let base_url = base_url.clone();
|
||||
let exec_id = id_for_stream.clone();
|
||||
async move {
|
||||
let mut stream = state?;
|
||||
loop {
|
||||
match stream.next().await? {
|
||||
Err(e) => return Some((Err(format!("Exec output error: {}", e)), None)),
|
||||
Ok(Message::Binary(b)) => {
|
||||
if b.is_empty() {
|
||||
continue;
|
||||
}
|
||||
let payload = b.slice(1..);
|
||||
let chunk = match b[0] {
|
||||
1 => ExecChunk::StdOut(payload),
|
||||
2 => ExecChunk::StdErr(payload),
|
||||
_ => ExecChunk::Other(payload),
|
||||
};
|
||||
return Some((Ok(chunk), Some(stream)));
|
||||
}
|
||||
Ok(Message::Text(t)) => {
|
||||
let frame: ControlFrame = match serde_json::from_str(t.as_str()) {
|
||||
Ok(f) => f,
|
||||
Err(_) => continue,
|
||||
};
|
||||
match frame.kind.as_str() {
|
||||
"exit" => {
|
||||
if let Some(code) = frame.code {
|
||||
record_exit(&base_url, &exec_id, code);
|
||||
}
|
||||
return None;
|
||||
}
|
||||
"error" => {
|
||||
let e = frame.error.unwrap_or_else(|| "agent error".into());
|
||||
return Some((Err(format!("Exec output error: {}", e)), None));
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Ok(Message::Close(_)) => return None,
|
||||
Ok(_) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
Ok(AttachedExec {
|
||||
exec_id,
|
||||
output: Box::pin(output),
|
||||
input: Box::pin(AttachInput {
|
||||
tx: PollSender::new(tx),
|
||||
close_sent: false,
|
||||
}),
|
||||
})
|
||||
}
|
||||
|
||||
/// Read until the agent says `started` (its exec id) or `error`.
|
||||
async fn wait_started(ws: &mut Ws) -> Result<String, String> {
|
||||
while let Some(msg) = ws.next().await {
|
||||
match msg.map_err(|e| e.to_string())? {
|
||||
Message::Text(t) => {
|
||||
let frame: ControlFrame = serde_json::from_str(t.as_str())
|
||||
.map_err(|e| format!("malformed agent frame: {}", e))?;
|
||||
match frame.kind.as_str() {
|
||||
"started" => {
|
||||
return frame
|
||||
.exec_id
|
||||
.ok_or_else(|| "agent sent no exec id".to_string())
|
||||
}
|
||||
"error" => {
|
||||
return Err(frame.error.unwrap_or_else(|| "agent error".into()))
|
||||
}
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Message::Close(_) => break,
|
||||
_ => continue,
|
||||
}
|
||||
}
|
||||
Err("the agent closed the connection before starting the command".to_string())
|
||||
}
|
||||
|
||||
/// An attach's stdin.
|
||||
struct AttachInput {
|
||||
tx: PollSender<StdinCmd>,
|
||||
close_sent: bool,
|
||||
}
|
||||
|
||||
fn broken_pipe() -> io::Error {
|
||||
io::Error::new(io::ErrorKind::BrokenPipe, "agent connection closed")
|
||||
}
|
||||
|
||||
impl AsyncWrite for AttachInput {
|
||||
fn poll_write(
|
||||
self: Pin<&mut Self>,
|
||||
cx: &mut Context<'_>,
|
||||
buf: &[u8],
|
||||
) -> Poll<io::Result<usize>> {
|
||||
let this = self.get_mut();
|
||||
if buf.is_empty() {
|
||||
return Poll::Ready(Ok(0));
|
||||
}
|
||||
if this.close_sent {
|
||||
return Poll::Ready(Err(broken_pipe()));
|
||||
}
|
||||
match this.tx.poll_reserve(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Err(_)) => Poll::Ready(Err(broken_pipe())),
|
||||
Poll::Ready(Ok(())) => {
|
||||
// One frame per write, at most STDIN_FRAME_MAX; `write_all`
|
||||
// loops for the rest. Keeps every frame under the agent's
|
||||
// WebSocket message limit.
|
||||
let n = buf.len().min(STDIN_FRAME_MAX);
|
||||
this.tx
|
||||
.send_item(StdinCmd::Data(Bytes::copy_from_slice(&buf[..n])))
|
||||
.map_err(|_| broken_pipe())?;
|
||||
Poll::Ready(Ok(n))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Frames are flushed by the socket task as it sends them.
|
||||
fn poll_flush(self: Pin<&mut Self>, _cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
|
||||
/// EOF on the child's stdin: `{"type":"close_stdin"}`, once.
|
||||
fn poll_shutdown(self: Pin<&mut Self>, cx: &mut Context<'_>) -> Poll<io::Result<()>> {
|
||||
let this = self.get_mut();
|
||||
if this.close_sent {
|
||||
return Poll::Ready(Ok(()));
|
||||
}
|
||||
match this.tx.poll_reserve(cx) {
|
||||
Poll::Pending => Poll::Pending,
|
||||
Poll::Ready(Err(_)) => Poll::Ready(Err(broken_pipe())),
|
||||
Poll::Ready(Ok(())) => {
|
||||
this.tx.send_item(StdinCmd::Close).map_err(|_| broken_pipe())?;
|
||||
this.close_sent = true;
|
||||
Poll::Ready(Ok(()))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `(uid, gid)` of `claude`, falling back to `(1000, 1000)` — the same probe
|
||||
/// and parse as Docker's `container_user_ids`.
|
||||
pub async fn user_ids(ep: &AgentEndpoint) -> (u64, u64) {
|
||||
let (out, _) = exec_oneshot_limited(ep, "claude", user_ids_cmd(), Vec::new(), USER_IDS_OUTPUT_LIMIT)
|
||||
.await
|
||||
.unwrap_or_default();
|
||||
parse_user_ids(&out)
|
||||
}
|
||||
|
||||
/// Split Docker's tar-entry `name` (which may carry a relative directory, as
|
||||
/// the terminal drop's `triple-c-drops/<file>` does) into the `dir` and
|
||||
/// single-component `name` `PUT /v1/files` takes. Docker's extractor would
|
||||
/// put the file at the same place.
|
||||
fn split_upload_target(dest_dir: &str, file_name: &str) -> Result<(String, String), String> {
|
||||
let parts: Vec<&str> = file_name.split('/').filter(|p| !p.is_empty()).collect();
|
||||
let Some((name, parents)) = parts.split_last() else {
|
||||
return Err(format!("Failed to upload file to container: empty file name {:?}", file_name));
|
||||
};
|
||||
if parts.iter().any(|p| *p == "." || *p == "..") {
|
||||
return Err(format!(
|
||||
"Failed to upload file to container: {:?} is not a plain relative name",
|
||||
file_name
|
||||
));
|
||||
}
|
||||
let mut dir = dest_dir.trim_end_matches('/').to_string();
|
||||
for p in parents {
|
||||
dir.push('/');
|
||||
dir.push_str(p);
|
||||
}
|
||||
if dir.is_empty() {
|
||||
dir.push('/');
|
||||
}
|
||||
Ok((dir, name.to_string()))
|
||||
}
|
||||
|
||||
async fn put_file(
|
||||
ep: &AgentEndpoint,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: Vec<u8>,
|
||||
mode: u32,
|
||||
(uid, gid): (u64, u64),
|
||||
) -> Result<(), String> {
|
||||
const CONTEXT: &str = "Failed to upload file to container";
|
||||
let (dir, name) = split_upload_target(dest_dir, file_name)?;
|
||||
let mut url = ep.url("v1/files");
|
||||
url.query_pairs_mut()
|
||||
.append_pair("dir", &dir)
|
||||
.append_pair("name", &name)
|
||||
.append_pair("mode", &format!("{:o}", mode))
|
||||
.append_pair("uid", &uid.to_string())
|
||||
.append_pair("gid", &gid.to_string());
|
||||
// Generous, but finite: a minute plus 256 KiB/s for the payload.
|
||||
let timeout = Duration::from_secs(60 + (data.len() as u64) / (256 * 1024));
|
||||
let resp = send(
|
||||
CONTEXT,
|
||||
http()
|
||||
.put(url)
|
||||
.timeout(timeout)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.header(reqwest::header::CONTENT_TYPE, "application/octet-stream")
|
||||
.body(data),
|
||||
)
|
||||
.await?;
|
||||
if !resp.status().is_success() {
|
||||
return Err(status_error(CONTEXT, resp).await);
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Docker's `upload_bytes_with_ids`: same reported path.
|
||||
pub async fn upload_bytes_with_ids(
|
||||
ep: &AgentEndpoint,
|
||||
dest_dir: &str,
|
||||
file_name: &str,
|
||||
data: &[u8],
|
||||
mode: u32,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
put_file(ep, dest_dir, file_name, data.to_vec(), mode, ids).await?;
|
||||
Ok(format!("{}/{}", dest_dir.trim_end_matches('/'), file_name))
|
||||
}
|
||||
|
||||
/// Docker's `upload_host_file_with_ids`: the host file is read (and checked)
|
||||
/// by the same function, mode 0644, same reported path.
|
||||
pub async fn upload_host_file_with_ids(
|
||||
ep: &AgentEndpoint,
|
||||
host_path: &str,
|
||||
dest_dir: &str,
|
||||
dest_name: &str,
|
||||
ids: (u64, u64),
|
||||
) -> Result<String, String> {
|
||||
let path = host_path.to_string();
|
||||
let data = tokio::task::spawn_blocking(move || read_host_file_for_upload(&path))
|
||||
.await
|
||||
.map_err(|e| format!("Upload task panicked: {}", e))??;
|
||||
put_file(ep, dest_dir, dest_name, data, HOST_FILE_UPLOAD_MODE, ids).await?;
|
||||
Ok(container_join(dest_dir, dest_name))
|
||||
}
|
||||
|
||||
/// `GET /v1/archive`, streamed. Dropping the stream drops the response,
|
||||
/// which closes the connection and cancels the read on the agent.
|
||||
pub fn download_archive(ep: &AgentEndpoint, path: &str) -> ArchiveStream {
|
||||
const CONTEXT: &str = "Failed to download archive";
|
||||
let mut url = ep.url("v1/archive");
|
||||
url.query_pairs_mut().append_pair("path", path);
|
||||
let req = http()
|
||||
.get(url)
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth());
|
||||
|
||||
enum State {
|
||||
Start(reqwest::RequestBuilder),
|
||||
Body(reqwest::Response),
|
||||
}
|
||||
Box::pin(futures_util::stream::unfold(
|
||||
Some(State::Start(req)),
|
||||
|state| async move {
|
||||
let mut resp = match state? {
|
||||
State::Start(req) => match req.send().await {
|
||||
Err(e) => return Some((Err(format!("{}: {}", CONTEXT, e)), None)),
|
||||
Ok(r) if !r.status().is_success() => {
|
||||
return Some((Err(status_error(CONTEXT, r).await), None))
|
||||
}
|
||||
Ok(r) => r,
|
||||
},
|
||||
State::Body(r) => r,
|
||||
};
|
||||
loop {
|
||||
match resp.chunk().await {
|
||||
Err(e) => return Some((Err(format!("{}: {}", CONTEXT, e)), None)),
|
||||
Ok(None) => return None,
|
||||
Ok(Some(b)) if b.is_empty() => continue,
|
||||
Ok(Some(b)) => return Some((Ok(b), Some(State::Body(resp)))),
|
||||
}
|
||||
}
|
||||
},
|
||||
))
|
||||
}
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────────────────
|
||||
|
||||
/// `GET /v1/health` answers → `true`. Can't connect (or times out) → `false`,
|
||||
/// the same "not running" an inspect failure gives on Docker. An agent that
|
||||
/// answers with an error status is reachable but unusable, which is an
|
||||
/// `Err` rather than a quiet `false`.
|
||||
pub async fn is_running(ep: &AgentEndpoint) -> Result<bool, String> {
|
||||
let resp = match http()
|
||||
.get(ep.url("v1/health"))
|
||||
.header(reqwest::header::AUTHORIZATION, ep.auth())
|
||||
.timeout(HEALTH_TIMEOUT)
|
||||
.send()
|
||||
.await
|
||||
{
|
||||
Ok(r) => r,
|
||||
Err(_) => return Ok(false),
|
||||
};
|
||||
if !resp.status().is_success() {
|
||||
return Err(status_error("Agent health check failed", resp).await);
|
||||
}
|
||||
Ok(true)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests;
|
||||
#[cfg(test)]
|
||||
mod live_tests;
|
||||
@@ -0,0 +1,265 @@
|
||||
//! The real agent against `docker exec`, in one real container: every
|
||||
//! operation goes through `ContainerTarget::Docker` and
|
||||
//! `ContainerTarget::Agent` on the *same* container and must come back the
|
||||
//! same. That is the agent protocol's whole requirement ("Docker parity is the
|
||||
//! requirement"), checked end to end rather than against a fake.
|
||||
//!
|
||||
//! Needs a Docker daemon and an image whose entrypoint starts the agent:
|
||||
//! `TRIPLEC_AGENT_E2E_IMAGE`, default `tc-agent-e2e:latest` (the sandbox image
|
||||
//! plus this branch's `entrypoint.sh` and `triple-c-agent`).
|
||||
|
||||
use super::*;
|
||||
use crate::host::{ContainerTarget, ExecChunk, ExecSpec, OUTPUT_LIMIT_MARKER};
|
||||
use futures_util::StreamExt;
|
||||
use std::process::Command;
|
||||
use std::time::Duration;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
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()
|
||||
}
|
||||
|
||||
fn sh(script: &str) -> Vec<String> {
|
||||
vec!["sh".into(), "-c".into(), script.into()]
|
||||
}
|
||||
|
||||
/// Drain an attached exec: (stdout, stderr).
|
||||
async fn drain(exec: crate::host::AttachedExec) -> (String, String) {
|
||||
let mut out = exec.output;
|
||||
let (mut o, mut e) = (Vec::new(), Vec::new());
|
||||
let collect = async {
|
||||
while let Some(chunk) = out.next().await {
|
||||
match chunk {
|
||||
Ok(ExecChunk::StdOut(b)) => o.extend_from_slice(&b),
|
||||
Ok(ExecChunk::StdErr(b)) => e.extend_from_slice(&b),
|
||||
Ok(ExecChunk::Other(_)) => {}
|
||||
Err(_) => break,
|
||||
}
|
||||
}
|
||||
};
|
||||
tokio::time::timeout(Duration::from_secs(20), collect).await.expect("attach output ended");
|
||||
(String::from_utf8_lossy(&o).into_owned(), String::from_utf8_lossy(&e).into_owned())
|
||||
}
|
||||
|
||||
/// Fail with both answers side by side.
|
||||
fn same<T: PartialEq + std::fmt::Debug>(what: &str, docker: T, agent: T) -> Result<(), String> {
|
||||
if docker == agent {
|
||||
Ok(())
|
||||
} else {
|
||||
Err(format!("{what} differs\n docker: {docker:?}\n agent: {agent:?}"))
|
||||
}
|
||||
}
|
||||
|
||||
async fn parity(d: &ContainerTarget, a: &ContainerTarget) -> Result<(), String> {
|
||||
// ── one-shot ─────────────────────────────────────────────────────────────
|
||||
let script = sh("echo out1; echo err1 >&2; echo out2; exit 3");
|
||||
same(
|
||||
"streams + exit code",
|
||||
d.exec_oneshot_streams_as("claude", script.clone(), vec![]).await?,
|
||||
a.exec_oneshot_streams_as("claude", script, vec![]).await?,
|
||||
)?;
|
||||
|
||||
for user in ["claude", "root"] {
|
||||
let env = sh("env | sort");
|
||||
let strip = |s: String| -> String {
|
||||
// `docker exec` inherits the container's configured env, which in
|
||||
// this harness carries the agent's settings (the token included —
|
||||
// the local-harness trade-off entrypoint.sh documents). The agent
|
||||
// never passes its own settings on.
|
||||
s.lines().filter(|l| !l.starts_with("TRIPLEC_AGENT_")).collect::<Vec<_>>().join("\n")
|
||||
};
|
||||
let (de, dc) = d.exec_oneshot_as(user, env.clone(), vec![]).await?;
|
||||
let (ae, ac) = a.exec_oneshot_as(user, env, vec![]).await?;
|
||||
same(&format!("environment as {user}"), (strip(de), dc), (strip(ae), ac))?;
|
||||
}
|
||||
|
||||
same("default working dir", d.exec_oneshot(vec!["pwd".into()]).await?, a.exec_oneshot(vec!["pwd".into()]).await?)?;
|
||||
|
||||
let probe = sh("echo \"$FOO|$HOME|$USER|$(id -un)\"");
|
||||
let env = vec!["FOO=bar".to_string(), "HOME=/tmp".to_string()];
|
||||
same(
|
||||
"env overrides",
|
||||
d.exec_oneshot_env_status(probe.clone(), env.clone()).await?,
|
||||
a.exec_oneshot_env_status(probe, env).await?,
|
||||
)?;
|
||||
|
||||
same("user ids", d.user_ids().await, a.user_ids().await)?;
|
||||
|
||||
// Signal state: an exec starts with nothing ignored or blocked, whatever
|
||||
// the process that launched it had (the entrypoint's supervisor ignores
|
||||
// SIGHUP; that must not turn every exec into `nohup`).
|
||||
let sigs = sh("grep -E '^Sig(Ign|Blk):' /proc/self/status");
|
||||
same("signal dispositions/mask", d.exec_oneshot(sigs.clone()).await?, a.exec_oneshot(sigs).await?)?;
|
||||
|
||||
// Stdio belongs to the exec user, as runc arranges: re-opening it by
|
||||
// name works for a non-root user.
|
||||
let reopen = sh("echo out > /dev/stdout; echo err > /dev/stderr");
|
||||
same(
|
||||
"re-opening stdio by name as claude",
|
||||
d.exec_oneshot_streams_as("claude", reopen.clone(), vec![]).await?,
|
||||
a.exec_oneshot_streams_as("claude", reopen, vec![]).await?,
|
||||
)?;
|
||||
|
||||
let flood = sh("head -c 100000 /dev/zero");
|
||||
let de = d.exec_oneshot_limited("claude", flood.clone(), 1000).await.unwrap_err();
|
||||
let ae = a.exec_oneshot_limited("claude", flood, 1000).await.unwrap_err();
|
||||
if !de.starts_with(OUTPUT_LIMIT_MARKER) {
|
||||
return Err(format!("docker limit error: {de}"));
|
||||
}
|
||||
same("output limit error", de, ae)?;
|
||||
|
||||
let (_, dcode) = d.exec_oneshot_env_status(vec!["no-such-binary-tc".into()], vec![]).await?;
|
||||
let (_, acode) = a.exec_oneshot_env_status(vec!["no-such-binary-tc".into()], vec![]).await?;
|
||||
same("missing binary exit code", dcode, acode)?;
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────
|
||||
let ids = d.user_ids().await;
|
||||
d.upload_bytes_with_ids("/tmp", "via-docker.txt", b"hello\n", 0o640, ids).await?;
|
||||
a.upload_bytes_with_ids("/tmp", "via-agent.txt", b"hello\n", 0o640, ids).await?;
|
||||
let stat = |n: &str| sh(&format!("stat -c '%a %u %g %s' /tmp/{n} && cat /tmp/{n}"));
|
||||
same(
|
||||
"uploaded file (mode, owner, size, content)",
|
||||
d.exec_oneshot(stat("via-docker.txt")).await?,
|
||||
a.exec_oneshot(stat("via-agent.txt")).await?,
|
||||
)?;
|
||||
|
||||
d.exec_oneshot(sh("rm -rf /tmp/arch && mkdir -p /tmp/arch/sub && echo x > /tmp/arch/x && echo y > /tmp/arch/sub/y")).await?;
|
||||
let list = |t: &ContainerTarget| {
|
||||
let mut s = t.download_archive("/tmp/arch");
|
||||
async move {
|
||||
let mut bytes = Vec::new();
|
||||
while let Some(c) = s.next().await {
|
||||
bytes.extend_from_slice(&c?);
|
||||
}
|
||||
let mut names: Vec<(String, Vec<u8>)> = tar::Archive::new(&bytes[..])
|
||||
.entries()
|
||||
.map_err(|e| e.to_string())?
|
||||
.map(|e| {
|
||||
let mut e = e.unwrap();
|
||||
let name = e.path().unwrap().to_string_lossy().trim_end_matches('/').to_string();
|
||||
let mut data = Vec::new();
|
||||
std::io::Read::read_to_end(&mut e, &mut data).unwrap();
|
||||
(name, data)
|
||||
})
|
||||
.collect();
|
||||
names.sort();
|
||||
Ok::<_, String>(names)
|
||||
}
|
||||
};
|
||||
same("archive members and contents", list(d).await?, list(a).await?)?;
|
||||
|
||||
// ── attached ─────────────────────────────────────────────────────────────
|
||||
for (label, t) in [("docker", d), ("agent", a)] {
|
||||
let mut exec = t.attach_exec(vec!["head".into(), "-n1".into()], false).await?;
|
||||
exec.input.write_all(b"ping\n").await.map_err(|e| e.to_string())?;
|
||||
exec.input.flush().await.map_err(|e| e.to_string())?;
|
||||
let id = exec.exec_id.clone();
|
||||
let (o, e) = drain(exec).await;
|
||||
same(&format!("{label}: attach echo"), ("ping\n".to_string(), String::new()), (o, e))?;
|
||||
same(&format!("{label}: attach exit"), Some(0), t.wait_exec_exit(&id).await)?;
|
||||
}
|
||||
|
||||
let mut sizes = Vec::new();
|
||||
for t in [d, a] {
|
||||
let exec = t
|
||||
.attach(ExecSpec::new(sh("sleep 1; stty size")).tty(true))
|
||||
.await?;
|
||||
t.resize_exec(&exec.exec_id, 100, 40).await?;
|
||||
let (o, _) = drain(exec).await;
|
||||
sizes.push(o.trim().to_string());
|
||||
}
|
||||
same("tty size after resize", sizes[0].clone(), sizes[1].clone())?;
|
||||
same("tty size value", "40 100".to_string(), sizes[1].clone())?;
|
||||
|
||||
// A tty session whose client goes away ends (SIGHUP), as a dropped
|
||||
// `docker exec -t` does. Only checked through the agent: it is the agent's
|
||||
// disconnect handling under test. Nothing else in this fresh container
|
||||
// runs `sleep 600`, so counting exact matches is the probe.
|
||||
let exec = a.attach(ExecSpec::new(vec!["sleep".into(), "600".into()]).tty(true)).await?;
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
let count = sh("pgrep -xfc 'sleep 600' || true");
|
||||
let before = a.exec_oneshot(count.clone()).await?;
|
||||
drop(exec);
|
||||
let mut after = String::new();
|
||||
for _ in 0..40 {
|
||||
tokio::time::sleep(Duration::from_millis(250)).await;
|
||||
after = a.exec_oneshot(count.clone()).await?;
|
||||
if after.trim() == "0" {
|
||||
break;
|
||||
}
|
||||
}
|
||||
same("tty session ends when its client goes away (sleep count before → after)", ("1".to_string(), "0".to_string()), (before.trim().to_string(), after.trim().to_string()))?;
|
||||
|
||||
// ── detached ─────────────────────────────────────────────────────────────
|
||||
let mut codes = Vec::new();
|
||||
for t in [d, a] {
|
||||
let id = t.exec_detached(ExecSpec::new(sh("sleep 0.3; exit 5"))).await?;
|
||||
codes.push(t.wait_exec_exit(&id).await);
|
||||
}
|
||||
same("detached exit code", codes[0], codes[1])?;
|
||||
|
||||
same("is_running", d.is_running().await?, a.is_running().await?)?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon and an image whose entrypoint starts triple-c-agent (TRIPLEC_AGENT_E2E_IMAGE)"]
|
||||
async fn the_agent_answers_exactly_as_docker_exec_does() {
|
||||
let image = std::env::var("TRIPLEC_AGENT_E2E_IMAGE").unwrap_or_else(|_| "tc-agent-e2e:latest".into());
|
||||
let token = format!("{}{}", uuid::Uuid::new_v4().simple(), uuid::Uuid::new_v4().simple());
|
||||
let name = format!("tc-agent-e2e-{}", uuid::Uuid::new_v4().simple());
|
||||
docker_cli(&[
|
||||
"run", "-d", "--name", &name, "-w", "/workspace",
|
||||
"-e", &format!("TRIPLEC_AGENT_TOKEN={token}"),
|
||||
// Docker forwards a published port to the container's own address,
|
||||
// not its loopback, so the harness binds the agent wide *inside* the
|
||||
// container and publishes it to the host's loopback only.
|
||||
"-e", "TRIPLEC_AGENT_ADDR=0.0.0.0:47900",
|
||||
"-p", "127.0.0.1::47900",
|
||||
&image,
|
||||
]);
|
||||
let outcome = async {
|
||||
let mapped = docker_cli(&["port", &name, "47900/tcp"]);
|
||||
let port: u16 = mapped.lines().next().and_then(|l| l.rsplit(':').next()).and_then(|p| p.parse().ok())
|
||||
.ok_or_else(|| format!("no published port in {mapped:?}"))?;
|
||||
// Two ways in, because where *this test* runs decides which one routes:
|
||||
// on the host (where the app runs) the published loopback port; from
|
||||
// inside another container on the same daemon, the published port is
|
||||
// on the host's loopback, not ours, but the container's bridge address
|
||||
// is reachable.
|
||||
let ip = docker_cli(&["inspect", "-f", "{{range .NetworkSettings.Networks}}{{.IPAddress}} {{end}}", &name]);
|
||||
let mut candidates = vec![AgentEndpoint::loopback(port, token.clone())];
|
||||
for addr in ip.split_whitespace() {
|
||||
let url = url::Url::parse(&format!("http://{addr}:47900/")).map_err(|e| e.to_string())?;
|
||||
candidates.push(AgentEndpoint::new(url, token.clone())?);
|
||||
}
|
||||
let docker = ContainerTarget::docker(name.clone());
|
||||
|
||||
// The entrypoint starts the agent last, after setup (including a
|
||||
// bounded `claude update`), so health is the readiness signal.
|
||||
let mut agent = None;
|
||||
'wait: for _ in 0..240 {
|
||||
for ep in &candidates {
|
||||
let t = ContainerTarget::Agent(ep.clone());
|
||||
if t.is_running().await.unwrap_or(false) {
|
||||
agent = Some(t);
|
||||
break 'wait;
|
||||
}
|
||||
}
|
||||
tokio::time::sleep(Duration::from_millis(500)).await;
|
||||
}
|
||||
let Some(agent) = agent else {
|
||||
return Err(format!("agent never answered on {candidates:?}; logs:\n{}", docker_cli(&["logs", "--tail", "40", &name])));
|
||||
};
|
||||
eprintln!("agent reached at {}", agent.id());
|
||||
parity(&docker, &agent).await
|
||||
}
|
||||
.await;
|
||||
let logs = docker_cli(&["logs", "--tail", "20", &name]);
|
||||
let _ = Command::new("docker").args(["rm", "-f", &name]).output();
|
||||
if let Err(e) = outcome {
|
||||
panic!("{e}\n--- container log tail ---\n{logs}");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,673 @@
|
||||
//! The agent client against a **fake** agent: a few axum handlers that speak
|
||||
//! just enough of the v1 protocol to exercise each client path, and record
|
||||
//! what they were sent. It is not the real server (that lives in `agent/`)
|
||||
//! and does not try to behave like one beyond the wire shapes.
|
||||
|
||||
use super::*;
|
||||
use crate::host::{ContainerTarget, OUTPUT_LIMIT_MARKER};
|
||||
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Path, Query, State};
|
||||
use axum::http::{HeaderMap, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
use serde_json::{json, Value};
|
||||
use std::sync::Arc;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
|
||||
/// Obviously fake; long enough to be unmistakable in an error string.
|
||||
const TOKEN: &str = "fake-agent-token-for-unit-tests-only-0000";
|
||||
|
||||
#[derive(Default)]
|
||||
struct Seen {
|
||||
auth: Vec<String>,
|
||||
exec_bodies: Vec<Value>,
|
||||
detached_bodies: Vec<Value>,
|
||||
status_polls: u32,
|
||||
resizes: Vec<(String, Value)>,
|
||||
uploads: Vec<(HashMap<String, String>, Vec<u8>)>,
|
||||
attach_hello: Option<Value>,
|
||||
stdin: Vec<u8>,
|
||||
close_stdin: bool,
|
||||
}
|
||||
|
||||
struct Fake {
|
||||
seen: std::sync::Mutex<Seen>,
|
||||
}
|
||||
|
||||
type St = State<Arc<Fake>>;
|
||||
|
||||
impl Fake {
|
||||
fn authed(&self, h: &HeaderMap) -> bool {
|
||||
let got = h
|
||||
.get("authorization")
|
||||
.and_then(|v| v.to_str().ok())
|
||||
.unwrap_or("")
|
||||
.to_string();
|
||||
let ok = got == format!("Bearer {}", TOKEN);
|
||||
self.seen.lock().unwrap().auth.push(got);
|
||||
ok
|
||||
}
|
||||
}
|
||||
|
||||
fn unauthorized() -> Response {
|
||||
StatusCode::UNAUTHORIZED.into_response()
|
||||
}
|
||||
|
||||
fn b64(bytes: &[u8]) -> String {
|
||||
base64::engine::general_purpose::STANDARD.encode(bytes)
|
||||
}
|
||||
|
||||
async fn exec(State(f): St, h: HeaderMap, Json(body): Json<Value>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
f.seen.lock().unwrap().exec_bodies.push(body.clone());
|
||||
match body["cmd"][0].as_str().unwrap_or("") {
|
||||
"sh" => Json(json!({"chunks": [{"s": "out", "d": b64(b"1000\n1001\n")}], "exit_code": 0}))
|
||||
.into_response(),
|
||||
// stdout and stderr interleaved; `ü` split across two stdout chunks;
|
||||
// one byte that is not UTF-8 at all on stderr.
|
||||
"mixed" => Json(json!({
|
||||
"chunks": [
|
||||
{"s": "out", "d": b64(b"first ")},
|
||||
{"s": "err", "d": b64(b"find: /x: denied\n")},
|
||||
{"s": "out", "d": b64(&[0xc3])},
|
||||
{"s": "out", "d": b64(&[0xbc, b'!'])},
|
||||
{"s": "err", "d": b64(&[0xff])},
|
||||
],
|
||||
"exit_code": 3
|
||||
}))
|
||||
.into_response(),
|
||||
"big" => (
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
Json(json!({"error": "OUTPUT_LIMIT: Command output exceeded 5 bytes and was abandoned"})),
|
||||
)
|
||||
.into_response(),
|
||||
_ => (StatusCode::INTERNAL_SERVER_ERROR, Json(json!({"error": "boom"}))).into_response(),
|
||||
}
|
||||
}
|
||||
|
||||
async fn detached(State(f): St, h: HeaderMap, Json(body): Json<Value>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
f.seen.lock().unwrap().detached_bodies.push(body);
|
||||
Json(json!({"exec_id": "d1"})).into_response()
|
||||
}
|
||||
|
||||
/// `d1` reports running for two polls, then exit 7. Everything else is 404 —
|
||||
/// including the attach's `a1`, so an attach's exit can only have come from
|
||||
/// the client's own record of the `exit` frame.
|
||||
async fn status(State(f): St, h: HeaderMap, Path(id): Path<String>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
if id != "d1" {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({"error": "no such exec"}))).into_response();
|
||||
}
|
||||
let mut s = f.seen.lock().unwrap();
|
||||
s.status_polls += 1;
|
||||
if s.status_polls < 3 {
|
||||
Json(json!({"running": true, "exit_code": null})).into_response()
|
||||
} else {
|
||||
Json(json!({"running": false, "exit_code": 7})).into_response()
|
||||
}
|
||||
}
|
||||
|
||||
async fn resize(State(f): St, h: HeaderMap, Path(id): Path<String>, Json(body): Json<Value>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
f.seen.lock().unwrap().resizes.push((id, body));
|
||||
StatusCode::NO_CONTENT.into_response()
|
||||
}
|
||||
|
||||
async fn files(
|
||||
State(f): St,
|
||||
h: HeaderMap,
|
||||
Query(q): Query<HashMap<String, String>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
let path = format!("{}/{}", q["dir"], q["name"]);
|
||||
f.seen.lock().unwrap().uploads.push((q, body.to_vec()));
|
||||
Json(json!({"path": path})).into_response()
|
||||
}
|
||||
|
||||
async fn archive(State(f): St, h: HeaderMap, Query(q): Query<HashMap<String, String>>) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
if q["path"] == "/missing" {
|
||||
return (StatusCode::NOT_FOUND, Json(json!({"error": "no such path"}))).into_response();
|
||||
}
|
||||
let parts: Vec<Result<Bytes, std::io::Error>> = vec![
|
||||
Ok(Bytes::from_static(b"tar-part-1;")),
|
||||
Ok(Bytes::from_static(b"tar-part-2;")),
|
||||
Ok(Bytes::from(q["path"].clone().into_bytes())),
|
||||
];
|
||||
axum::body::Body::from_stream(futures_util::stream::iter(parts)).into_response()
|
||||
}
|
||||
|
||||
async fn health(State(f): St, h: HeaderMap) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
Json(json!({"agent": "triple-c-agent", "version": "0.0.0-fake", "protocol": 1})).into_response()
|
||||
}
|
||||
|
||||
async fn attach_ws(State(f): St, h: HeaderMap, ws: WebSocketUpgrade) -> Response {
|
||||
if !f.authed(&h) {
|
||||
return unauthorized();
|
||||
}
|
||||
ws.on_upgrade(move |socket| fake_attach(f, socket))
|
||||
}
|
||||
|
||||
/// started → stdout, stderr, an unknown channel → collect stdin until
|
||||
/// `close_stdin` → echo it on stdout → exit 5 → close.
|
||||
async fn fake_attach(f: Arc<Fake>, mut ws: WebSocket) {
|
||||
let Some(Ok(WsMessage::Text(hello))) = ws.recv().await else { return };
|
||||
let hello: Value = serde_json::from_str(hello.as_str()).unwrap();
|
||||
let refuse = hello["cmd"][0] == "nope";
|
||||
f.seen.lock().unwrap().attach_hello = Some(hello);
|
||||
if refuse {
|
||||
let _ = ws
|
||||
.send(WsMessage::Text(r#"{"type":"error","error":"no such user"}"#.into()))
|
||||
.await;
|
||||
return;
|
||||
}
|
||||
let _ = ws
|
||||
.send(WsMessage::Text(r#"{"type":"started","exec_id":"a1"}"#.into()))
|
||||
.await;
|
||||
for frame in [&b"\x01out"[..], b"\x02err", b"\x09odd"] {
|
||||
let _ = ws.send(WsMessage::Binary(Bytes::copy_from_slice(frame))).await;
|
||||
}
|
||||
while let Some(Ok(msg)) = ws.recv().await {
|
||||
match msg {
|
||||
WsMessage::Binary(b) => f.seen.lock().unwrap().stdin.extend_from_slice(&b),
|
||||
WsMessage::Text(t) if t.as_str().contains("close_stdin") => {
|
||||
f.seen.lock().unwrap().close_stdin = true;
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
let mut echo = vec![1u8];
|
||||
echo.extend_from_slice(&f.seen.lock().unwrap().stdin);
|
||||
let _ = ws.send(WsMessage::Binary(echo.into())).await;
|
||||
let _ = ws.send(WsMessage::Text(r#"{"type":"exit","code":5}"#.into())).await;
|
||||
let _ = ws.close().await;
|
||||
}
|
||||
|
||||
async fn fake_agent() -> (AgentEndpoint, Arc<Fake>) {
|
||||
let fake = Arc::new(Fake {
|
||||
seen: std::sync::Mutex::new(Seen::default()),
|
||||
});
|
||||
let app = Router::new()
|
||||
.route("/v1/health", get(health))
|
||||
.route("/v1/exec", post(exec))
|
||||
.route("/v1/exec/detached", post(detached))
|
||||
.route("/v1/exec/{id}", get(status))
|
||||
.route("/v1/exec/{id}/resize", post(resize))
|
||||
.route("/v1/files", put(files))
|
||||
.route("/v1/archive", get(archive))
|
||||
.route("/v1/attach", get(attach_ws))
|
||||
.with_state(fake.clone());
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
(AgentEndpoint::loopback(port, TOKEN), fake)
|
||||
}
|
||||
|
||||
fn target(ep: &AgentEndpoint) -> ContainerTarget {
|
||||
ContainerTarget::Agent(ep.clone())
|
||||
}
|
||||
|
||||
fn cmd(c: &str) -> Vec<String> {
|
||||
vec![c.to_string()]
|
||||
}
|
||||
|
||||
// ── endpoint ─────────────────────────────────────────────────────────────────
|
||||
|
||||
#[test]
|
||||
fn the_token_never_appears_in_debug_output() {
|
||||
let ep = AgentEndpoint::loopback(47900, TOKEN);
|
||||
let dbg = format!("{:?} {:?}", ep, ContainerTarget::Agent(ep.clone()));
|
||||
assert!(!dbg.contains(TOKEN), "{}", dbg);
|
||||
assert!(dbg.contains("127.0.0.1:47900"));
|
||||
assert!(dbg.contains("redacted"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn an_agent_target_is_identified_by_its_base_url() {
|
||||
let a = ContainerTarget::Agent(AgentEndpoint::loopback(47900, TOKEN));
|
||||
assert_eq!(a.id(), "http://127.0.0.1:47900/");
|
||||
assert_eq!(a, ContainerTarget::Agent(AgentEndpoint::loopback(47900, TOKEN)));
|
||||
assert_ne!(a, ContainerTarget::Agent(AgentEndpoint::loopback(47901, TOKEN)));
|
||||
let mut set = std::collections::HashSet::new();
|
||||
set.insert(a.clone());
|
||||
assert!(set.contains(&a));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn only_plain_http_endpoints_are_accepted() {
|
||||
let ok = AgentEndpoint::new(url::Url::parse("http://127.0.0.1:1/x").unwrap(), TOKEN).unwrap();
|
||||
assert_eq!(ok.base_url.as_str(), "http://127.0.0.1:1/x/");
|
||||
assert_eq!(ok.url("v1/health").as_str(), "http://127.0.0.1:1/x/v1/health");
|
||||
assert_eq!(ok.ws_url("v1/attach").as_str(), "ws://127.0.0.1:1/x/v1/attach");
|
||||
assert!(AgentEndpoint::new(url::Url::parse("https://h/").unwrap(), TOKEN).is_err());
|
||||
assert!(AgentEndpoint::new(url::Url::parse("file:///tmp").unwrap(), TOKEN).is_err());
|
||||
}
|
||||
|
||||
// ── auth ─────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_request_carries_the_bearer_token() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
assert!(t.is_running().await.unwrap());
|
||||
t.exec_oneshot(cmd("sh")).await.unwrap();
|
||||
let auth = fake.seen.lock().unwrap().auth.clone();
|
||||
assert_eq!(auth.len(), 2);
|
||||
assert!(auth.iter().all(|a| a == &format!("Bearer {}", TOKEN)));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_wrong_token_is_an_error_that_does_not_carry_any_token() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let wrong_token = "wrong-fake-token-for-unit-tests-only-1111";
|
||||
let bad = ContainerTarget::Agent(AgentEndpoint::new(ep.base_url.clone(), wrong_token).unwrap());
|
||||
|
||||
let e = bad.exec_oneshot(cmd("sh")).await.unwrap_err();
|
||||
assert!(e.contains("401"), "{}", e);
|
||||
assert!(e.starts_with("Failed to start exec"), "{}", e);
|
||||
let Err(attach_err) = bad.attach(ExecSpec::new(cmd("sh"))).await else {
|
||||
panic!("attach with a wrong token succeeded")
|
||||
};
|
||||
assert!(attach_err.contains("401"), "{}", attach_err);
|
||||
let health_err = bad.is_running().await.unwrap_err();
|
||||
let up_err = bad.upload_bytes("/tmp", "x", b"x", 0o644).await.unwrap_err();
|
||||
for e in [&e, &attach_err, &health_err, &up_err] {
|
||||
assert!(!e.contains(wrong_token) && !e.contains(TOKEN), "{}", e);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn other_errors_carry_the_agents_message_and_no_token() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let e = target(&ep).exec_oneshot(cmd("fail")).await.unwrap_err();
|
||||
assert_eq!(e, "Failed to start exec: boom");
|
||||
}
|
||||
|
||||
// ── one-shot execs ───────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn every_oneshot_variant_reassembles_what_docker_would_return() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
// Arrival order, `ü` rejoined across chunks, the stray 0xff lossy.
|
||||
let combined = "first find: /x: denied\nü!\u{fffd}";
|
||||
let env = vec!["K=V".to_string()];
|
||||
|
||||
assert_eq!(t.exec_oneshot(cmd("mixed")).await.unwrap(), combined);
|
||||
assert_eq!(t.exec_oneshot_env(cmd("mixed"), env.clone()).await.unwrap(), combined);
|
||||
assert_eq!(
|
||||
t.exec_oneshot_env_status(cmd("mixed"), env.clone()).await.unwrap(),
|
||||
(combined.to_string(), 3)
|
||||
);
|
||||
assert_eq!(
|
||||
t.exec_oneshot_as("root", cmd("mixed"), env.clone()).await.unwrap(),
|
||||
(combined.to_string(), 3)
|
||||
);
|
||||
assert_eq!(
|
||||
t.exec_oneshot_limited("root", cmd("mixed"), 4096).await.unwrap(),
|
||||
(combined.to_string(), 3)
|
||||
);
|
||||
assert_eq!(
|
||||
t.exec_oneshot_streams_as("root", cmd("mixed"), env.clone()).await.unwrap(),
|
||||
("first ü!".to_string(), "find: /x: denied\n\u{fffd}".to_string(), 3)
|
||||
);
|
||||
|
||||
let bodies = fake.seen.lock().unwrap().exec_bodies.clone();
|
||||
let max = MAX_ONESHOT_OUTPUT as u64;
|
||||
let expect = [
|
||||
("claude", json!([]), max),
|
||||
("claude", json!(["K=V"]), max),
|
||||
("claude", json!(["K=V"]), max),
|
||||
("root", json!(["K=V"]), max),
|
||||
("root", json!([]), 4096),
|
||||
("root", json!(["K=V"]), max),
|
||||
];
|
||||
assert_eq!(bodies.len(), expect.len());
|
||||
for (b, (user, env, limit)) in bodies.iter().zip(expect) {
|
||||
assert_eq!(b["user"], user);
|
||||
assert_eq!(b["env"], env);
|
||||
assert_eq!(b["limit"].as_u64(), Some(limit));
|
||||
assert_eq!(b["cmd"], json!(["mixed"]));
|
||||
// Docker's one-shot names no working dir and no tty.
|
||||
assert_eq!(b["working_dir"], Value::Null);
|
||||
assert_eq!(b["tty"], false);
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_output_limit_refusal_passes_through_verbatim() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let e = target(&ep).exec_oneshot_limited("claude", cmd("big"), 5).await.unwrap_err();
|
||||
assert_eq!(e, "OUTPUT_LIMIT: Command output exceeded 5 bytes and was abandoned");
|
||||
assert!(e.starts_with(OUTPUT_LIMIT_MARKER));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn the_client_also_enforces_the_limit_on_what_it_buffers() {
|
||||
// An agent that ignored `limit` still cannot make the client buffer past it.
|
||||
let (ep, _) = fake_agent().await;
|
||||
let e = target(&ep).exec_oneshot_limited("claude", cmd("mixed"), 8).await.unwrap_err();
|
||||
assert!(e.starts_with(OUTPUT_LIMIT_MARKER), "{}", e);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn user_ids_come_from_the_same_probe_as_docker() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
assert_eq!(target(&ep).user_ids().await, (1000, 1001));
|
||||
let b = fake.seen.lock().unwrap().exec_bodies[0].clone();
|
||||
assert_eq!(b["cmd"], json!(["sh", "-c", "id -u; id -g"]));
|
||||
assert_eq!(b["user"], "claude");
|
||||
assert_eq!(b["limit"], 256);
|
||||
}
|
||||
|
||||
// ── detached, status, resize ─────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_detached_exec_is_polled_until_it_reports_its_exit() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
let spec = ExecSpec::new(cmd("sleep")).user("root").working_dir(None).no_stdin();
|
||||
let id = t.exec_detached(spec).await.unwrap();
|
||||
assert_eq!(id, "d1");
|
||||
assert_eq!(t.wait_exec_exit(&id).await, Some(7));
|
||||
let s = fake.seen.lock().unwrap();
|
||||
assert_eq!(s.status_polls, 3);
|
||||
assert_eq!(s.detached_bodies[0]["user"], "root");
|
||||
assert_eq!(s.detached_bodies[0]["working_dir"], Value::Null);
|
||||
drop(s);
|
||||
assert_eq!(t.wait_exec_exit("unknown").await, None);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn resize_posts_cols_and_rows() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
target(&ep).resize_exec("a1", 120, 40).await.unwrap();
|
||||
let r = fake.seen.lock().unwrap().resizes.clone();
|
||||
assert_eq!(r, vec![("a1".to_string(), json!({"cols": 120, "rows": 40}))]);
|
||||
}
|
||||
|
||||
// ── attach ───────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn attach_maps_channels_forwards_stdin_and_ends_on_exit() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
let spec = ExecSpec::new(cmd("cat")).env(vec!["A=1".into()]).tty(true);
|
||||
let AttachedExec {
|
||||
exec_id,
|
||||
mut output,
|
||||
mut input,
|
||||
} = t.attach(spec).await.unwrap();
|
||||
assert_eq!(exec_id, "a1");
|
||||
|
||||
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::StdOut(Bytes::from_static(b"out")));
|
||||
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::StdErr(Bytes::from_static(b"err")));
|
||||
assert_eq!(output.next().await.unwrap().unwrap(), ExecChunk::Other(Bytes::from_static(b"odd")));
|
||||
|
||||
input.write_all(b"hello ").await.unwrap();
|
||||
input.write_all(b"agent").await.unwrap();
|
||||
input.shutdown().await.unwrap();
|
||||
|
||||
assert_eq!(
|
||||
output.next().await.unwrap().unwrap(),
|
||||
ExecChunk::StdOut(Bytes::from_static(b"hello agent"))
|
||||
);
|
||||
// The exit frame ends the stream…
|
||||
assert!(output.next().await.is_none());
|
||||
// …and is what answers wait_exec_exit (the fake 404s `a1`).
|
||||
assert_eq!(t.wait_exec_exit(&exec_id).await, Some(5));
|
||||
|
||||
let s = fake.seen.lock().unwrap();
|
||||
assert!(s.close_stdin);
|
||||
assert_eq!(s.stdin, b"hello agent");
|
||||
let hello = s.attach_hello.clone().unwrap();
|
||||
assert_eq!(hello["cmd"], json!(["cat"]));
|
||||
assert_eq!(hello["user"], "claude");
|
||||
assert_eq!(hello["working_dir"], "/workspace");
|
||||
assert_eq!(hello["env"], json!(["A=1"]));
|
||||
assert_eq!(hello["tty"], true);
|
||||
assert_eq!(hello["attach_stdin"], true);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_attach_the_agent_refuses_is_an_error() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let Err(e) = target(&ep).attach(ExecSpec::new(cmd("nope"))).await else {
|
||||
panic!("a refused attach succeeded")
|
||||
};
|
||||
assert_eq!(e, "Failed to start exec: no such user");
|
||||
}
|
||||
|
||||
// ── files ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn uploads_put_the_bytes_with_dir_name_mode_and_owner() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
assert_eq!(
|
||||
t.upload_bytes_with_ids("/tmp/", "x.txt", b"hi", 0o755, (1000, 1001))
|
||||
.await
|
||||
.unwrap(),
|
||||
"/tmp/x.txt"
|
||||
);
|
||||
assert_eq!(
|
||||
t.upload_bytes("/home/claude/in", "p.tar", b"tar", 0o644).await.unwrap(),
|
||||
"/home/claude/in/p.tar"
|
||||
);
|
||||
// The terminal drop's nested name: Docker's tar entry would land it in the
|
||||
// subdirectory, so the agent is asked for that directory.
|
||||
assert_eq!(
|
||||
t.upload_bytes_with_ids("/tmp", "triple-c-drops/a.txt", b"a", 0o644, (1, 2))
|
||||
.await
|
||||
.unwrap(),
|
||||
"/tmp/triple-c-drops/a.txt"
|
||||
);
|
||||
assert!(t.upload_bytes("/tmp", "../etc/x", b"", 0o644).await.is_err());
|
||||
|
||||
let up = fake.seen.lock().unwrap().uploads.clone();
|
||||
let q = |i: usize, k: &str| up[i].0[k].clone();
|
||||
assert_eq!((q(0, "dir"), q(0, "name"), q(0, "mode")), ("/tmp".into(), "x.txt".into(), "755".into()));
|
||||
assert_eq!((q(0, "uid"), q(0, "gid")), ("1000".into(), "1001".into()));
|
||||
assert_eq!(up[0].1, b"hi");
|
||||
assert_eq!((q(1, "uid"), q(1, "gid"), q(1, "mode")), ("0".into(), "0".into(), "644".into()));
|
||||
assert_eq!((q(2, "dir"), q(2, "name")), ("/tmp/triple-c-drops".into(), "a.txt".into()));
|
||||
assert_eq!(up.len(), 3);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_host_file_upload_reads_through_the_shared_checked_reader() {
|
||||
let (ep, fake) = fake_agent().await;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("notes.txt");
|
||||
std::fs::write(&path, b"from the host").unwrap();
|
||||
let path = path.canonicalize().unwrap();
|
||||
let t = target(&ep);
|
||||
let got = t
|
||||
.upload_host_file_with_ids(path.to_str().unwrap(), "/workspace/", "notes.txt", (7, 8))
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(got, "/workspace/notes.txt");
|
||||
// Without ids: resolved through the uid probe first.
|
||||
t.upload_host_file(path.to_str().unwrap(), "/tmp", "n2.txt").await.unwrap();
|
||||
|
||||
let up = fake.seen.lock().unwrap().uploads.clone();
|
||||
assert_eq!(up[0].1, b"from the host");
|
||||
assert_eq!(up[0].0["mode"], "644");
|
||||
assert_eq!((up[0].0["uid"].as_str(), up[0].0["gid"].as_str()), ("7", "8"));
|
||||
assert_eq!((up[1].0["uid"].as_str(), up[1].0["gid"].as_str()), ("1000", "1001"));
|
||||
|
||||
let missing = dir.path().join("gone.txt");
|
||||
let e = t
|
||||
.upload_host_file_with_ids(missing.to_str().unwrap(), "/tmp", "g", (1, 1))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.starts_with("Failed to read"), "{}", e);
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_archive_is_streamed_and_a_missing_path_is_an_error() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
let t = target(&ep);
|
||||
let mut s = t.download_archive("/workspace/a dir");
|
||||
let mut got = Vec::new();
|
||||
while let Some(chunk) = s.next().await {
|
||||
got.extend_from_slice(&chunk.unwrap());
|
||||
}
|
||||
assert_eq!(got, b"tar-part-1;tar-part-2;/workspace/a dir");
|
||||
|
||||
let mut s = t.download_archive("/missing");
|
||||
let e = s.next().await.unwrap().unwrap_err();
|
||||
assert_eq!(e, "Failed to download archive: no such path");
|
||||
assert!(s.next().await.is_none());
|
||||
}
|
||||
|
||||
// ── state ────────────────────────────────────────────────────────────────────
|
||||
|
||||
#[tokio::test]
|
||||
async fn is_running_follows_whether_the_agent_answers() {
|
||||
let (ep, _) = fake_agent().await;
|
||||
assert!(target(&ep).is_running().await.unwrap());
|
||||
assert!(!target(&ep).has_exited().await);
|
||||
|
||||
let l = std::net::TcpListener::bind("127.0.0.1:0").unwrap();
|
||||
let port = l.local_addr().unwrap().port();
|
||||
drop(l);
|
||||
let gone = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN));
|
||||
assert!(!gone.is_running().await.unwrap());
|
||||
// Silence is not a positive exit.
|
||||
assert!(!gone.has_exited().await);
|
||||
}
|
||||
|
||||
// ── a hostile agent ──────────────────────────────────────────────────────────
|
||||
// The agent runs inside the container, which is not trusted with the host.
|
||||
|
||||
/// Serve `app` on an ephemeral loopback port; return the port.
|
||||
async fn serve(app: Router) -> u16 {
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
axum::serve(listener, app).await.unwrap();
|
||||
});
|
||||
port
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_redirect_from_the_agent_is_not_followed() {
|
||||
let hits = Arc::new(std::sync::atomic::AtomicUsize::new(0));
|
||||
let elsewhere = {
|
||||
let hits = hits.clone();
|
||||
serve(Router::new().fallback(move || {
|
||||
let hits = hits.clone();
|
||||
async move {
|
||||
hits.fetch_add(1, std::sync::atomic::Ordering::SeqCst);
|
||||
"followed"
|
||||
}
|
||||
}))
|
||||
.await
|
||||
};
|
||||
let to = format!("http://127.0.0.1:{}/anything", elsewhere);
|
||||
let agent = serve(Router::new().fallback(move || {
|
||||
let to = to.clone();
|
||||
async move { (StatusCode::TEMPORARY_REDIRECT, [(axum::http::header::LOCATION, to)]) }
|
||||
}))
|
||||
.await;
|
||||
let t = ContainerTarget::Agent(AgentEndpoint::loopback(agent, TOKEN));
|
||||
|
||||
assert!(t.exec_detached(ExecSpec::new(cmd("true"))).await.is_err());
|
||||
assert!(t.exec_oneshot(cmd("true")).await.is_err());
|
||||
assert!(t.is_running().await.is_err(), "a 3xx health answer is not 'running'");
|
||||
assert_eq!(hits.load(std::sync::atomic::Ordering::SeqCst), 0, "the client followed a redirect");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_oversized_error_body_is_neither_buffered_nor_passed_through_whole() {
|
||||
// 8 MiB of error text: past the small-body cap, so it is not even parsed.
|
||||
let huge = serve(Router::new().fallback(|| async {
|
||||
let body = json!({ "error": "x".repeat(8 * 1024 * 1024) });
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(body))
|
||||
}))
|
||||
.await;
|
||||
let e = ContainerTarget::Agent(AgentEndpoint::loopback(huge, TOKEN))
|
||||
.exec_detached(ExecSpec::new(cmd("true")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.len() < 1024, "error was {} bytes", e.len());
|
||||
assert!(e.contains("500"), "{e}");
|
||||
|
||||
// Under the body cap but long: parsed, then cut to the display cap.
|
||||
let long = serve(Router::new().fallback(|| async {
|
||||
let body = json!({ "error": "é".repeat(20 * 1024) });
|
||||
(StatusCode::INTERNAL_SERVER_ERROR, Json(body))
|
||||
}))
|
||||
.await;
|
||||
let e = ContainerTarget::Agent(AgentEndpoint::loopback(long, TOKEN))
|
||||
.exec_detached(ExecSpec::new(cmd("true")))
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.len() <= 4 * 1024 + 64, "error was {} bytes", e.len());
|
||||
assert!(e.ends_with('…'), "{e}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn an_agent_that_accepts_and_then_says_nothing_cannot_hang_the_small_calls() {
|
||||
// Accepts every connection, reads nothing, answers nothing, holds it open.
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let port = listener.local_addr().unwrap().port();
|
||||
tokio::spawn(async move {
|
||||
let mut held = Vec::new();
|
||||
while let Ok((sock, _)) = listener.accept().await {
|
||||
held.push(sock);
|
||||
}
|
||||
});
|
||||
let t = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN));
|
||||
let started = std::time::Instant::now();
|
||||
assert!(t.exec_detached(ExecSpec::new(cmd("true"))).await.is_err());
|
||||
assert!(t.resize_exec("abc", 80, 24).await.is_err());
|
||||
assert_eq!(t.wait_exec_exit("abc").await, None);
|
||||
// Three calls, each bounded by SMALL_REQUEST_TIMEOUT (1 s under test).
|
||||
assert!(started.elapsed() < std::time::Duration::from_secs(10), "took {:?}", started.elapsed());
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_that_cannot_be_a_header_value_is_refused_up_front() {
|
||||
let url = url::Url::parse("http://127.0.0.1:1/").unwrap();
|
||||
for bad in ["", "has space", "new\nline", "tab\there", "ünïcode"] {
|
||||
assert!(AgentEndpoint::new(url.clone(), bad).is_err(), "{bad:?} accepted");
|
||||
}
|
||||
assert!(AgentEndpoint::new(url, TOKEN).is_ok());
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_413_that_is_not_the_limit_message_is_not_passed_through() {
|
||||
let port = serve(Router::new().fallback(|| async {
|
||||
(StatusCode::PAYLOAD_TOO_LARGE, Json(json!({ "error": "click here: http://evil.example" })))
|
||||
}))
|
||||
.await;
|
||||
let e = ContainerTarget::Agent(AgentEndpoint::loopback(port, TOKEN))
|
||||
.exec_oneshot_limited("claude", cmd("true"), 1000)
|
||||
.await
|
||||
.unwrap_err();
|
||||
assert!(e.starts_with(OUTPUT_LIMIT_MARKER), "{e}");
|
||||
assert!(!e.contains("evil"), "{e}");
|
||||
}
|
||||
@@ -0,0 +1,333 @@
|
||||
//! Bringing a project's container into existence, starting, stopping and
|
||||
//! removing it — the host-specific half of the project lifecycle commands.
|
||||
//!
|
||||
//! The commands in `commands/project_commands.rs` keep everything that is the
|
||||
//! same on every host: validating the LLM backend config, the Bedrock SSO
|
||||
//! check, project status transitions, the project lock, closing terminal
|
||||
//! sessions, the auth bridge, marketplace and credential syncs. They ask a
|
||||
//! [`ProjectHost`] for the rest. For Docker that is the
|
||||
//! find → maybe-snapshot-and-recreate → create → start sequence, which used to
|
||||
//! sit inline in `start_project_container_locked` and is moved here unchanged.
|
||||
//!
|
||||
//! Docker-only operations with no host-neutral meaning — base-image migration,
|
||||
//! snapshot sweeps, legacy cleanup at startup — keep calling `crate::docker`
|
||||
//! directly; see the boundary test in `host/mod.rs`.
|
||||
|
||||
use super::ContainerTarget;
|
||||
use crate::docker;
|
||||
use crate::models::{AppSettings, Project};
|
||||
use crate::models::container_config;
|
||||
|
||||
/// The host a project runs on. One variant today; an AWS variant will carry
|
||||
/// the connection, region and environment (see the spec's data model).
|
||||
#[derive(Debug, Clone, PartialEq, Eq)]
|
||||
pub enum ProjectHost {
|
||||
Docker,
|
||||
}
|
||||
|
||||
/// An agent target is a way *into* a container, not a handle on its
|
||||
/// lifecycle: whatever runs the container (ECS, the local harness) stops and
|
||||
/// removes it. Reaching here means a caller paired the Docker host with an
|
||||
/// agent target, which is a bug to surface, not a panic.
|
||||
const AGENT_LIFECYCLE: &str =
|
||||
"{op} a container is not supported for an agent target; the host that runs it owns its lifecycle";
|
||||
|
||||
/// What a host could not clean up when asked to discard a project's state.
|
||||
/// Docker: the snapshot image and the named volumes.
|
||||
#[derive(Debug, Default, Clone, PartialEq, Eq)]
|
||||
pub struct DiscardLeftovers {
|
||||
pub image: Option<String>,
|
||||
pub volumes: Vec<String>,
|
||||
}
|
||||
|
||||
impl ProjectHost {
|
||||
pub fn for_project(_project: &Project) -> Self {
|
||||
ProjectHost::Docker
|
||||
}
|
||||
|
||||
/// Make sure the project's container exists, matches the project's
|
||||
/// current configuration, and is running. Returns where it runs.
|
||||
///
|
||||
/// `progress` receives the same human-readable steps the start view has
|
||||
/// always shown. Failure leaves the caller to reset the project status.
|
||||
pub async fn ensure_started(
|
||||
&self,
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
progress: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<ContainerTarget, String> {
|
||||
match self {
|
||||
ProjectHost::Docker => docker_ensure_started(project, settings, progress)
|
||||
.await
|
||||
.map(ContainerTarget::docker),
|
||||
}
|
||||
}
|
||||
|
||||
/// The container the host currently has for this project, found by asking
|
||||
/// the host rather than trusting `project.container_id` — that field can
|
||||
/// be stale (a crash between remove and the store update, a hand-edited
|
||||
/// `projects.json`).
|
||||
pub async fn find_current(&self, project: &Project) -> Result<Option<ContainerTarget>, String> {
|
||||
match self {
|
||||
ProjectHost::Docker => Ok(docker::find_existing_container(project)
|
||||
.await?
|
||||
.map(ContainerTarget::docker)),
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn stop(&self, target: &ContainerTarget) -> Result<(), String> {
|
||||
match target {
|
||||
ContainerTarget::Docker { container_id } => docker::stop_container(container_id).await,
|
||||
ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Stopping")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Stop (errors ignored — a stopped container is the goal) and remove.
|
||||
pub async fn remove(&self, target: &ContainerTarget) -> Result<(), String> {
|
||||
match target {
|
||||
ContainerTarget::Docker { container_id } => {
|
||||
let _ = docker::stop_container(container_id).await;
|
||||
docker::remove_container(container_id).await
|
||||
}
|
||||
ContainerTarget::Agent(_) => Err(AGENT_LIFECYCLE.replace("{op}", "Removing")),
|
||||
}
|
||||
}
|
||||
|
||||
/// Delete what outlives the container — for Docker the snapshot image and
|
||||
/// both named volumes (`~/.claude`, the OAuth login and transcripts go with
|
||||
/// them). Best-effort; reports what it could not remove.
|
||||
pub async fn discard_project_state(&self, project: &Project) -> DiscardLeftovers {
|
||||
match self {
|
||||
ProjectHost::Docker => {
|
||||
let mut left = DiscardLeftovers::default();
|
||||
if let Err(e) = docker::remove_snapshot_image(project).await {
|
||||
log::warn!("Failed to remove snapshot image for project {}: {}", project.id, e);
|
||||
left.image = Some(docker::get_snapshot_image_name(project));
|
||||
}
|
||||
left.volumes = docker::remove_project_volumes(project).await;
|
||||
left
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Remove leftovers of features this host no longer has (Docker: the
|
||||
/// deleted MCP feature's containers and network).
|
||||
pub async fn remove_legacy_leftovers(&self, project: &Project) {
|
||||
match self {
|
||||
ProjectHost::Docker => {
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The Docker start sequence, moved here verbatim from
|
||||
/// `start_project_container_locked`. Returns the running container's id.
|
||||
async fn docker_ensure_started(
|
||||
project: &Project,
|
||||
settings: &AppSettings,
|
||||
progress: &(dyn Fn(&str) + Send + Sync),
|
||||
) -> Result<String, String> {
|
||||
let image_name =
|
||||
container_config::resolve_image_name(&settings.image_source, &settings.custom_image_name);
|
||||
|
||||
// Ensure image exists
|
||||
progress("Checking image...");
|
||||
if !docker::image_exists(&image_name).await? {
|
||||
return Err(format!("Docker image '{}' not found. Please pull or build the image first.", image_name));
|
||||
}
|
||||
|
||||
// Determine docker socket path
|
||||
let docker_socket = settings.docker_socket_path
|
||||
.as_deref()
|
||||
.map(|s| s.to_string())
|
||||
.unwrap_or_else(|| docker::default_docker_socket());
|
||||
|
||||
// AWS config path from global settings
|
||||
let aws_config_path = settings.global_aws.aws_config_path.clone();
|
||||
|
||||
// What we would create this container from *right now*: the project's
|
||||
// snapshot when one exists, else the configured base. This is the value
|
||||
// `container_needs_recreation` compares against the container's
|
||||
// `triple-c.create-image` label — the check that replaced the old
|
||||
// tautological one. It is resolved *before* the commit below, so it
|
||||
// describes the pre-commit world the existing container was born into.
|
||||
let snapshot_image = docker::get_snapshot_image_name(project);
|
||||
let expected_create_image =
|
||||
if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let container_id = if let Some(existing_id) = docker::find_existing_container(project).await? {
|
||||
// Check if config changed — if so, snapshot + recreate
|
||||
let needs_recreate = docker::container_needs_recreation(
|
||||
&existing_id,
|
||||
project,
|
||||
&expected_create_image,
|
||||
&settings.global_aws,
|
||||
&settings.global_ollama,
|
||||
&settings.global_llamacpp,
|
||||
&settings.global_openai_compatible,
|
||||
settings.global_claude_instructions.as_deref(),
|
||||
&settings.global_custom_env_vars,
|
||||
settings.timezone.as_deref(),
|
||||
settings.global_claude_code_settings.as_ref(),
|
||||
settings.default_ssh_key_path.as_deref(),
|
||||
settings.ca_cert_path.as_deref(),
|
||||
settings.default_git_user_name.as_deref(),
|
||||
settings.default_git_user_email.as_deref(),
|
||||
).await.unwrap_or(false);
|
||||
|
||||
if needs_recreate {
|
||||
log::info!("Container config changed for project {} — committing snapshot and recreating", project.id);
|
||||
// Snapshot the filesystem before destroying
|
||||
progress("Saving container state...");
|
||||
if let Err(e) = docker::commit_container_snapshot(&existing_id, project).await {
|
||||
log::warn!("Failed to snapshot container before recreation: {}", e);
|
||||
}
|
||||
progress("Recreating container...");
|
||||
let _ = docker::stop_container(&existing_id).await;
|
||||
docker::remove_container(&existing_id).await?;
|
||||
|
||||
// Legacy MCP cleanup: the old container may have been attached to
|
||||
// `triple-c-net-<projectId>`. Tear down leftover MCP containers and
|
||||
// that network now, before the replacement is created without it.
|
||||
docker::remove_legacy_mcp_containers(&project.id).await;
|
||||
docker::remove_legacy_project_network(&project.id).await;
|
||||
|
||||
// Create from snapshot image (preserves system-level changes).
|
||||
// Re-resolved after the commit above: when no snapshot existed
|
||||
// before, one does now, and creating from the base instead
|
||||
// would throw away the state that was just saved.
|
||||
let create_image = if docker::image_exists(&snapshot_image).await.unwrap_or(false) {
|
||||
snapshot_image.clone()
|
||||
} else {
|
||||
image_name.clone()
|
||||
};
|
||||
|
||||
let new_id = docker::create_container_for_project(
|
||||
project,
|
||||
settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
progress("Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
|
||||
// The commit above moved `:latest` and orphaned the image it
|
||||
// used to point at; the container holding that image open was
|
||||
// removed a few lines up, so now is when Docker will actually
|
||||
// let it go. Detached because this is housekeeping and the
|
||||
// project is already running — and it sweeps every orphan, not
|
||||
// just this one, so recreations that happened before the sweep
|
||||
// existed are cleaned up too.
|
||||
tauri::async_runtime::spawn(async {
|
||||
docker::sweep_orphaned_snapshots_logged("after recreation").await;
|
||||
});
|
||||
|
||||
new_id
|
||||
} else {
|
||||
progress("Starting container...");
|
||||
docker::start_container(&existing_id).await?;
|
||||
existing_id
|
||||
}
|
||||
} else {
|
||||
// Container doesn't exist (first start, or Docker pruned it).
|
||||
// Check for a snapshot image first — it preserves system-level
|
||||
// changes (apt/pip/npm installs) from the previous session.
|
||||
if expected_create_image == snapshot_image {
|
||||
log::info!("Creating container from snapshot image for project {}", project.id);
|
||||
}
|
||||
let create_image = expected_create_image.clone();
|
||||
|
||||
progress("Creating container...");
|
||||
let new_id = docker::create_container_for_project(
|
||||
project,
|
||||
settings,
|
||||
&docker_socket,
|
||||
aws_config_path.as_deref(),
|
||||
&create_image,
|
||||
&image_name,
|
||||
docker::CreateExtras::default(),
|
||||
).await?;
|
||||
progress("Starting container...");
|
||||
docker::start_container(&new_id).await?;
|
||||
new_id
|
||||
};
|
||||
|
||||
Ok(container_id)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
/// End to end against a real daemon and the registry sandbox image: a
|
||||
/// throwaway project (fresh UUID, so its container, volumes and snapshot
|
||||
/// name are its own) is started, reached, stopped, started again on the
|
||||
/// *same* container, then removed and its state discarded.
|
||||
#[tokio::test]
|
||||
#[ignore = "needs a Docker daemon and the triple-c-sandbox image; creates and removes a throwaway project container"]
|
||||
async fn a_project_container_round_trips_through_the_host() {
|
||||
let project = Project::new("lifecycle-live-test".to_string(), Vec::new());
|
||||
let settings = AppSettings::default();
|
||||
let host = ProjectHost::for_project(&project);
|
||||
let steps = std::sync::Mutex::new(Vec::<String>::new());
|
||||
let progress = |m: &str| steps.lock().unwrap().push(m.to_string());
|
||||
// Failures inside the round trip return instead of panicking, so the
|
||||
// cleanup below always runs.
|
||||
fn check(ok: bool, what: &str) -> Result<(), String> {
|
||||
if ok { Ok(()) } else { Err(format!("check failed: {what}")) }
|
||||
}
|
||||
|
||||
let outcome = async {
|
||||
check(host.find_current(&project).await?.is_none(), "no container before the first start")?;
|
||||
|
||||
let first = host.ensure_started(&project, &settings, &progress).await?;
|
||||
check(first.is_running().await?, "running after start")?;
|
||||
let (out, code) = first
|
||||
.exec_oneshot_env_status(vec!["id".into(), "-un".into()], vec![])
|
||||
.await?;
|
||||
check(out.trim() == "claude" && code == 0, "exec runs as claude")?;
|
||||
check(host.find_current(&project).await? == Some(first.clone()), "find_current names it")?;
|
||||
|
||||
host.stop(&first).await?;
|
||||
check(first.has_exited().await, "exited after stop")?;
|
||||
|
||||
// Same config, so the existing container is started, not recreated.
|
||||
let second = host.ensure_started(&project, &settings, &progress).await?;
|
||||
check(second == first, "restart reuses the container")?;
|
||||
check(second.is_running().await?, "running after restart")?;
|
||||
|
||||
host.remove(&second).await?;
|
||||
check(host.find_current(&project).await?.is_none(), "gone after remove")?;
|
||||
Ok::<_, String>(())
|
||||
}
|
||||
.await;
|
||||
|
||||
// Always clean up, even after a failed assertion above.
|
||||
if let Ok(Some(t)) = host.find_current(&project).await {
|
||||
let _ = host.remove(&t).await;
|
||||
}
|
||||
let left = host.discard_project_state(&project).await;
|
||||
outcome.expect("round trip");
|
||||
assert_eq!(left, DiscardLeftovers::default(), "nothing left behind");
|
||||
let steps = steps.into_inner().unwrap();
|
||||
assert_eq!(
|
||||
steps,
|
||||
[
|
||||
"Checking image...",
|
||||
"Creating container...",
|
||||
"Starting container...",
|
||||
"Checking image...",
|
||||
"Starting container...",
|
||||
]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,567 @@
|
||||
//! 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.
|
||||
//! [`ContainerTarget::Agent`] reaches a container through the in-container
|
||||
//! `triple-c-agent` instead (`agent.rs`), which is how the AWS host will get in.
|
||||
//!
|
||||
//! ## 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 agent;
|
||||
pub mod lifecycle;
|
||||
pub mod sessions;
|
||||
pub mod watch;
|
||||
|
||||
pub use crate::docker::exec::{
|
||||
MAX_DROP_BYTES, OUTPUT_LIMIT_MARKER, PROC_NET_OUTPUT_LIMIT,
|
||||
};
|
||||
pub use agent::AgentEndpoint;
|
||||
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 },
|
||||
/// A container reached through the in-container `triple-c-agent`
|
||||
/// (`host/agent.rs`; protocol in
|
||||
/// `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`). Used
|
||||
/// by the AWS host (later) and the local agent harness. The host that
|
||||
/// runs the container, not this target, owns its lifecycle.
|
||||
Agent(AgentEndpoint),
|
||||
}
|
||||
|
||||
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,
|
||||
ContainerTarget::Agent(ep) => ep.base_url.as_str(),
|
||||
}
|
||||
}
|
||||
|
||||
// ── 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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::is_running(ep).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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, Vec::new())
|
||||
.await
|
||||
.map(|(out, _)| out),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", cmd, env)
|
||||
.await
|
||||
.map(|(out, _)| out),
|
||||
}
|
||||
}
|
||||
|
||||
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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, "claude", 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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_as(ep, 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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::exec_oneshot_limited(ep, user, cmd, Vec::new(), 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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_oneshot_streams_as(ep, 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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::exec_detached(ep, &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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::attach(ep, &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,
|
||||
ContainerTarget::Agent(ep) => agent::wait_exec_exit(ep, 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,
|
||||
ContainerTarget::Agent(ep) => agent::resize_exec(ep, 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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::user_ids(ep).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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_bytes_with_ids(ep, dest_dir, file_name, data, mode, (0, 0)).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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_bytes_with_ids(ep, 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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
let ids = agent::user_ids(ep).await;
|
||||
agent::upload_host_file_with_ids(ep, host_path, dest_dir, dest_name, ids).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
|
||||
}
|
||||
ContainerTarget::Agent(ep) => {
|
||||
agent::upload_host_file_with_ids(ep, 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)
|
||||
}
|
||||
ContainerTarget::Agent(ep) => agent::download_archive(ep, 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");
|
||||
}
|
||||
|
||||
/// Every non-`docker/`, non-`host/` source file, with its path relative to
|
||||
/// `src/`, minus the files in `allowed`.
|
||||
fn sources_outside_the_host_layer(allowed: &[&str]) -> Vec<(String, String)> {
|
||||
let src = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("src");
|
||||
let mut out = 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()) {
|
||||
out.push((rel, std::fs::read_to_string(&path).unwrap()));
|
||||
}
|
||||
}
|
||||
}
|
||||
out.sort();
|
||||
out
|
||||
}
|
||||
|
||||
/// Docker-only by nature: base-image migration replays apt, restores a
|
||||
/// payload, commits and swaps containers. It has no AWS meaning.
|
||||
const DOCKER_ONLY: &[&str] = &["commands/migration_commands.rs"];
|
||||
|
||||
/// The host boundary, pinned: outside `docker/` and `host/`, nothing names
|
||||
/// bollard or `docker::exec`. Container-interior code goes through
|
||||
/// [`ContainerTarget`]. Shrink [`DOCKER_ONLY`]; do not grow it.
|
||||
#[test]
|
||||
fn no_container_interior_bollard_outside_the_host_layer() {
|
||||
let offenders: Vec<String> = sources_outside_the_host_layer(DOCKER_ONLY)
|
||||
.into_iter()
|
||||
.filter(|(_, text)| {
|
||||
text.contains("bollard::")
|
||||
|| text.contains("docker::exec")
|
||||
|| text.contains("get_docker")
|
||||
})
|
||||
.map(|(rel, _)| rel)
|
||||
.collect();
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"these files reach into the container without going through host::ContainerTarget: {:?}",
|
||||
offenders
|
||||
);
|
||||
}
|
||||
|
||||
/// The lifecycle half of the boundary: outside `docker/` and `host/`, no
|
||||
/// non-test code creates, starts, stops, finds or recreates a container
|
||||
/// itself (that is [`lifecycle::ProjectHost`]'s job), and none builds a
|
||||
/// [`ContainerTarget::docker`] from a bare id, which would hard-code the
|
||||
/// host. Comments and `#[cfg(test)]` code are not checked.
|
||||
///
|
||||
/// `docker::remove_container` is deliberately absent: startup's
|
||||
/// pending-cleanup retry removes Docker leftovers it recorded *by name*,
|
||||
/// which is Docker housekeeping, not lifecycle.
|
||||
#[test]
|
||||
fn no_container_lifecycle_or_bare_docker_targets_outside_the_host_layer() {
|
||||
const BANNED: &[&str] = &[
|
||||
"docker::create_container(",
|
||||
"docker::start_container(",
|
||||
"docker::stop_container(",
|
||||
"docker::find_existing_container(",
|
||||
"docker::container_needs_recreation(",
|
||||
"docker::commit_container_snapshot(",
|
||||
"docker::remove_snapshot_image(",
|
||||
"docker::remove_project_volumes(",
|
||||
"docker::is_container_running(",
|
||||
"container::is_container_running(",
|
||||
"ContainerTarget::docker(",
|
||||
];
|
||||
let mut offenders = Vec::new();
|
||||
for (rel, text) in sources_outside_the_host_layer(DOCKER_ONLY) {
|
||||
let code = text.split("#[cfg(test)]").next().unwrap_or("");
|
||||
for (n, line) in code.lines().enumerate() {
|
||||
let line = line.trim_start();
|
||||
if line.starts_with("//") {
|
||||
continue;
|
||||
}
|
||||
for pat in BANNED {
|
||||
if line.contains(pat) {
|
||||
offenders.push(format!("{}:{}: {}", rel, n + 1, pat));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
assert!(
|
||||
offenders.is_empty(),
|
||||
"container lifecycle / bare Docker targets outside host::lifecycle:\n{}",
|
||||
offenders.join("\n")
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
//! Where `container_watch` learns that a project container stopped on its
|
||||
//! own. Host-neutral shape: a reachability check, a stream of containers
|
||||
//! reported dead (replayable from a point in time), and a positive-exit check
|
||||
//! used to confirm each report before anything changes.
|
||||
//!
|
||||
//! Docker pushes `die` events. An AWS host will have no push stream from the
|
||||
//! app's point of view; its [`deaths`] will be a `DescribeTasks` poll that
|
||||
//! yields stopped tasks, which `container_watch` handles the same way because
|
||||
//! every report is confirmed with [`ContainerTarget::has_exited`] anyway.
|
||||
|
||||
use futures_util::{Stream, StreamExt};
|
||||
use std::pin::Pin;
|
||||
|
||||
use super::ContainerTarget;
|
||||
use crate::docker;
|
||||
|
||||
/// Which host's deaths to watch. One variant today.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ExitSource {
|
||||
Docker,
|
||||
}
|
||||
|
||||
pub type DeathStream = Pin<Box<dyn Stream<Item = Result<ContainerTarget, String>> + Send>>;
|
||||
|
||||
impl ExitSource {
|
||||
/// Whether the host answers at all. A `false` here is a failed connection
|
||||
/// attempt for the watcher's backoff.
|
||||
pub async fn reachable(&self) -> bool {
|
||||
match self {
|
||||
ExitSource::Docker => docker::events::ping().await,
|
||||
}
|
||||
}
|
||||
|
||||
/// Containers reported dead, replayed from `since` (unix seconds). An `Err`
|
||||
/// item, or the stream ending, means the connection is gone.
|
||||
pub fn deaths(&self, since: i64) -> DeathStream {
|
||||
match self {
|
||||
ExitSource::Docker => Box::pin(
|
||||
docker::events::container_deaths(since).map(|r| r.map(ContainerTarget::docker)),
|
||||
),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl ContainerTarget {
|
||||
/// `true` only when the host positively says the container is not running
|
||||
/// (stopped, or gone). Any failure to find out is `false` — unlike
|
||||
/// [`ContainerTarget::is_running`], whose callers want errors to read as
|
||||
/// "not running".
|
||||
pub async fn has_exited(&self) -> bool {
|
||||
match self {
|
||||
ContainerTarget::Docker { container_id } => docker::events::has_exited(container_id).await,
|
||||
// The agent can only say "I answer" or "I don't"; silence is not a
|
||||
// positive exit (an SSM blip looks the same). Only the host that
|
||||
// runs the container can confirm a death — an AWS host answers
|
||||
// from ECS — so through the agent alone this never confirms one.
|
||||
ContainerTarget::Agent(_) => false,
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
{
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -26,6 +26,7 @@ use crate::models::marketplace::{
|
||||
effective_installs, CatalogItem, ItemKind, ItemUpdate, Marketplace, MarketplaceInstall, MarketplaceSnapshot,
|
||||
SyncReport,
|
||||
};
|
||||
use crate::host::ContainerTarget;
|
||||
use crate::models::{AppSettings, Project};
|
||||
use catalog::{item_fingerprint, parse_catalog};
|
||||
use tree::GitTree;
|
||||
@@ -505,7 +506,7 @@ pub async fn sync_project(
|
||||
mgr: &MarketplaceManager,
|
||||
settings: &AppSettings,
|
||||
project: &Project,
|
||||
container_id: &str,
|
||||
container: &ContainerTarget,
|
||||
) -> SyncReport {
|
||||
let lock = mgr.sync_lock(&project.id);
|
||||
let _sync_guard = lock.lock().await;
|
||||
@@ -533,7 +534,7 @@ pub async fn sync_project(
|
||||
items,
|
||||
p.skipped.len()
|
||||
);
|
||||
let result = sync::sync_container(container_id, &p).await;
|
||||
let result = sync::sync_container(container, &p).await;
|
||||
sync::with_payload_skips(sync::report_from_result(result), &p.skipped)
|
||||
}
|
||||
Err(e) => sync::report_from_result(Err(e)),
|
||||
@@ -556,13 +557,16 @@ pub fn spawn_project_sync(
|
||||
mgr: Arc<MarketplaceManager>,
|
||||
settings: AppSettings,
|
||||
project: Project,
|
||||
container_id: String,
|
||||
// The target the start just produced — not `ContainerTarget::for_project`:
|
||||
// `project.container_id` is written after this is called, so it could name
|
||||
// a container that was just replaced, or none at all.
|
||||
container: ContainerTarget,
|
||||
) {
|
||||
if !should_sync(&mgr, &settings, &project) {
|
||||
return;
|
||||
}
|
||||
tauri::async_runtime::spawn(async move {
|
||||
let report = sync_project(&mgr, &settings, &project, &container_id).await;
|
||||
let report = sync_project(&mgr, &settings, &project, &container).await;
|
||||
if !report.errors.is_empty() {
|
||||
log::warn!(
|
||||
"Marketplace sync for project {} reported errors: {:?}",
|
||||
@@ -1132,9 +1136,10 @@ mod tests {
|
||||
|
||||
let lock = mgr.sync_lock(&project.id);
|
||||
let guard = lock.lock().await;
|
||||
let nowhere = ContainerTarget::docker("no-such-container");
|
||||
let blocked = tokio::time::timeout(
|
||||
std::time::Duration::from_millis(300),
|
||||
sync_project(&mgr, &settings, &project, "no-such-container"),
|
||||
sync_project(&mgr, &settings, &project, &nowhere),
|
||||
)
|
||||
.await;
|
||||
assert!(blocked.is_err(), "sync must wait while another sync holds the lock");
|
||||
@@ -1143,7 +1148,7 @@ mod tests {
|
||||
|
||||
// No Docker (or no such container) here: the failure becomes a stored
|
||||
// report instead of an error.
|
||||
let report = sync_project(&mgr, &settings, &project, "no-such-container").await;
|
||||
let report = sync_project(&mgr, &settings, &project, &nowhere).await;
|
||||
assert_eq!(report.errors.len(), 1, "{report:?}");
|
||||
assert!(!report.finished_at.is_empty());
|
||||
assert_eq!(mgr.report(&project.id), Some(report));
|
||||
|
||||
@@ -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(());
|
||||
}
|
||||
@@ -81,35 +92,31 @@ 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?;
|
||||
pub async fn sync_container(
|
||||
target: &ContainerTarget,
|
||||
payload: &Payload,
|
||||
) -> Result<SyncReport, String> {
|
||||
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 +203,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(() => {
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 };
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
# Build output of the agent crate must never enter the image build context.
|
||||
triple-c-agent/target
|
||||
+57
-2
@@ -1,3 +1,31 @@
|
||||
# ── triple-c-agent ─────────────────────────────────────────────────────────
|
||||
# The in-container agent the AWS host reaches the container through
|
||||
# (docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md). A static
|
||||
# musl binary, so it needs nothing from the image below.
|
||||
#
|
||||
# The stage runs on the *build* machine's platform and cross-compiles for the
|
||||
# target: CI builds amd64 and arm64 together, and compiling Rust for arm64
|
||||
# under QEMU is many times slower. BuildKit predefines BUILDPLATFORM and
|
||||
# TARGETARCH; the app's local build (the classic builder) passes them itself
|
||||
# (docker/image.rs, `local_build_args`), because there an empty `--platform=`
|
||||
# fails the build. Pure-Rust dependencies only, so rust-lld links aarch64
|
||||
# with no C cross toolchain (see container/triple-c-agent/README.md).
|
||||
ARG BUILDPLATFORM
|
||||
FROM --platform=$BUILDPLATFORM rust:1.98-slim-bookworm AS agent-build
|
||||
ARG TARGETARCH
|
||||
RUN rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
|
||||
WORKDIR /src
|
||||
COPY triple-c-agent/Cargo.toml triple-c-agent/Cargo.lock ./
|
||||
COPY triple-c-agent/src ./src
|
||||
RUN case "$TARGETARCH" in \
|
||||
amd64) target=x86_64-unknown-linux-musl ;; \
|
||||
arm64) target=aarch64-unknown-linux-musl ;; \
|
||||
*) echo "triple-c-agent: unsupported TARGETARCH '$TARGETARCH'" >&2; exit 1 ;; \
|
||||
esac \
|
||||
&& CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \
|
||||
cargo build --release --locked --target "$target" \
|
||||
&& install -D -m 0755 "target/$target/release/triple-c-agent" /out/triple-c-agent
|
||||
|
||||
FROM ubuntu:24.04
|
||||
|
||||
# ── Provenance labels ────────────────────────────────────────────────────────
|
||||
@@ -28,12 +56,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 +323,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/*; \
|
||||
@@ -437,5 +488,9 @@ COPY triple-c-playwright-heal /usr/local/bin/triple-c-playwright-heal
|
||||
RUN chmod +x /usr/local/bin/triple-c-playwright-heal
|
||||
COPY triple-c-task-runner /usr/local/bin/triple-c-task-runner
|
||||
RUN chmod +x /usr/local/bin/triple-c-task-runner
|
||||
# Root-owned in /usr/local/bin: entrypoint.sh starts it as root (only when a
|
||||
# token is configured), and it rides the snapshot and migration like the other
|
||||
# shims here.
|
||||
COPY --from=agent-build /out/triple-c-agent /usr/local/bin/triple-c-agent
|
||||
|
||||
ENTRYPOINT ["/usr/local/bin/entrypoint.sh"]
|
||||
+131
-2
@@ -13,6 +13,50 @@
|
||||
# upgradable content in /usr/local/bin or /opt, or seed it from here.
|
||||
# See "Container Lifecycle" in the repo's CLAUDE.md.
|
||||
|
||||
# ── triple-c-agent token: off the environment before anything else runs ───
|
||||
# The agent (started at the very end, see start_agent) authenticates every
|
||||
# request with this token, and it runs as root, so the token is effectively
|
||||
# root in this container. Given as an env var it would be inherited by every
|
||||
# process this script starts (cron, the scheduler, Claude sessions spawned by
|
||||
# the agent). So it moves to a root-only file *first* and leaves the
|
||||
# environment; the agent reads the file. A `docker exec` session still sees
|
||||
# the container's configured env — the local test harness passes it that way
|
||||
# and accepts that; on AWS nothing is started by `docker exec`.
|
||||
#
|
||||
# The agent also takes its exec environment and default working directory
|
||||
# from how *it* was started, and execs must see what `docker exec` would: the
|
||||
# container's configured env and WorkingDir, not whatever this script has
|
||||
# exported or cd'd into by the time it gets to start_agent. So both are
|
||||
# snapshotted here, before anything else runs, and the agent is started from
|
||||
# the snapshot (`env -i`). The agent drops its own TRIPLEC_AGENT_* settings.
|
||||
# >>> agent_token
|
||||
agent_cwd=$PWD
|
||||
# The token goes on a tmpfs, never the container's writable layer: a file
|
||||
# there would persist after stop and ride `docker commit` into the project's
|
||||
# snapshot image (the commit's SECRET_ENV_KEYS scrub covers env, not files).
|
||||
# /dev/shm is a tmpfs in Docker and on Fargate; anything else is refused.
|
||||
if [ -n "${TRIPLEC_AGENT_TOKEN:-}" ]; then
|
||||
if [ "$(stat -f -c %T /dev/shm 2>/dev/null)" = tmpfs ]; then
|
||||
mkdir -p /dev/shm/triple-c
|
||||
chown root:root /dev/shm/triple-c
|
||||
chmod 700 /dev/shm/triple-c
|
||||
( umask 077; printf '%s' "$TRIPLEC_AGENT_TOKEN" > /dev/shm/triple-c/agent-token )
|
||||
export TRIPLEC_AGENT_TOKEN_FILE=/dev/shm/triple-c/agent-token
|
||||
else
|
||||
echo "triple-c-agent: /dev/shm is not a tmpfs; not writing the token to disk — agent disabled" >&2
|
||||
fi
|
||||
unset TRIPLEC_AGENT_TOKEN
|
||||
fi
|
||||
# The exec environment snapshot stays in this script's memory — it holds the
|
||||
# container's whole configured env, API keys included, so it must not touch
|
||||
# disk either. Minus what bash itself exports (PWD, OLDPWD, SHLVL, `_`):
|
||||
# those are this script's, not the container's, and `docker exec` has none.
|
||||
agent_env=()
|
||||
if [ -n "${TRIPLEC_AGENT_TOKEN_FILE:-}" ]; then
|
||||
mapfile -d '' agent_env < <(env -u PWD -u OLDPWD -u SHLVL -u _ -0)
|
||||
fi
|
||||
# <<< agent_token
|
||||
|
||||
# ── UID/GID remapping ──────────────────────────────────────────────────────
|
||||
# Match the container's claude user to the host user's UID/GID so that
|
||||
# bind-mounted files (project dir, docker socket) have correct ownership.
|
||||
@@ -736,6 +780,91 @@ 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.
|
||||
# ── triple-c-agent ──────────────────────────────────────────────────────────
|
||||
# The app's way into the container on hosts without a Docker API (AWS, over
|
||||
# an SSM port-forward). Started only when a token was provided, and only now,
|
||||
# after every setup step above, so "the agent answers /v1/health" also means
|
||||
# "the container is set up" — the AWS host's readiness signal.
|
||||
#
|
||||
# Same survival rules as the keepalive below, for the same reason: it runs as
|
||||
# root under a root supervisor loop, so a claude-user `pkill`/`kill -9 -1`
|
||||
# gets EPERM, and a crash is restarted (throttled: a binary that exits at
|
||||
# once must not spin a core).
|
||||
# >>> start_agent
|
||||
start_agent() {
|
||||
[ -n "${TRIPLEC_AGENT_TOKEN_FILE:-}" ] || return 0
|
||||
if [ ! -x /usr/local/bin/triple-c-agent ]; then
|
||||
echo "triple-c-agent: token given but no agent binary in this image — not starting it" >&2
|
||||
return 0
|
||||
fi
|
||||
(
|
||||
# Keeps the supervisor alive through a stray HUP. The agent resets
|
||||
# every signal disposition and the mask in each exec it spawns, so this
|
||||
# does not leak into commands (an ignored SIGHUP otherwise survives
|
||||
# exec and turns every exec into `nohup`).
|
||||
trap '' HUP
|
||||
cd "$agent_cwd" || cd /
|
||||
while :; do
|
||||
agent_started=$SECONDS
|
||||
env -i "${agent_env[@]}" /usr/local/bin/triple-c-agent
|
||||
echo "triple-c-agent exited ($?) — restarting" >&2
|
||||
[ $((SECONDS - agent_started)) -ge 5 ] || sleep 5
|
||||
done
|
||||
) &
|
||||
}
|
||||
# <<< start_agent
|
||||
start_agent
|
||||
|
||||
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
|
||||
Generated
+1301
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,48 @@
|
||||
[package]
|
||||
name = "triple-c-agent"
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "In-container agent: docker-exec parity over authenticated HTTP/WebSocket"
|
||||
publish = false
|
||||
|
||||
# Deliberately not part of any workspace: this binary is built for
|
||||
# *-unknown-linux-musl and baked into the container image, on its own cadence.
|
||||
# The empty table stops cargo from walking up and adopting it into one.
|
||||
[workspace]
|
||||
|
||||
[lib]
|
||||
name = "triple_c_agent"
|
||||
path = "src/lib.rs"
|
||||
|
||||
[[bin]]
|
||||
name = "triple-c-agent"
|
||||
path = "src/main.rs"
|
||||
|
||||
# Pure-Rust only — no TLS, no openssl, nothing with a C build step — so the
|
||||
# binary links statically for both musl targets without a C cross toolchain.
|
||||
[dependencies]
|
||||
axum = { version = "0.8", default-features = false, features = ["http1", "tokio", "json", "query", "ws"] }
|
||||
base64 = "0.22"
|
||||
bytes = "1"
|
||||
futures-util = { version = "0.3", default-features = false, features = ["std", "sink"] }
|
||||
# Already in the tree through axum, with these features; named directly for
|
||||
# the accept loop in `lib.rs::serve` (header-read timeout needs a hyper timer).
|
||||
hyper = { version = "1", default-features = false, features = ["http1", "server"] }
|
||||
hyper-util = { version = "0.1", default-features = false, features = ["tokio", "server", "service"] }
|
||||
libc = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
tar = { version = "0.4", default-features = false }
|
||||
tokio = { version = "1", features = ["rt-multi-thread", "macros", "net", "process", "io-util", "sync", "time", "fs"] }
|
||||
|
||||
[dev-dependencies]
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json"] }
|
||||
tempfile = "3"
|
||||
tokio-tungstenite = { version = "0.29", default-features = false, features = ["connect"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = "s"
|
||||
lto = true
|
||||
codegen-units = 1
|
||||
strip = true
|
||||
panic = "abort"
|
||||
@@ -0,0 +1,112 @@
|
||||
# triple-c-agent
|
||||
|
||||
The in-container agent: a small static binary that does over authenticated HTTP/WebSocket what
|
||||
`host::ContainerTarget` does through the Docker API. It provides one-shot, attached and detached
|
||||
execs, resize, exit status, file upload, archive download and TCP tunnels. The wire protocol is
|
||||
`docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`, and that spec is the contract.
|
||||
This crate implements it and does not extend it.
|
||||
|
||||
It is a standalone crate: not part of any workspace, with its own `Cargo.lock`.
|
||||
|
||||
## Running
|
||||
|
||||
| Variable | Meaning |
|
||||
|---|---|
|
||||
| `TRIPLEC_AGENT_TOKEN` | Bearer token, at least 32 bytes. Removed from the agent's env before anything is spawned, so no exec inherits it — but see below. |
|
||||
| `TRIPLEC_AGENT_TOKEN_FILE` | Read when the env var is unset or empty. Must be owned by the agent's uid and not readable by others. Surrounding whitespace is trimmed. **Preferred.** |
|
||||
| `TRIPLEC_AGENT_ADDR` | Listen address. Default `127.0.0.1:47900`. |
|
||||
|
||||
Removing `TRIPLEC_AGENT_TOKEN` only edits the process's libc environment. The kernel keeps the
|
||||
environment the agent was started with, so `/proc/<agent pid>/environ` still shows the token to root
|
||||
and to any process running as the agent's uid. Use `TRIPLEC_AGENT_TOKEN_FILE`; the entrypoint does.
|
||||
|
||||
Connections are HTTP/1.1. A client gets 10 seconds to send a complete request head, and at most 256
|
||||
connections are served at once; past that, new connections wait in the listen backlog for a slot.
|
||||
WebSocket sessions (attach, tunnel) stop counting once upgraded, and accept messages of at most
|
||||
1 MiB.
|
||||
|
||||
Execs start with every signal at its default disposition and an empty signal mask, whatever the
|
||||
agent itself inherited (the entrypoint's supervisor ignores `SIGHUP`), and their stdin/stdout/stderr
|
||||
are owned by the exec's user unless they are `/dev/null`, as runc arranges for `docker exec`.
|
||||
|
||||
Without a token of at least 32 bytes the agent exits with status 2. There is no unauthenticated mode. On
|
||||
startup it logs a single line, `triple-c-agent <version> listening on <addr>`, to stderr. The token
|
||||
is never logged.
|
||||
|
||||
It must run **as root** (see CLAUDE.md, "Never hand the container's lifetime to a process the
|
||||
`claude` user owns"). Execs then switch to the requested user. A non-root agent can only run execs
|
||||
as itself and answers `403` for any other user. The tests rely on that.
|
||||
|
||||
## Tests
|
||||
|
||||
```bash
|
||||
cd container/triple-c-agent
|
||||
cargo test # everything, as the current user
|
||||
cargo clippy --all-targets
|
||||
# The root-only tests (user switch, upload chown, stdio ownership), as root:
|
||||
sudo -E env "PATH=$PATH" CARGO_HOME="$HOME/.cargo" RUSTUP_HOME="$HOME/.rustup" \
|
||||
cargo test -- --include-ignored
|
||||
sudo chown -R "$(id -u):$(id -g)" target # sudo leaves root-owned build files behind
|
||||
```
|
||||
|
||||
The integration tests (`tests/protocol.rs`) start the real server in-process on `127.0.0.1:0` and
|
||||
drive every endpoint over real HTTP and WebSocket.
|
||||
|
||||
## Static release builds
|
||||
|
||||
Both targets build from an x86_64 Linux host with **no C cross toolchain**: every dependency is
|
||||
pure Rust, and Rust's musl targets ship a self-contained CRT. aarch64 links with the `rust-lld`
|
||||
that comes with the toolchain. Verified with rustc 1.98.0:
|
||||
|
||||
```bash
|
||||
rustup target add x86_64-unknown-linux-musl aarch64-unknown-linux-musl
|
||||
|
||||
cd container/triple-c-agent
|
||||
cargo build --release --target x86_64-unknown-linux-musl
|
||||
CARGO_TARGET_AARCH64_UNKNOWN_LINUX_MUSL_LINKER=rust-lld \
|
||||
cargo build --release --target aarch64-unknown-linux-musl
|
||||
```
|
||||
|
||||
Output:
|
||||
|
||||
- `target/x86_64-unknown-linux-musl/release/triple-c-agent`: `static-pie linked`, about 1.3 MB.
|
||||
- `target/aarch64-unknown-linux-musl/release/triple-c-agent`: `statically linked`, about 1.2 MB.
|
||||
|
||||
Check a build with `file <binary>`. It must say `statically linked` or `static-pie linked`, and never
|
||||
`dynamically linked` or `interpreter`.
|
||||
|
||||
Do not add a dependency that needs a C compiler (`ring`, `openssl-sys`, anything using `cc`). The
|
||||
aarch64 build would then need a cross C toolchain or `cargo-zigbuild`. TLS is out of scope for v1
|
||||
anyway, because the transport is the SSM channel or loopback.
|
||||
|
||||
In the image, `container/Dockerfile`'s `agent-build` stage does this on the build platform for
|
||||
`$TARGETARCH`; the app's local build passes those build args itself (`docker/image.rs`).
|
||||
|
||||
## Docker parity test
|
||||
|
||||
The requirement is that every operation through the agent comes back exactly as it does through
|
||||
`docker exec`. `app/src-tauri/src/host/agent/live_tests.rs` checks that in one real container,
|
||||
running each operation through both `ContainerTarget::Docker` and `ContainerTarget::Agent` and
|
||||
comparing. It needs an image whose entrypoint starts the agent. To build one from the published
|
||||
sandbox image plus this branch's agent and entrypoint:
|
||||
|
||||
```bash
|
||||
cargo build --release --target x86_64-unknown-linux-musl # or aarch64, on an arm64 host
|
||||
ctx=$(mktemp -d)
|
||||
cp target/x86_64-unknown-linux-musl/release/triple-c-agent ../entrypoint.sh "$ctx"/
|
||||
cat > "$ctx/Dockerfile" <<'DOCKERFILE'
|
||||
FROM ghcr.io/shadowdao/triple-c-sandbox:latest
|
||||
COPY entrypoint.sh /usr/local/bin/entrypoint.sh
|
||||
COPY triple-c-agent /usr/local/bin/triple-c-agent
|
||||
RUN chmod 0755 /usr/local/bin/entrypoint.sh /usr/local/bin/triple-c-agent
|
||||
DOCKERFILE
|
||||
docker build -t tc-agent-e2e:latest "$ctx"
|
||||
|
||||
cd ../../app/src-tauri
|
||||
cargo test --lib the_agent_answers_exactly_as_docker_exec_does -- --ignored --nocapture
|
||||
```
|
||||
|
||||
The test starts and removes its own container. It reaches the agent on the published loopback port
|
||||
when run on the host, or on the container's bridge address when run from inside another container
|
||||
on the same daemon. It has already caught one real divergence: `docker exec` sets only `HOME` for
|
||||
the exec's user, never `USER`/`LOGNAME`.
|
||||
@@ -0,0 +1,340 @@
|
||||
//! `GET /v1/attach`: an exec with its stdio carried over a WebSocket.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::State;
|
||||
use axum::response::Response;
|
||||
use bytes::Bytes;
|
||||
use serde::Deserialize;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::sync::{mpsc, oneshot};
|
||||
|
||||
use crate::pty;
|
||||
use crate::spawn::{self, ExecSpec, Io, PrepareError};
|
||||
use crate::{AppState, CountGuard, MAX_WS_MESSAGE};
|
||||
|
||||
/// How long the client has to send its spec after the upgrade.
|
||||
const SPEC_TIMEOUT: Duration = Duration::from_secs(30);
|
||||
|
||||
/// After a tty exec's own process exits, how long to keep draining the pty
|
||||
/// for output still in flight. Waiting for EOF alone is not enough: a
|
||||
/// background job that kept the terminal open would hold the attach (and its
|
||||
/// exit frame) forever, while Docker ends the stream when the exec exits.
|
||||
const TTY_DRAIN_IDLE: Duration = Duration::from_millis(100);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
struct AttachRequest {
|
||||
#[serde(flatten)]
|
||||
spec: ExecSpec,
|
||||
#[serde(default)]
|
||||
attach_stdin: bool,
|
||||
#[serde(default)]
|
||||
cols: Option<u16>,
|
||||
#[serde(default)]
|
||||
rows: Option<u16>,
|
||||
}
|
||||
|
||||
/// What the pump sends to the socket.
|
||||
enum Event {
|
||||
Out(u8, Bytes),
|
||||
Exit(i32),
|
||||
}
|
||||
|
||||
/// What the socket sends to the process.
|
||||
enum Input {
|
||||
Data(Bytes),
|
||||
}
|
||||
|
||||
pub async fn upgrade(State(st): State<Arc<AppState>>, ws: WebSocketUpgrade) -> Response {
|
||||
ws.max_message_size(MAX_WS_MESSAGE)
|
||||
.max_frame_size(MAX_WS_MESSAGE)
|
||||
.on_upgrade(move |socket| session(socket, st))
|
||||
}
|
||||
|
||||
fn text(v: serde_json::Value) -> Message {
|
||||
Message::Text(v.to_string().into())
|
||||
}
|
||||
|
||||
async fn fail(mut socket: WebSocket, msg: String) {
|
||||
let _ = socket.send(text(serde_json::json!({ "type": "error", "error": msg }))).await;
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
fn frame(channel: u8, data: &[u8]) -> Message {
|
||||
let mut v = Vec::with_capacity(data.len() + 1);
|
||||
v.push(channel);
|
||||
v.extend_from_slice(data);
|
||||
Message::Binary(v.into())
|
||||
}
|
||||
|
||||
async fn session(mut socket: WebSocket, st: Arc<AppState>) {
|
||||
// 1. The spec, as one text frame.
|
||||
let first = match tokio::time::timeout(SPEC_TIMEOUT, socket.recv()).await {
|
||||
Ok(Some(Ok(Message::Text(t)))) => t,
|
||||
Ok(Some(Ok(_))) => return fail(socket, "the first frame must be a text ExecSpec".into()).await,
|
||||
_ => return,
|
||||
};
|
||||
let req: AttachRequest = match serde_json::from_str(first.as_str()) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return fail(socket, format!("invalid attach request: {e}")).await,
|
||||
};
|
||||
let tty = req.spec.tty;
|
||||
|
||||
let prepared = match spawn::prepare(&st.config, &req.spec, tty) {
|
||||
Ok(p) => p,
|
||||
Err(PrepareError::Refused(_, m)) => return fail(socket, m).await,
|
||||
Err(PrepareError::CannotStart { code, message }) => {
|
||||
return not_started(socket, &st, code, message).await
|
||||
}
|
||||
};
|
||||
|
||||
// 2. Start it. A tty exec gets the pty slave on all three fds; stdin
|
||||
// stays the pty even with `attach_stdin: false` (the client's bytes are
|
||||
// just ignored), because `stty`, `read -s` and friends need a terminal on
|
||||
// fd 0 — the same as a Docker tty exec without `-i`.
|
||||
let (child, master) = if tty {
|
||||
let (master, slave) = match pty::open(req.cols.unwrap_or(80), req.rows.unwrap_or(24)) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return fail(socket, format!("cannot open a pty: {e}")).await,
|
||||
};
|
||||
let fds = (|| Ok::<_, std::io::Error>((slave.try_clone()?, slave.try_clone()?, slave)))();
|
||||
let (a, b, c) = match fds {
|
||||
Ok(f) => f,
|
||||
Err(e) => return fail(socket, format!("cannot open a pty: {e}")).await,
|
||||
};
|
||||
// The slave copies are moved into the Command and closed when this
|
||||
// returns, so the master sees EOF once the *child's* copies close.
|
||||
match prepared.spawn(Io::Fd(a), Io::Fd(b), Io::Fd(c), true) {
|
||||
Ok(child) => (child, Some(Arc::new(master))),
|
||||
Err(PrepareError::CannotStart { code, message }) => {
|
||||
return not_started(socket, &st, code, message).await
|
||||
}
|
||||
Err(PrepareError::Refused(_, m)) => return fail(socket, m).await,
|
||||
}
|
||||
} else {
|
||||
let stdin = if req.attach_stdin { Io::Piped } else { Io::Null };
|
||||
match prepared.spawn(stdin, Io::Piped, Io::Piped, false) {
|
||||
Ok(child) => (child, None),
|
||||
Err(PrepareError::CannotStart { code, message }) => {
|
||||
return not_started(socket, &st, code, message).await
|
||||
}
|
||||
Err(PrepareError::Refused(_, m)) => return fail(socket, m).await,
|
||||
}
|
||||
};
|
||||
let exec_id = st.execs.insert_running(master.clone());
|
||||
if socket
|
||||
.send(text(serde_json::json!({ "type": "started", "exec_id": exec_id })))
|
||||
.await
|
||||
.is_err()
|
||||
{
|
||||
// Gone before it began; still let the process run to an exit code.
|
||||
}
|
||||
|
||||
// 3. The pump owns the child and its output for the process's whole life,
|
||||
// independent of the socket: if the client leaves, it keeps draining (and
|
||||
// discarding) output so the process is never wedged on a full pipe, and
|
||||
// it records the exit code either way.
|
||||
let (ev_tx, mut ev_rx) = mpsc::channel::<Event>(64);
|
||||
let (in_tx, in_rx) = mpsc::unbounded_channel::<Input>();
|
||||
let (hangup_tx, hangup_rx) = oneshot::channel::<()>();
|
||||
let guard = CountGuard::execs(&st);
|
||||
{
|
||||
let st = st.clone();
|
||||
let exec_id = exec_id.clone();
|
||||
let master = master.clone();
|
||||
tokio::spawn(async move {
|
||||
let _active = guard;
|
||||
let code = match master {
|
||||
Some(m) => pump_tty(child, m, in_rx, hangup_rx, req.attach_stdin, &ev_tx).await,
|
||||
None => pump_pipes(child, in_rx, &ev_tx).await,
|
||||
};
|
||||
st.execs.finish(&exec_id, code);
|
||||
let _ = ev_tx.send(Event::Exit(code)).await;
|
||||
});
|
||||
}
|
||||
|
||||
// 4. Shuttle between socket and pump. Unbounded stdin on purpose: a child
|
||||
// that is not reading stdin must not stop us forwarding its output, or a
|
||||
// heavy two-way stream deadlocks. The client is authenticated; how much it
|
||||
// queues is its own business, as with Docker's hijacked connection.
|
||||
// The loop only `break`s when the client has gone; the normal end (exit
|
||||
// frame sent) `return`s from inside it.
|
||||
let mut in_tx = Some(in_tx);
|
||||
loop {
|
||||
tokio::select! {
|
||||
ev = ev_rx.recv() => match ev {
|
||||
Some(Event::Out(ch, data)) => {
|
||||
st.activity.touch();
|
||||
if socket.send(frame(ch, &data)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Some(Event::Exit(code)) => {
|
||||
let _ = socket.send(text(serde_json::json!({ "type": "exit", "code": code }))).await;
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
return;
|
||||
}
|
||||
None => return,
|
||||
},
|
||||
msg = socket.recv() => match msg {
|
||||
Some(Ok(Message::Binary(data))) => {
|
||||
st.activity.touch();
|
||||
if req.attach_stdin {
|
||||
if let Some(tx) = &in_tx {
|
||||
let _ = tx.send(Input::Data(data));
|
||||
}
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Text(t))) => {
|
||||
let is_close = serde_json::from_str::<serde_json::Value>(t.as_str())
|
||||
.ok()
|
||||
.is_some_and(|v| v["type"] == "close_stdin");
|
||||
if is_close {
|
||||
// Dropping the sender ends the writer, which drops the
|
||||
// child's stdin: EOF, like Docker's CloseWrite.
|
||||
in_tx = None;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Ping(_) | Message::Pong(_))) => {}
|
||||
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => break,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// 5. The client went away, as a dropped `docker exec` does. A tty exec
|
||||
// gets SIGHUP on its process group — what a terminal hangup delivers. A
|
||||
// non-tty exec only loses stdin and is NOT killed: Docker does not kill
|
||||
// it either, and `gh_login`/`setup-token` clean up after themselves with
|
||||
// their own `pkill`. The pump keeps running and records the exit code.
|
||||
//
|
||||
// The pump sends the SIGHUP, not this task: only the pump knows whether it
|
||||
// has already reaped the process, and once it has, the pid — which is the
|
||||
// process-group id — may belong to someone else. If the pump is already
|
||||
// gone the send just fails.
|
||||
drop(in_tx);
|
||||
if tty {
|
||||
let _ = hangup_tx.send(());
|
||||
}
|
||||
}
|
||||
|
||||
/// The command could not start: register it as finished, and tell the client
|
||||
/// the way a Docker attach would show it — started, the reason on stderr,
|
||||
/// then the exit code.
|
||||
async fn not_started(mut socket: WebSocket, st: &Arc<AppState>, code: i32, message: String) {
|
||||
let id = st.execs.insert_finished(code);
|
||||
let _ = socket.send(text(serde_json::json!({ "type": "started", "exec_id": id }))).await;
|
||||
let _ = socket.send(frame(2, format!("{message}\n").as_bytes())).await;
|
||||
let _ = socket.send(text(serde_json::json!({ "type": "exit", "code": code }))).await;
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
}
|
||||
|
||||
/// Send output unless the socket side has gone; output after that is drained
|
||||
/// and discarded so the process never blocks on a full pipe.
|
||||
async fn emit(tx: &mpsc::Sender<Event>, ch: u8, data: &[u8]) {
|
||||
let _ = tx.send(Event::Out(ch, Bytes::copy_from_slice(data))).await;
|
||||
}
|
||||
|
||||
async fn pump_pipes(
|
||||
mut child: tokio::process::Child,
|
||||
mut input: mpsc::UnboundedReceiver<Input>,
|
||||
tx: &mpsc::Sender<Event>,
|
||||
) -> i32 {
|
||||
let mut stdout = child.stdout.take().expect("piped stdout");
|
||||
let mut stderr = child.stderr.take().expect("piped stderr");
|
||||
if let Some(mut stdin) = child.stdin.take() {
|
||||
tokio::spawn(async move {
|
||||
while let Some(Input::Data(d)) = input.recv().await {
|
||||
if stdin.write_all(&d).await.is_err() {
|
||||
break;
|
||||
}
|
||||
let _ = stdin.flush().await;
|
||||
}
|
||||
// `stdin` drops here: EOF for the child.
|
||||
});
|
||||
}
|
||||
let (mut ob, mut eb) = (vec![0u8; 16 * 1024], vec![0u8; 16 * 1024]);
|
||||
let (mut out_open, mut err_open) = (true, true);
|
||||
let mut code: Option<i32> = None;
|
||||
// The exit code is taken as soon as the process exits (so `GET
|
||||
// /v1/exec/{id}` is not held up by a grandchild still holding a pipe), but
|
||||
// the exit *frame* waits for both streams to end: "all output has been
|
||||
// sent" first, as the spec requires.
|
||||
while out_open || err_open || code.is_none() {
|
||||
tokio::select! {
|
||||
r = stdout.read(&mut ob), if out_open => match r {
|
||||
Ok(n) if n > 0 => emit(tx, 1, &ob[..n]).await,
|
||||
_ => out_open = false,
|
||||
},
|
||||
r = stderr.read(&mut eb), if err_open => match r {
|
||||
Ok(n) if n > 0 => emit(tx, 2, &eb[..n]).await,
|
||||
_ => err_open = false,
|
||||
},
|
||||
s = child.wait(), if code.is_none() => {
|
||||
code = Some(s.map(spawn::exit_code).unwrap_or(255));
|
||||
}
|
||||
}
|
||||
}
|
||||
code.unwrap_or(255)
|
||||
}
|
||||
|
||||
async fn pump_tty(
|
||||
mut child: tokio::process::Child,
|
||||
master: Arc<pty::PtyMaster>,
|
||||
mut input: mpsc::UnboundedReceiver<Input>,
|
||||
hangup: oneshot::Receiver<()>,
|
||||
attach_stdin: bool,
|
||||
tx: &mpsc::Sender<Event>,
|
||||
) -> i32 {
|
||||
// `Some` until the client has gone (or the session ended without saying
|
||||
// so), so a completed receiver is never polled again.
|
||||
let mut hangup = Some(hangup);
|
||||
if attach_stdin {
|
||||
let master = master.clone();
|
||||
tokio::spawn(async move {
|
||||
while let Some(Input::Data(d)) = input.recv().await {
|
||||
if master.write_all(&d).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
// A tty has no EOF to give: closing stdin on a terminal is not
|
||||
// a thing, and the pty must stay open for output. Docker's
|
||||
// CloseWrite on a tty exec is likewise not an EOF to the process.
|
||||
});
|
||||
}
|
||||
let mut buf = vec![0u8; 16 * 1024];
|
||||
let mut code: Option<i32> = None;
|
||||
loop {
|
||||
if let Some(c) = code {
|
||||
match tokio::time::timeout(TTY_DRAIN_IDLE, master.read(&mut buf)).await {
|
||||
Ok(Ok(n)) if n > 0 => emit(tx, 1, &buf[..n]).await,
|
||||
_ => return c,
|
||||
}
|
||||
continue;
|
||||
}
|
||||
tokio::select! {
|
||||
r = master.read(&mut buf) => match r {
|
||||
// Everything is channel 1 on a tty: Docker's `Console` stream
|
||||
// has no stderr, and the app maps it to stdout.
|
||||
Ok(n) if n > 0 => emit(tx, 1, &buf[..n]).await,
|
||||
_ => {
|
||||
let s = child.wait().await;
|
||||
return s.map(spawn::exit_code).unwrap_or(255);
|
||||
}
|
||||
},
|
||||
s = child.wait() => {
|
||||
code = Some(s.map(spawn::exit_code).unwrap_or(255));
|
||||
}
|
||||
// Only reachable while `code` is `None`: `child.wait()` has not
|
||||
// returned, so the process is unreaped (alive or a zombie) and its
|
||||
// pid cannot have been reused for another process group.
|
||||
r = async { hangup.as_mut().expect("guarded by is_some").await }, if hangup.is_some() => {
|
||||
hangup = None;
|
||||
if r.is_ok() {
|
||||
spawn::signal_group(child.id(), libc::SIGHUP);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
//! One-shot and detached execs, exec status, and resize.
|
||||
|
||||
use std::collections::HashMap;
|
||||
use std::sync::{Arc, Mutex};
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use axum::extract::{Path, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use base64::Engine;
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncReadExt;
|
||||
|
||||
use crate::pty::PtyMaster;
|
||||
use crate::spawn::{self, ExecSpec, Io, PrepareError};
|
||||
use crate::{error, AppState, CountGuard, MAX_ONESHOT_OUTPUT};
|
||||
|
||||
/// How long a finished exec stays answerable. The spec promises "at least 10
|
||||
/// minutes": the app polls `GET /v1/exec/{id}` after an attach ends, and a
|
||||
/// slow poll (a laptop waking from sleep) must not turn a real exit code into
|
||||
/// an unknown id.
|
||||
const KEEP_FINISHED: Duration = Duration::from_secs(10 * 60);
|
||||
|
||||
pub struct ExecEntry {
|
||||
pub running: bool,
|
||||
pub exit_code: Option<i32>,
|
||||
finished_at: Option<Instant>,
|
||||
/// Present only for tty execs that are still running — what `resize`
|
||||
/// needs. Dropped on exit so the terminal is released.
|
||||
pub pty: Option<Arc<PtyMaster>>,
|
||||
}
|
||||
|
||||
/// Exec ids → status, for `detached` and `attach` execs.
|
||||
#[derive(Default)]
|
||||
pub struct Registry {
|
||||
map: Mutex<HashMap<String, ExecEntry>>,
|
||||
}
|
||||
|
||||
impl Registry {
|
||||
/// Register a running exec and return its id. Expired entries are pruned
|
||||
/// on the way in, which bounds the map by the insert rate × 10 minutes.
|
||||
pub fn insert_running(&self, pty: Option<Arc<PtyMaster>>) -> String {
|
||||
self.insert(ExecEntry {
|
||||
running: true,
|
||||
exit_code: None,
|
||||
finished_at: None,
|
||||
pty,
|
||||
})
|
||||
}
|
||||
|
||||
/// Register an exec that never started (126/127), so its id answers like
|
||||
/// any finished exec — which is how Docker presents it.
|
||||
pub fn insert_finished(&self, code: i32) -> String {
|
||||
self.insert(ExecEntry {
|
||||
running: false,
|
||||
exit_code: Some(code),
|
||||
finished_at: Some(Instant::now()),
|
||||
pty: None,
|
||||
})
|
||||
}
|
||||
|
||||
fn insert(&self, entry: ExecEntry) -> String {
|
||||
let id = new_id();
|
||||
let mut map = self.map.lock().unwrap();
|
||||
map.retain(|_, e| e.finished_at.is_none_or(|t| t.elapsed() < KEEP_FINISHED));
|
||||
map.insert(id.clone(), entry);
|
||||
id
|
||||
}
|
||||
|
||||
pub fn finish(&self, id: &str, code: i32) {
|
||||
if let Some(e) = self.map.lock().unwrap().get_mut(id) {
|
||||
e.running = false;
|
||||
e.exit_code = Some(code);
|
||||
e.finished_at = Some(Instant::now());
|
||||
e.pty = None;
|
||||
}
|
||||
}
|
||||
|
||||
fn with<R>(&self, id: &str, f: impl FnOnce(&ExecEntry) -> R) -> Option<R> {
|
||||
self.map.lock().unwrap().get(id).map(f)
|
||||
}
|
||||
}
|
||||
|
||||
/// 64 hex characters from the kernel's CSPRNG — Docker's exec id shape. Ids
|
||||
/// are not secrets (the token is the boundary), but guessable ones would let
|
||||
/// one client poke at another's execs for no reason.
|
||||
fn new_id() -> String {
|
||||
let mut bytes = [0u8; 32];
|
||||
let mut filled = 0;
|
||||
while filled < bytes.len() {
|
||||
let n = unsafe {
|
||||
libc::getrandom(bytes[filled..].as_mut_ptr().cast(), bytes.len() - filled, 0)
|
||||
};
|
||||
if n > 0 {
|
||||
filled += n as usize;
|
||||
} else if std::io::Error::last_os_error().kind() != std::io::ErrorKind::Interrupted {
|
||||
// getrandom(2) cannot fail on a supported kernel once the pool is
|
||||
// initialised; panicking beats handing out predictable ids.
|
||||
panic!("getrandom failed: {}", std::io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
bytes.iter().map(|b| format!("{b:02x}")).collect()
|
||||
}
|
||||
|
||||
/// Map a spec refusal onto an HTTP error response.
|
||||
pub fn refusal(status: StatusCode, msg: String) -> Response {
|
||||
error(status, msg)
|
||||
}
|
||||
|
||||
/// Parse a JSON body without insisting on `Content-Type: application/json`.
|
||||
/// The spec says bodies are JSON and says nothing about the header, so a
|
||||
/// client that omits it (curl `-d`, a hand-rolled hyper request) must not get
|
||||
/// a 415 for a body that is perfectly good JSON.
|
||||
fn parse_json<T: serde::de::DeserializeOwned>(body: &[u8]) -> Result<T, String> {
|
||||
serde_json::from_slice(body).map_err(|e| format!("invalid request body: {e}"))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct OneshotRequest {
|
||||
#[serde(flatten)]
|
||||
spec: ExecSpec,
|
||||
#[serde(default)]
|
||||
limit: Option<usize>,
|
||||
}
|
||||
|
||||
/// `POST /v1/exec`.
|
||||
pub async fn oneshot(
|
||||
State(st): State<Arc<AppState>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let req: OneshotRequest = match parse_json(&body) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
let limit = req.limit.unwrap_or(MAX_ONESHOT_OUTPUT);
|
||||
// A one-shot never has a tty (spec), whatever the spec's `tty` says.
|
||||
let prepared = spawn::prepare(&st.config, &req.spec, false);
|
||||
let child = prepared.and_then(|p| p.spawn(Io::Null, Io::Piped, Io::Piped, false));
|
||||
let mut child = match child {
|
||||
Ok(c) => c,
|
||||
Err(PrepareError::Refused(s, m)) => return refusal(s, m),
|
||||
Err(PrepareError::CannotStart { code, message }) => {
|
||||
// Docker's shape for an exec that could not start: a finished exec
|
||||
// with 126/127 and the reason on stderr — not an API error.
|
||||
return oneshot_response(&[(false, format!("{message}\n").into_bytes())], code);
|
||||
}
|
||||
};
|
||||
let _active = CountGuard::execs(&st);
|
||||
let pid = child.id();
|
||||
let mut stdout = child.stdout.take().expect("piped stdout");
|
||||
let mut stderr = child.stderr.take().expect("piped stderr");
|
||||
|
||||
// (is_stdout, bytes) in arrival order; adjacent reads from the same
|
||||
// stream are merged, which changes nothing a client can rebuild.
|
||||
let mut chunks: Vec<(bool, Vec<u8>)> = Vec::new();
|
||||
let mut total = 0usize;
|
||||
let (mut out_open, mut err_open) = (true, true);
|
||||
let mut ob = vec![0u8; 16 * 1024];
|
||||
let mut eb = vec![0u8; 16 * 1024];
|
||||
while out_open || err_open {
|
||||
let (is_out, n) = tokio::select! {
|
||||
r = stdout.read(&mut ob), if out_open => (true, r.unwrap_or(0)),
|
||||
r = stderr.read(&mut eb), if err_open => (false, r.unwrap_or(0)),
|
||||
};
|
||||
if n == 0 {
|
||||
if is_out { out_open = false } else { err_open = false }
|
||||
continue;
|
||||
}
|
||||
// The same test as `docker/exec.rs`'s `push_capped`: refuse the chunk
|
||||
// that would take the total past `limit`, so exactly `limit` bytes is
|
||||
// still a success on both transports.
|
||||
if total + n > limit {
|
||||
// Kill the whole process group, not just the pid: a pipeline or a
|
||||
// backgrounded writer would otherwise keep running with nobody
|
||||
// reading. Reaped in the background so the response is not held
|
||||
// hostage by a process stuck in uninterruptible sleep.
|
||||
spawn::signal_group(pid, libc::SIGKILL);
|
||||
tokio::spawn(async move {
|
||||
let _ = child.wait().await;
|
||||
});
|
||||
// Byte-for-byte the Docker path's message, so the client can pass
|
||||
// it through and `OUTPUT_LIMIT_MARKER` checks keep matching.
|
||||
return error(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
format!("OUTPUT_LIMIT: Command output exceeded {limit} bytes and was abandoned"),
|
||||
);
|
||||
}
|
||||
total += n;
|
||||
let data = if is_out { &ob[..n] } else { &eb[..n] };
|
||||
match chunks.last_mut() {
|
||||
Some((s, buf)) if *s == is_out => buf.extend_from_slice(data),
|
||||
_ => chunks.push((is_out, data.to_vec())),
|
||||
}
|
||||
}
|
||||
let code = match child.wait().await {
|
||||
Ok(status) => spawn::exit_code(status),
|
||||
Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("wait failed: {e}")),
|
||||
};
|
||||
oneshot_response(&chunks, code)
|
||||
}
|
||||
|
||||
fn oneshot_response(chunks: &[(bool, Vec<u8>)], code: i32) -> Response {
|
||||
let b64 = base64::engine::general_purpose::STANDARD;
|
||||
let chunks: Vec<_> = chunks
|
||||
.iter()
|
||||
.map(|(is_out, d)| serde_json::json!({ "s": if *is_out { "out" } else { "err" }, "d": b64.encode(d) }))
|
||||
.collect();
|
||||
Json(serde_json::json!({ "chunks": chunks, "exit_code": code })).into_response()
|
||||
}
|
||||
|
||||
/// `POST /v1/exec/detached`: stdin, stdout and stderr all `/dev/null`.
|
||||
pub async fn detached(
|
||||
State(st): State<Arc<AppState>>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let spec: ExecSpec = match parse_json(&body) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
let child = spawn::prepare(&st.config, &spec, false)
|
||||
.and_then(|p| p.spawn(Io::Null, Io::Null, Io::Null, false));
|
||||
let id = match child {
|
||||
Ok(mut child) => {
|
||||
let id = st.execs.insert_running(None);
|
||||
let guard = CountGuard::execs(&st);
|
||||
let (st2, id2) = (st.clone(), id.clone());
|
||||
tokio::spawn(async move {
|
||||
let _active = guard;
|
||||
let code = child.wait().await.map(spawn::exit_code).unwrap_or(255);
|
||||
st2.execs.finish(&id2, code);
|
||||
});
|
||||
id
|
||||
}
|
||||
Err(PrepareError::Refused(s, m)) => return refusal(s, m),
|
||||
// Docker accepts a detached exec of a missing binary and reports the
|
||||
// failure as its exit code; the output has nowhere to go.
|
||||
Err(PrepareError::CannotStart { code, .. }) => st.execs.insert_finished(code),
|
||||
};
|
||||
Json(serde_json::json!({ "exec_id": id })).into_response()
|
||||
}
|
||||
|
||||
/// `GET /v1/exec/{id}`.
|
||||
pub async fn status(State(st): State<Arc<AppState>>, Path(id): Path<String>) -> Response {
|
||||
match st.execs.with(&id, |e| (e.running, e.exit_code)) {
|
||||
Some((running, code)) => {
|
||||
Json(serde_json::json!({ "running": running, "exit_code": code })).into_response()
|
||||
}
|
||||
None => error(StatusCode::NOT_FOUND, format!("no such exec: {id}")),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ResizeRequest {
|
||||
cols: u16,
|
||||
rows: u16,
|
||||
}
|
||||
|
||||
/// `POST /v1/exec/{id}/resize`: `204`, or `409` for anything that is not a
|
||||
/// running tty exec.
|
||||
pub async fn resize(
|
||||
State(st): State<Arc<AppState>>,
|
||||
Path(id): Path<String>,
|
||||
body: Bytes,
|
||||
) -> Response {
|
||||
let req: ResizeRequest = match parse_json(&body) {
|
||||
Ok(r) => r,
|
||||
Err(e) => return error(StatusCode::BAD_REQUEST, e),
|
||||
};
|
||||
let pty = match st.execs.with(&id, |e| e.pty.clone()) {
|
||||
None => return error(StatusCode::NOT_FOUND, format!("no such exec: {id}")),
|
||||
Some(None) => return error(StatusCode::CONFLICT, "exec is not a running tty exec"),
|
||||
Some(Some(p)) => p,
|
||||
};
|
||||
match pty.resize(req.cols, req.rows) {
|
||||
Ok(()) => StatusCode::NO_CONTENT.into_response(),
|
||||
Err(e) => error(StatusCode::INTERNAL_SERVER_ERROR, format!("resize failed: {e}")),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,265 @@
|
||||
//! `PUT /v1/files` and `GET /v1/archive`: the agent's side of the app's file
|
||||
//! upload and Docker's archive download.
|
||||
|
||||
use std::io::{self, Write};
|
||||
use std::os::fd::AsRawFd;
|
||||
use std::os::unix::fs::OpenOptionsExt;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use axum::body::Body;
|
||||
use axum::extract::rejection::QueryRejection;
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::Json;
|
||||
use bytes::Bytes;
|
||||
use futures_util::StreamExt;
|
||||
use serde::Deserialize;
|
||||
use tokio::io::AsyncWriteExt;
|
||||
use tokio::sync::mpsc;
|
||||
|
||||
use crate::{error, AppState};
|
||||
|
||||
fn query_rejection(r: QueryRejection) -> Response {
|
||||
error(StatusCode::BAD_REQUEST, format!("invalid query: {}", r.body_text()))
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct PutQuery {
|
||||
dir: String,
|
||||
name: String,
|
||||
/// Octal, e.g. `644`. Defaults to 0644, the mode the app's uploads use.
|
||||
#[serde(default)]
|
||||
mode: Option<String>,
|
||||
#[serde(default)]
|
||||
uid: Option<u32>,
|
||||
#[serde(default)]
|
||||
gid: Option<u32>,
|
||||
}
|
||||
|
||||
/// A name the spec allows: one path component, nothing that walks.
|
||||
fn valid_name(name: &str) -> bool {
|
||||
!name.is_empty() && name != "." && name != ".." && !name.contains('/') && !name.contains('\0')
|
||||
}
|
||||
|
||||
/// `PUT /v1/files?dir=&name=&mode=&uid=&gid=`.
|
||||
///
|
||||
/// Mirrors what Docker's archive extractor does with the app's single-file
|
||||
/// tar (`build_single_file_tar`): an existing file is unlinked and a new one
|
||||
/// created — never written through, so a hard link or a file another process
|
||||
/// holds open keeps its old content — then given `mode` and `uid:gid`, mtime
|
||||
/// now. `dir` is not created: the app has already checked it, and silently
|
||||
/// making directories is how a typo becomes a stray tree.
|
||||
pub async fn put_file(
|
||||
State(st): State<Arc<AppState>>,
|
||||
q: Result<Query<PutQuery>, QueryRejection>,
|
||||
body: Body,
|
||||
) -> Response {
|
||||
let Query(q) = match q {
|
||||
Ok(q) => q,
|
||||
Err(r) => return query_rejection(r),
|
||||
};
|
||||
if !q.dir.starts_with('/') {
|
||||
return error(StatusCode::BAD_REQUEST, "dir must be an absolute path");
|
||||
}
|
||||
if !valid_name(&q.name) {
|
||||
return error(StatusCode::BAD_REQUEST, "name must be a single path component");
|
||||
}
|
||||
let mode = match q.mode.as_deref() {
|
||||
None => 0o644,
|
||||
Some(m) => match u32::from_str_radix(m, 8) {
|
||||
Ok(m) if m <= 0o7777 => m,
|
||||
_ => return error(StatusCode::BAD_REQUEST, format!("invalid octal mode: {m}")),
|
||||
},
|
||||
};
|
||||
let (euid, egid) = unsafe { (libc::geteuid(), libc::getegid()) };
|
||||
let uid = q.uid.unwrap_or(euid);
|
||||
let gid = q.gid.unwrap_or(egid);
|
||||
// Same rule as execs: a non-root agent (tests) can only act as itself.
|
||||
// Checked before touching the filesystem, so a refusal changes nothing.
|
||||
if euid != 0 && (uid != euid || gid != egid) {
|
||||
return error(StatusCode::FORBIDDEN, "the agent is not root and cannot chown to another user");
|
||||
}
|
||||
// `dir` is resolved following symlinks, and the write runs as root. That
|
||||
// is deliberate parity, not an oversight: Docker's archive upload — what
|
||||
// this replaces — also resolves symlinks inside the container. It is not
|
||||
// a privilege boundary either: the `claude` user who could plant a
|
||||
// symlink has passwordless sudo in this image (Dockerfile, sudoers.d), so
|
||||
// redirecting a root write gains nothing `sudo` doesn't. The final
|
||||
// component is still opened O_NOFOLLOW|O_CREAT|O_EXCL below, so an
|
||||
// existing symlink *at* the name is replaced, never written through.
|
||||
// Revisit with openat2(RESOLVE_NO_SYMLINKS) if that sudo ever goes.
|
||||
match std::fs::metadata(&q.dir) {
|
||||
Ok(m) if m.is_dir() => {}
|
||||
Ok(_) => return error(StatusCode::BAD_REQUEST, format!("{} is not a directory", q.dir)),
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
return error(StatusCode::NOT_FOUND, format!("{} does not exist", q.dir))
|
||||
}
|
||||
Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("{}: {e}", q.dir)),
|
||||
}
|
||||
let path = PathBuf::from(&q.dir).join(&q.name);
|
||||
let shown = format!("{}/{}", q.dir.trim_end_matches('/'), q.name);
|
||||
|
||||
// Unlink, then create — tar extraction's replace. A directory in the way
|
||||
// is refused rather than removed recursively.
|
||||
match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) if m.is_dir() => {
|
||||
return error(StatusCode::CONFLICT, format!("{shown} is a directory"));
|
||||
}
|
||||
Ok(_) => {
|
||||
if let Err(e) = std::fs::remove_file(&path) {
|
||||
return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot replace {shown}: {e}"));
|
||||
}
|
||||
}
|
||||
Err(_) => {}
|
||||
}
|
||||
let file = std::fs::OpenOptions::new()
|
||||
.write(true)
|
||||
.create_new(true)
|
||||
.mode(0o600)
|
||||
.custom_flags(libc::O_NOFOLLOW)
|
||||
.open(&path);
|
||||
let file = match file {
|
||||
Ok(f) => f,
|
||||
Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot create {shown}: {e}")),
|
||||
};
|
||||
// Owner before mode: chown clears setuid/setgid bits, so the mode has to
|
||||
// land last to survive. fchmod rather than the open mode so the umask
|
||||
// does not get a say — tar extraction as root restores modes exactly.
|
||||
let fd = file.as_raw_fd();
|
||||
let set = unsafe {
|
||||
(euid != 0 || libc::fchown(fd, uid, gid) == 0) && libc::fchmod(fd, mode as libc::mode_t) == 0
|
||||
};
|
||||
if !set {
|
||||
let e = io::Error::last_os_error();
|
||||
let _ = std::fs::remove_file(&path);
|
||||
return error(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot set owner/mode on {shown}: {e}"));
|
||||
}
|
||||
|
||||
let mut file = tokio::fs::File::from_std(file);
|
||||
let mut stream = body.into_data_stream();
|
||||
let mut written: u64 = 0;
|
||||
let fail = |status, msg: String| {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
error(status, msg)
|
||||
};
|
||||
while let Some(chunk) = stream.next().await {
|
||||
let chunk = match chunk {
|
||||
Ok(c) => c,
|
||||
Err(e) => return fail(StatusCode::BAD_REQUEST, format!("upload interrupted: {e}")),
|
||||
};
|
||||
written += chunk.len() as u64;
|
||||
if written > st.config.max_file_bytes {
|
||||
return fail(
|
||||
StatusCode::PAYLOAD_TOO_LARGE,
|
||||
format!("File too large to upload (limit {} MB)", st.config.max_file_bytes / (1024 * 1024)),
|
||||
);
|
||||
}
|
||||
if let Err(e) = file.write_all(&chunk).await {
|
||||
return fail(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write {shown}: {e}"));
|
||||
}
|
||||
}
|
||||
if let Err(e) = file.flush().await {
|
||||
return fail(StatusCode::INTERNAL_SERVER_ERROR, format!("cannot write {shown}: {e}"));
|
||||
}
|
||||
Json(serde_json::json!({ "path": shown })).into_response()
|
||||
}
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct ArchiveQuery {
|
||||
path: String,
|
||||
}
|
||||
|
||||
/// `GET /v1/archive?path=`: a streamed tar whose root member is
|
||||
/// `basename(path)`, recursing into directories — the shape of Docker's
|
||||
/// `GET /containers/{id}/archive`, which is what the app's download parses.
|
||||
///
|
||||
/// Symlinks are archived as links, not followed, at the root as well as
|
||||
/// inside: Docker does the same, and following them would let a link in the
|
||||
/// tree pull in files from anywhere in the container.
|
||||
pub async fn archive(q: Result<Query<ArchiveQuery>, QueryRejection>) -> Response {
|
||||
let Query(q) = match q {
|
||||
Ok(q) => q,
|
||||
Err(r) => return query_rejection(r),
|
||||
};
|
||||
if !q.path.starts_with('/') {
|
||||
return error(StatusCode::BAD_REQUEST, "path must be absolute");
|
||||
}
|
||||
let path = PathBuf::from(&q.path);
|
||||
let meta = match std::fs::symlink_metadata(&path) {
|
||||
Ok(m) => m,
|
||||
Err(e) if e.kind() == io::ErrorKind::NotFound => {
|
||||
return error(StatusCode::NOT_FOUND, format!("{} does not exist", q.path))
|
||||
}
|
||||
Err(e) => return error(StatusCode::INTERNAL_SERVER_ERROR, format!("{}: {e}", q.path)),
|
||||
};
|
||||
let root_name = path
|
||||
.file_name()
|
||||
.map(PathBuf::from)
|
||||
.unwrap_or_else(|| PathBuf::from("."));
|
||||
|
||||
// tar is a synchronous writer, so it runs on a blocking thread feeding a
|
||||
// small channel. When the client disconnects, the body (and receiver) is
|
||||
// dropped, the next send fails, and the walk stops — "dropping the
|
||||
// connection cancels the read".
|
||||
let (tx, mut rx) = mpsc::channel::<io::Result<Bytes>>(8);
|
||||
tokio::task::spawn_blocking(move || {
|
||||
let mut w = ChannelWriter { tx: tx.clone(), buf: Vec::with_capacity(CHUNK) };
|
||||
let res = (|| {
|
||||
let mut b = tar::Builder::new(&mut w);
|
||||
b.follow_symlinks(false);
|
||||
b.mode(tar::HeaderMode::Complete);
|
||||
if meta.is_dir() {
|
||||
b.append_dir_all(&root_name, &path)?;
|
||||
} else {
|
||||
b.append_path_with_name(&path, &root_name)?;
|
||||
}
|
||||
b.finish()
|
||||
})();
|
||||
let res = res.and_then(|()| w.flush());
|
||||
if let Err(e) = res {
|
||||
// Surface as a body error: the client sees a truncated, failed
|
||||
// download rather than a short archive that looks complete.
|
||||
let _ = tx.blocking_send(Err(e));
|
||||
}
|
||||
});
|
||||
let stream = futures_util::stream::poll_fn(move |cx| rx.poll_recv(cx));
|
||||
Response::builder()
|
||||
.header(header::CONTENT_TYPE, "application/x-tar")
|
||||
.body(Body::from_stream(stream))
|
||||
.unwrap_or_else(|_| error(StatusCode::INTERNAL_SERVER_ERROR, "response build failed"))
|
||||
}
|
||||
|
||||
const CHUNK: usize = 64 * 1024;
|
||||
|
||||
struct ChannelWriter {
|
||||
tx: mpsc::Sender<io::Result<Bytes>>,
|
||||
buf: Vec<u8>,
|
||||
}
|
||||
|
||||
impl ChannelWriter {
|
||||
fn send(&mut self) -> io::Result<()> {
|
||||
if self.buf.is_empty() {
|
||||
return Ok(());
|
||||
}
|
||||
let data = Bytes::from(std::mem::replace(&mut self.buf, Vec::with_capacity(CHUNK)));
|
||||
self.tx
|
||||
.blocking_send(Ok(data))
|
||||
.map_err(|_| io::Error::new(io::ErrorKind::BrokenPipe, "client went away"))
|
||||
}
|
||||
}
|
||||
|
||||
impl Write for ChannelWriter {
|
||||
fn write(&mut self, data: &[u8]) -> io::Result<usize> {
|
||||
self.buf.extend_from_slice(data);
|
||||
if self.buf.len() >= CHUNK {
|
||||
self.send()?;
|
||||
}
|
||||
Ok(data.len())
|
||||
}
|
||||
fn flush(&mut self) -> io::Result<()> {
|
||||
self.send()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,440 @@
|
||||
//! `triple-c-agent`: everything `host::ContainerTarget` does through the Docker
|
||||
//! API, done from inside the container over authenticated HTTP/WebSocket.
|
||||
//!
|
||||
//! The contract is `docs/superpowers/specs/2026-11-13-triple-c-agent-protocol.md`.
|
||||
//! **Docker parity is the requirement**: the same `ExecSpec` must behave the
|
||||
//! same here as through `docker exec`, because the app's call sites were
|
||||
//! written against Docker and are not going to grow a second set of rules.
|
||||
//! Each place this code reproduces a Docker behaviour says so where it does it.
|
||||
//!
|
||||
//! The library exists so the integration tests can run the real server
|
||||
//! in-process on an ephemeral port; `main.rs` only reads the environment.
|
||||
|
||||
mod attach;
|
||||
mod exec;
|
||||
mod files;
|
||||
mod pty;
|
||||
mod spawn;
|
||||
mod tunnel;
|
||||
mod users;
|
||||
|
||||
use std::ffi::OsString;
|
||||
use std::path::PathBuf;
|
||||
use std::sync::atomic::{AtomicI64, Ordering};
|
||||
use std::sync::Arc;
|
||||
use std::time::{Duration, SystemTime, UNIX_EPOCH};
|
||||
|
||||
use axum::extract::{Request, State};
|
||||
use axum::http::{header, StatusCode};
|
||||
use axum::middleware::{self, Next};
|
||||
use axum::response::{IntoResponse, Response};
|
||||
use axum::routing::{get, post, put};
|
||||
use axum::{Json, Router};
|
||||
|
||||
/// The wire protocol version reported by `/v1/health`.
|
||||
pub const PROTOCOL_VERSION: u32 = 1;
|
||||
|
||||
/// Upload ceiling for `PUT /v1/files`: the app's `MAX_DROP_BYTES`
|
||||
/// (`docker/exec.rs`). The app checks it before sending, so this only has to
|
||||
/// stop a body that lies about itself.
|
||||
pub const MAX_DROP_BYTES: u64 = 256 * 1024 * 1024;
|
||||
|
||||
/// The default one-shot budget when a request omits `limit` — the app's
|
||||
/// `MAX_ONESHOT_OUTPUT`. Every real client call sends its own.
|
||||
pub const MAX_ONESHOT_OUTPUT: usize = 8 * 1024 * 1024;
|
||||
|
||||
/// The shortest token the agent will accept. There is no unauthenticated mode,
|
||||
/// and a short token is close enough to none that it is refused the same way.
|
||||
pub const MIN_TOKEN_BYTES: usize = 32;
|
||||
|
||||
/// Name of the env var carrying the token. `main` removes it from the
|
||||
/// process's environment after reading it, so no exec inherits it — but that
|
||||
/// only edits libc's copy: the kernel's record of the environment the process
|
||||
/// was started with (`/proc/<pid>/environ`, readable by root and by the
|
||||
/// agent's own uid) still holds it. Prefer [`TOKEN_FILE_ENV`], which is what
|
||||
/// the entrypoint uses.
|
||||
pub const TOKEN_ENV: &str = "TRIPLEC_AGENT_TOKEN";
|
||||
pub const TOKEN_FILE_ENV: &str = "TRIPLEC_AGENT_TOKEN_FILE";
|
||||
pub const ADDR_ENV: &str = "TRIPLEC_AGENT_ADDR";
|
||||
pub const DEFAULT_ADDR: &str = "127.0.0.1:47900";
|
||||
|
||||
/// How long a client gets to send a complete request head. Until its token
|
||||
/// has been checked a connection is anonymous, so this is what stops a
|
||||
/// slowloris from holding sockets open forever.
|
||||
pub const HEADER_READ_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
/// Concurrent HTTP connections. Far above what the app opens; a bound on
|
||||
/// what an unauthenticated caller can make the agent hold.
|
||||
pub const MAX_CONNECTIONS: usize = 256;
|
||||
|
||||
/// The largest WebSocket message (and frame) the agent accepts on attach and
|
||||
/// tunnel sockets. The app sends stdin in frames of at most 64 KiB.
|
||||
pub(crate) const MAX_WS_MESSAGE: usize = 1024 * 1024;
|
||||
|
||||
/// Everything the server needs, captured once at startup.
|
||||
#[derive(Clone)]
|
||||
pub struct Config {
|
||||
/// The bearer token every request must carry.
|
||||
pub token: String,
|
||||
/// Where an exec with `working_dir: null` runs: the agent's own cwd at
|
||||
/// startup, which is the container's `WorkingDir` — what `docker exec`
|
||||
/// without `-w` uses.
|
||||
pub default_cwd: PathBuf,
|
||||
/// The env every exec starts from: the agent's own at startup, minus the
|
||||
/// token. That is the container's configured env, which is what a
|
||||
/// `docker exec` inherits.
|
||||
pub base_env: Vec<(OsString, OsString)>,
|
||||
/// `PUT /v1/files` ceiling. [`MAX_DROP_BYTES`] in production; a field only
|
||||
/// so tests can exercise the refusal without a 256 MiB body.
|
||||
pub max_file_bytes: u64,
|
||||
/// [`HEADER_READ_TIMEOUT`] in production; a field so tests need not wait.
|
||||
pub header_read_timeout: Duration,
|
||||
/// [`MAX_CONNECTIONS`] in production.
|
||||
pub max_connections: usize,
|
||||
}
|
||||
|
||||
impl Config {
|
||||
/// Capture the process's own cwd and env. Call after the token has been
|
||||
/// removed from the environment; the token is filtered here as well, so a
|
||||
/// caller that forgets cannot leak it into execs.
|
||||
pub fn from_process(token: String) -> std::io::Result<Config> {
|
||||
Ok(Config {
|
||||
token,
|
||||
default_cwd: std::env::current_dir()?,
|
||||
// None of the agent's own settings (`TRIPLEC_AGENT_*`) reach an
|
||||
// exec: the token is a secret, and the token-file path and listen
|
||||
// address are the agent's business — `docker exec` sees neither.
|
||||
base_env: std::env::vars_os()
|
||||
.filter(|(k, _)| !k.to_string_lossy().starts_with("TRIPLEC_AGENT_"))
|
||||
.collect(),
|
||||
max_file_bytes: MAX_DROP_BYTES,
|
||||
header_read_timeout: HEADER_READ_TIMEOUT,
|
||||
max_connections: MAX_CONNECTIONS,
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Read the token as the spec says: the env var first, then the file. Refuses
|
||||
/// anything empty or shorter than [`MIN_TOKEN_BYTES`] — the agent has no
|
||||
/// unauthenticated mode to fall back to.
|
||||
///
|
||||
/// Surrounding whitespace is trimmed: a token file written by `echo` or a
|
||||
/// secrets manager usually ends in a newline, and a trailing `\n` the client
|
||||
/// never sends would be a token nobody can present.
|
||||
pub fn load_token(env_token: Option<OsString>, file: Option<PathBuf>) -> Result<String, String> {
|
||||
let (raw, source) = match env_token.filter(|t| !t.is_empty()) {
|
||||
Some(t) => (
|
||||
t.into_string()
|
||||
.map_err(|_| format!("{TOKEN_ENV} is not valid UTF-8"))?,
|
||||
TOKEN_ENV.to_string(),
|
||||
),
|
||||
None => match file {
|
||||
Some(path) => (
|
||||
read_private_file(&path)
|
||||
.map_err(|e| format!("cannot use {TOKEN_FILE_ENV} ({}): {e}", path.display()))?,
|
||||
format!("{TOKEN_FILE_ENV} ({})", path.display()),
|
||||
),
|
||||
None => {
|
||||
return Err(format!(
|
||||
"no token: set {TOKEN_ENV} or {TOKEN_FILE_ENV}; refusing to start unauthenticated"
|
||||
))
|
||||
}
|
||||
},
|
||||
};
|
||||
let token = raw.trim().to_string();
|
||||
if token.len() < MIN_TOKEN_BYTES {
|
||||
return Err(format!(
|
||||
"token from {source} is {} bytes; at least {MIN_TOKEN_BYTES} are required",
|
||||
token.len()
|
||||
));
|
||||
}
|
||||
Ok(token)
|
||||
}
|
||||
|
||||
/// Read a secret file, refusing one that anyone but its owner could read or
|
||||
/// that this process does not own — as `ssh` does for key files. The token
|
||||
/// is effectively root in the container; a world-readable copy of it would be
|
||||
/// a way in for any process, not just root's. Checked on the opened file, so
|
||||
/// the file judged is the file read.
|
||||
fn read_private_file(path: &std::path::Path) -> Result<String, String> {
|
||||
use std::io::Read;
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
let mut f = std::fs::File::open(path).map_err(|e| e.to_string())?;
|
||||
let meta = f.metadata().map_err(|e| e.to_string())?;
|
||||
let euid = unsafe { libc::geteuid() };
|
||||
if meta.uid() != euid {
|
||||
return Err(format!("owned by uid {}, not this process's uid {euid}", meta.uid()));
|
||||
}
|
||||
if meta.mode() & 0o077 != 0 {
|
||||
return Err(format!(
|
||||
"mode {:o} lets others read it; it must be 0600 or stricter",
|
||||
meta.mode() & 0o777
|
||||
));
|
||||
}
|
||||
let mut s = String::new();
|
||||
f.read_to_string(&mut s).map_err(|e| e.to_string())?;
|
||||
Ok(s)
|
||||
}
|
||||
|
||||
/// Shared server state.
|
||||
pub(crate) struct AppState {
|
||||
pub config: Config,
|
||||
pub execs: exec::Registry,
|
||||
pub activity: Activity,
|
||||
}
|
||||
|
||||
/// What `/v1/activity` reports; the input to idle auto-stop.
|
||||
pub(crate) struct Activity {
|
||||
last_unix: AtomicI64,
|
||||
pub execs: AtomicI64,
|
||||
pub tunnels: AtomicI64,
|
||||
}
|
||||
|
||||
impl Activity {
|
||||
pub fn touch(&self) {
|
||||
self.last_unix.store(now_unix(), Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
/// Increments a counter for as long as it lives, so an exec or tunnel that
|
||||
/// ends by any path — including a panic or a dropped task — stops counting.
|
||||
pub(crate) struct CountGuard(Arc<AppState>, fn(&Activity) -> &AtomicI64);
|
||||
|
||||
impl CountGuard {
|
||||
pub fn execs(st: &Arc<AppState>) -> CountGuard {
|
||||
st.activity.execs.fetch_add(1, Ordering::Relaxed);
|
||||
CountGuard(st.clone(), |a| &a.execs)
|
||||
}
|
||||
pub fn tunnels(st: &Arc<AppState>) -> CountGuard {
|
||||
st.activity.tunnels.fetch_add(1, Ordering::Relaxed);
|
||||
CountGuard(st.clone(), |a| &a.tunnels)
|
||||
}
|
||||
}
|
||||
|
||||
impl Drop for CountGuard {
|
||||
fn drop(&mut self) {
|
||||
(self.1)(&self.0.activity).fetch_sub(1, Ordering::Relaxed);
|
||||
}
|
||||
}
|
||||
|
||||
pub(crate) fn now_unix() -> i64 {
|
||||
SystemTime::now()
|
||||
.duration_since(UNIX_EPOCH)
|
||||
.map(|d| d.as_secs() as i64)
|
||||
.unwrap_or(0)
|
||||
}
|
||||
|
||||
/// The spec's error shape: `{"error": "<message>"}` with a JSON content type.
|
||||
pub(crate) fn error(status: StatusCode, msg: impl Into<String>) -> Response {
|
||||
(status, Json(serde_json::json!({ "error": msg.into() }))).into_response()
|
||||
}
|
||||
|
||||
/// Every route, behind the token gate.
|
||||
fn router(state: Arc<AppState>) -> Router {
|
||||
Router::new()
|
||||
.route("/v1/health", get(health))
|
||||
.route("/v1/activity", get(activity))
|
||||
.route("/v1/exec", post(exec::oneshot))
|
||||
.route("/v1/exec/detached", post(exec::detached))
|
||||
.route("/v1/exec/{id}", get(exec::status))
|
||||
.route("/v1/exec/{id}/resize", post(exec::resize))
|
||||
.route("/v1/attach", get(attach::upgrade))
|
||||
.route(
|
||||
"/v1/files",
|
||||
// The handler streams the body and enforces its own ceiling, so
|
||||
// axum's 2 MB default must not cut it off first.
|
||||
put(files::put_file).layer(axum::extract::DefaultBodyLimit::disable()),
|
||||
)
|
||||
.route("/v1/archive", get(files::archive))
|
||||
.route("/v1/tunnel", get(tunnel::upgrade))
|
||||
.fallback(|| async { error(StatusCode::NOT_FOUND, "no such endpoint") })
|
||||
// `layer`, not `route_layer`: the fallback must be behind the token
|
||||
// too, or an unauthenticated caller could map the API by 404 vs 401.
|
||||
.layer(middleware::from_fn_with_state(state.clone(), auth))
|
||||
.with_state(state)
|
||||
}
|
||||
|
||||
/// Serve the protocol on `listener`. Runs until the task is dropped: accept
|
||||
/// errors (a full fd table, a connection reset before accept) are transient
|
||||
/// and retried, as `axum::serve` does.
|
||||
///
|
||||
/// Our own accept loop rather than `axum::serve`, because that gives hyper no
|
||||
/// timer, so hyper's header-read timeout never fires and an anonymous
|
||||
/// connection that trickles (or never sends) its request head is held
|
||||
/// forever. Here every connection gets [`Config::header_read_timeout`] to
|
||||
/// deliver a complete head, and at most [`Config::max_connections`] are
|
||||
/// served at once. HTTP/1.1 only — all the protocol needs — with upgrades.
|
||||
///
|
||||
/// At the cap the loop **waits** for a slot before accepting, rather than
|
||||
/// accepting and dropping: excess clients queue in the kernel's backlog and
|
||||
/// are served as soon as a slot frees, which the header timeout guarantees
|
||||
/// for any slot an idle or slow client holds. Dropping would instead fail the
|
||||
/// app's own requests outright whenever the cap is reached.
|
||||
///
|
||||
/// A connection's slot is released when hyper is done with it. A WebSocket
|
||||
/// upgrade ends hyper's part (the socket then belongs to the session task),
|
||||
/// so attach and tunnel sessions do not count against the cap — and they only
|
||||
/// exist past the token check.
|
||||
pub async fn serve(listener: tokio::net::TcpListener, config: Config) -> std::io::Result<()> {
|
||||
use hyper_util::rt::{TokioIo, TokioTimer};
|
||||
use hyper_util::service::TowerToHyperService;
|
||||
|
||||
let header_timeout = config.header_read_timeout;
|
||||
let slots = Arc::new(tokio::sync::Semaphore::new(config.max_connections.max(1)));
|
||||
let state = Arc::new(AppState {
|
||||
config,
|
||||
execs: exec::Registry::default(),
|
||||
activity: Activity {
|
||||
last_unix: AtomicI64::new(now_unix()),
|
||||
execs: AtomicI64::new(0),
|
||||
tunnels: AtomicI64::new(0),
|
||||
},
|
||||
});
|
||||
let app = router(state);
|
||||
loop {
|
||||
let permit = slots.clone().acquire_owned().await.expect("the semaphore is never closed");
|
||||
let stream = match listener.accept().await {
|
||||
Ok((stream, _)) => stream,
|
||||
Err(e) => {
|
||||
// ECONNABORTED and friends are per-connection; EMFILE/ENFILE
|
||||
// clear as connections close. Either way, back off and retry.
|
||||
if !matches!(
|
||||
e.kind(),
|
||||
std::io::ErrorKind::ConnectionAborted | std::io::ErrorKind::ConnectionReset
|
||||
) {
|
||||
eprintln!("triple-c-agent: accept failed: {e}");
|
||||
tokio::time::sleep(Duration::from_millis(100)).await;
|
||||
}
|
||||
continue;
|
||||
}
|
||||
};
|
||||
let service = TowerToHyperService::new(app.clone());
|
||||
tokio::spawn(async move {
|
||||
let _slot = permit;
|
||||
let conn = hyper::server::conn::http1::Builder::new()
|
||||
.timer(TokioTimer::new())
|
||||
.header_read_timeout(header_timeout)
|
||||
.serve_connection(TokioIo::new(stream), service)
|
||||
.with_upgrades();
|
||||
// An error here is the client's (reset, malformed request, header
|
||||
// timeout); there is nobody to report it to.
|
||||
let _ = conn.await;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/// Bearer-token gate on every request. A failure is `401` with an **empty**
|
||||
/// body (spec): nothing about the API is disclosed to a caller without the
|
||||
/// token.
|
||||
async fn auth(State(st): State<Arc<AppState>>, req: Request, next: Next) -> Response {
|
||||
let presented = req
|
||||
.headers()
|
||||
.get(header::AUTHORIZATION)
|
||||
.and_then(|v| v.as_bytes().strip_prefix(b"Bearer "));
|
||||
let ok = presented.is_some_and(|p| constant_time_eq(p, st.config.token.as_bytes()));
|
||||
if !ok {
|
||||
return StatusCode::UNAUTHORIZED.into_response();
|
||||
}
|
||||
// `health` and `activity` are the idle monitor's own probes; counting them
|
||||
// would mean an idle container could never look idle.
|
||||
let path = req.uri().path();
|
||||
if path != "/v1/health" && path != "/v1/activity" {
|
||||
st.activity.touch();
|
||||
}
|
||||
next.run(req).await
|
||||
}
|
||||
|
||||
/// Compare without an early exit, so response timing says nothing about how
|
||||
/// many leading bytes of a guess were right. The length is not secret (the
|
||||
/// minimum is public and tokens are generated at a fixed size), but it is
|
||||
/// folded in rather than short-circuited anyway.
|
||||
fn constant_time_eq(a: &[u8], b: &[u8]) -> bool {
|
||||
let mut diff: usize = a.len() ^ b.len();
|
||||
for i in 0..a.len().max(b.len()) {
|
||||
let x = a.get(i).copied().unwrap_or(0);
|
||||
let y = b.get(i).copied().unwrap_or(0);
|
||||
diff |= (x ^ y) as usize;
|
||||
}
|
||||
diff == 0
|
||||
}
|
||||
|
||||
async fn health() -> Response {
|
||||
Json(serde_json::json!({
|
||||
"agent": "triple-c-agent",
|
||||
"version": env!("CARGO_PKG_VERSION"),
|
||||
"protocol": PROTOCOL_VERSION,
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
async fn activity(State(st): State<Arc<AppState>>) -> Response {
|
||||
Json(serde_json::json!({
|
||||
"last_activity_unix": st.activity.last_unix.load(Ordering::Relaxed),
|
||||
"active_execs": st.activity.execs.load(Ordering::Relaxed),
|
||||
"active_tunnels": st.activity.tunnels.load(Ordering::Relaxed),
|
||||
}))
|
||||
.into_response()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn constant_time_eq_matches_plain_equality() {
|
||||
assert!(constant_time_eq(b"abc", b"abc"));
|
||||
assert!(!constant_time_eq(b"abc", b"abd"));
|
||||
assert!(!constant_time_eq(b"abc", b"abcd"));
|
||||
assert!(!constant_time_eq(b"", b"a"));
|
||||
// A length difference of exactly 256 must not wrap to "equal length".
|
||||
assert!(!constant_time_eq(&[0u8; 256], b""));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_token_file_others_can_read_is_refused() {
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let path = dir.path().join("token");
|
||||
std::fs::write(&path, "y".repeat(40)).unwrap();
|
||||
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o644)).unwrap();
|
||||
let e = load_token(None, Some(path.clone())).unwrap_err();
|
||||
assert!(e.contains("0600"), "{e}");
|
||||
|
||||
std::fs::set_permissions(&path, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
assert_eq!(load_token(None, Some(path.clone())).unwrap(), "y".repeat(40));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn the_token_never_reaches_the_exec_base_env() {
|
||||
// `main` removes the var before capturing; this is the second guard,
|
||||
// for a caller that forgets.
|
||||
std::env::set_var(TOKEN_ENV, "x".repeat(40));
|
||||
std::env::set_var(TOKEN_FILE_ENV, "/run/triple-c/agent-token");
|
||||
let cfg = Config::from_process("t".into()).unwrap();
|
||||
std::env::remove_var(TOKEN_ENV);
|
||||
std::env::remove_var(TOKEN_FILE_ENV);
|
||||
assert!(cfg.base_env.iter().all(|(k, _)| !k.to_string_lossy().starts_with("TRIPLEC_AGENT_")));
|
||||
assert!(cfg.base_env.iter().any(|(k, _)| k == "PATH"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn token_rules() {
|
||||
let long = "x".repeat(32);
|
||||
assert_eq!(load_token(Some(long.clone().into()), None).unwrap(), long);
|
||||
assert!(load_token(Some("short".into()), None).is_err());
|
||||
assert!(load_token(None, None).is_err());
|
||||
// An empty env var falls through to the file.
|
||||
let dir = tempfile::tempdir().unwrap();
|
||||
let f = dir.path().join("tok");
|
||||
std::fs::write(&f, format!("{long}\n")).unwrap();
|
||||
{
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
std::fs::set_permissions(&f, std::fs::Permissions::from_mode(0o600)).unwrap();
|
||||
}
|
||||
assert_eq!(load_token(Some("".into()), Some(f.clone())).unwrap(), long);
|
||||
std::fs::write(&f, "\n").unwrap();
|
||||
assert!(load_token(None, Some(f)).is_err());
|
||||
assert!(load_token(None, Some(dir.path().join("missing"))).is_err());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
//! `triple-c-agent` entry point: read the environment, drop the token from
|
||||
//! it, bind, serve. Everything else is in the library.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::process::ExitCode;
|
||||
|
||||
use triple_c_agent::{load_token, serve, Config, ADDR_ENV, DEFAULT_ADDR, TOKEN_ENV, TOKEN_FILE_ENV};
|
||||
|
||||
fn main() -> ExitCode {
|
||||
let token = match load_token(
|
||||
std::env::var_os(TOKEN_ENV),
|
||||
std::env::var_os(TOKEN_FILE_ENV).map(PathBuf::from),
|
||||
) {
|
||||
Ok(t) => t,
|
||||
Err(e) => {
|
||||
eprintln!("triple-c-agent: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
// Before the runtime starts any thread and before anything is spawned:
|
||||
// removing an env var is only sound while the process is single-threaded,
|
||||
// and every exec's base env is captured from this process's env below.
|
||||
//
|
||||
// This keeps the token out of every exec's env, and that is all it does:
|
||||
// it edits libc's copy of the environment, not the block the kernel
|
||||
// recorded at exec time, so `/proc/<agent pid>/environ` still shows the
|
||||
// token to root and to anything running as the agent's uid. Use
|
||||
// TRIPLEC_AGENT_TOKEN_FILE (what the entrypoint does) to avoid that.
|
||||
std::env::remove_var(TOKEN_ENV);
|
||||
|
||||
// Loopback by default. A non-loopback address is only ever bound when
|
||||
// someone wrote it into TRIPLEC_AGENT_ADDR on purpose.
|
||||
let addr = std::env::var(ADDR_ENV).unwrap_or_else(|_| DEFAULT_ADDR.to_string());
|
||||
let config = match Config::from_process(token) {
|
||||
Ok(c) => c,
|
||||
Err(e) => {
|
||||
eprintln!("triple-c-agent: cannot read startup state: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
|
||||
let rt = match tokio::runtime::Builder::new_multi_thread().enable_all().build() {
|
||||
Ok(rt) => rt,
|
||||
Err(e) => {
|
||||
eprintln!("triple-c-agent: cannot start runtime: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
rt.block_on(async move {
|
||||
let listener = match tokio::net::TcpListener::bind(&addr).await {
|
||||
Ok(l) => l,
|
||||
Err(e) => {
|
||||
eprintln!("triple-c-agent: cannot bind {addr}: {e}");
|
||||
return ExitCode::from(2);
|
||||
}
|
||||
};
|
||||
let shown = listener.local_addr().map(|a| a.to_string()).unwrap_or(addr);
|
||||
// One line, never the token.
|
||||
eprintln!("triple-c-agent {} listening on {shown}", env!("CARGO_PKG_VERSION"));
|
||||
match serve(listener, config).await {
|
||||
Ok(()) => ExitCode::SUCCESS,
|
||||
Err(e) => {
|
||||
eprintln!("triple-c-agent: server failed: {e}");
|
||||
ExitCode::FAILURE
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
//! Pseudo-terminals for `tty: true` execs.
|
||||
|
||||
use std::ffi::CStr;
|
||||
use std::io;
|
||||
use std::os::fd::{AsRawFd, FromRawFd, OwnedFd};
|
||||
|
||||
use tokio::io::unix::AsyncFd;
|
||||
|
||||
/// The agent's end of a pty. Shared (via `Arc`) by the output pump, the stdin
|
||||
/// writer and the exec registry, which needs it for `resize`.
|
||||
pub struct PtyMaster {
|
||||
fd: AsyncFd<OwnedFd>,
|
||||
}
|
||||
|
||||
/// Open a pty sized `cols`×`rows`, returning the master and the slave the
|
||||
/// child gets as stdin/stdout/stderr.
|
||||
///
|
||||
/// Both ends are opened `O_CLOEXEC` *atomically* (`posix_openpt`, then `open`
|
||||
/// on the slave path) rather than with `openpty` and a later `FD_CLOEXEC`:
|
||||
/// execs are spawned concurrently from other threads, and a master fd leaked
|
||||
/// into an unrelated child would keep this terminal open after its own process
|
||||
/// exits — the attach would never see EOF.
|
||||
pub fn open(cols: u16, rows: u16) -> io::Result<(PtyMaster, OwnedFd)> {
|
||||
unsafe {
|
||||
let m = libc::posix_openpt(libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC);
|
||||
if m < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let master = OwnedFd::from_raw_fd(m);
|
||||
if libc::grantpt(m) != 0 || libc::unlockpt(m) != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let mut name = [0 as libc::c_char; 128];
|
||||
let rc = libc::ptsname_r(m, name.as_mut_ptr(), name.len());
|
||||
if rc != 0 {
|
||||
return Err(io::Error::from_raw_os_error(rc));
|
||||
}
|
||||
let s = libc::open(
|
||||
CStr::from_ptr(name.as_ptr()).as_ptr(),
|
||||
libc::O_RDWR | libc::O_NOCTTY | libc::O_CLOEXEC,
|
||||
);
|
||||
if s < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let slave = OwnedFd::from_raw_fd(s);
|
||||
set_size(m, cols, rows)?;
|
||||
let flags = libc::fcntl(m, libc::F_GETFL);
|
||||
if flags < 0 || libc::fcntl(m, libc::F_SETFL, flags | libc::O_NONBLOCK) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
// Sound: `master` is an `OwnedFd`, open for as long as the AsyncFd
|
||||
// owns it, and `as_raw_fd` always returns that same descriptor.
|
||||
Ok((PtyMaster { fd: AsyncFd::register(master)? }, slave))
|
||||
}
|
||||
}
|
||||
|
||||
fn set_size(fd: libc::c_int, cols: u16, rows: u16) -> io::Result<()> {
|
||||
let ws = libc::winsize {
|
||||
ws_row: rows,
|
||||
ws_col: cols,
|
||||
ws_xpixel: 0,
|
||||
ws_ypixel: 0,
|
||||
};
|
||||
if unsafe { libc::ioctl(fd, libc::TIOCSWINSZ, &ws) } != 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
impl PtyMaster {
|
||||
/// `TIOCSWINSZ` on the master; the kernel delivers `SIGWINCH` to the
|
||||
/// terminal's foreground process group, as Docker's resize does.
|
||||
pub fn resize(&self, cols: u16, rows: u16) -> io::Result<()> {
|
||||
set_size(self.fd.get_ref().as_raw_fd(), cols, rows)
|
||||
}
|
||||
|
||||
/// Read output. `Ok(0)` is end of stream: Linux reports "every slave fd is
|
||||
/// closed" on the master as `EIO`, which is folded into EOF here.
|
||||
pub async fn read(&self, buf: &mut [u8]) -> io::Result<usize> {
|
||||
loop {
|
||||
let mut guard = self.fd.readable().await?;
|
||||
let res = guard.try_io(|inner| {
|
||||
let n = unsafe {
|
||||
libc::read(inner.get_ref().as_raw_fd(), buf.as_mut_ptr().cast(), buf.len())
|
||||
};
|
||||
if n < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(n as usize)
|
||||
}
|
||||
});
|
||||
match res {
|
||||
Ok(Ok(n)) => return Ok(n),
|
||||
Ok(Err(e)) if e.raw_os_error() == Some(libc::EIO) => return Ok(0),
|
||||
Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_would_block) => continue,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub async fn write_all(&self, mut data: &[u8]) -> io::Result<()> {
|
||||
while !data.is_empty() {
|
||||
let mut guard = self.fd.writable().await?;
|
||||
let res = guard.try_io(|inner| {
|
||||
let n = unsafe {
|
||||
libc::write(inner.get_ref().as_raw_fd(), data.as_ptr().cast(), data.len())
|
||||
};
|
||||
if n < 0 {
|
||||
Err(io::Error::last_os_error())
|
||||
} else {
|
||||
Ok(n as usize)
|
||||
}
|
||||
});
|
||||
match res {
|
||||
Ok(Ok(n)) => data = &data[n..],
|
||||
Ok(Err(e)) if e.kind() == io::ErrorKind::Interrupted => continue,
|
||||
Ok(Err(e)) => return Err(e),
|
||||
Err(_would_block) => continue,
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,424 @@
|
||||
//! Turning an `ExecSpec` into a running process the way `docker exec` would.
|
||||
|
||||
use std::ffi::{CString, OsStr, OsString};
|
||||
use std::io;
|
||||
use std::os::unix::ffi::OsStrExt;
|
||||
use std::os::unix::fs::PermissionsExt;
|
||||
use std::os::unix::process::{CommandExt, ExitStatusExt};
|
||||
use std::path::{Path, PathBuf};
|
||||
use std::process::{ExitStatus, Stdio};
|
||||
|
||||
use axum::http::StatusCode;
|
||||
use serde::Deserialize;
|
||||
|
||||
use crate::users::{self, UserInfo};
|
||||
use crate::Config;
|
||||
|
||||
/// The spec's ExecSpec. `user` may be omitted, meaning the agent's own user —
|
||||
/// as an exec with no `User` runs as the container's configured user (root).
|
||||
#[derive(Debug, Clone, Deserialize)]
|
||||
pub struct ExecSpec {
|
||||
pub cmd: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub user: Option<String>,
|
||||
#[serde(default)]
|
||||
pub working_dir: Option<String>,
|
||||
#[serde(default)]
|
||||
pub env: Vec<String>,
|
||||
#[serde(default)]
|
||||
pub tty: bool,
|
||||
}
|
||||
|
||||
/// Docker's default `PATH` when the container env has none
|
||||
/// (`CreateDaemonEnvironment` → `oci.DefaultPathEnv`).
|
||||
const DEFAULT_PATH: &str = "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin";
|
||||
|
||||
/// An exec ready to start.
|
||||
pub struct Prepared {
|
||||
program: PathBuf,
|
||||
argv: Vec<String>,
|
||||
env: Vec<(OsString, OsString)>,
|
||||
cwd: CString,
|
||||
user: UserInfo,
|
||||
/// Only when running as root. A non-root agent can only ever run as
|
||||
/// itself (anything else is refused with 403 before we get here), and
|
||||
/// calling `setgroups` unprivileged would fail the exec.
|
||||
switch_ids: bool,
|
||||
}
|
||||
|
||||
/// Why a spec could not become a process.
|
||||
pub enum PrepareError {
|
||||
/// An HTTP-level refusal (bad request, wrong user for a non-root agent).
|
||||
Refused(StatusCode, String),
|
||||
/// The command cannot start. Docker reports this as a *finished* exec with
|
||||
/// 126/127 and the reason on stderr, not as an API error, so callers turn
|
||||
/// it into exactly that.
|
||||
CannotStart { code: i32, message: String },
|
||||
}
|
||||
|
||||
impl PrepareError {
|
||||
fn cannot_start(code: i32, message: String) -> Self {
|
||||
PrepareError::CannotStart { code, message }
|
||||
}
|
||||
}
|
||||
|
||||
/// Validate a spec and resolve user, env, working directory and program.
|
||||
pub fn prepare(cfg: &Config, spec: &ExecSpec, tty: bool) -> Result<Prepared, PrepareError> {
|
||||
if spec.cmd.is_empty() || spec.cmd[0].is_empty() {
|
||||
return Err(PrepareError::Refused(StatusCode::BAD_REQUEST, "cmd must be non-empty".into()));
|
||||
}
|
||||
if spec.cmd.iter().any(|a| a.contains('\0')) {
|
||||
return Err(PrepareError::Refused(StatusCode::BAD_REQUEST, "cmd contains a NUL byte".into()));
|
||||
}
|
||||
|
||||
let me = users::current();
|
||||
let is_root = unsafe { libc::geteuid() } == 0;
|
||||
let user = match spec.user.as_deref().filter(|u| !u.is_empty()) {
|
||||
Some(name) => users::lookup(name).ok_or_else(|| {
|
||||
// runc's wording, which is what `docker exec -u nobody-here` prints.
|
||||
PrepareError::cannot_start(
|
||||
126,
|
||||
format!("unable to find user {name}: no matching entries in passwd file"),
|
||||
)
|
||||
})?,
|
||||
None => me.clone().ok_or_else(|| {
|
||||
PrepareError::Refused(
|
||||
StatusCode::INTERNAL_SERVER_ERROR,
|
||||
"the agent's own user has no passwd entry".into(),
|
||||
)
|
||||
})?,
|
||||
};
|
||||
if !is_root && user.uid != unsafe { libc::geteuid() } {
|
||||
return Err(PrepareError::Refused(
|
||||
StatusCode::FORBIDDEN,
|
||||
format!("the agent is not root and cannot run as {}", user.name),
|
||||
));
|
||||
}
|
||||
|
||||
let env = merge_env(cfg, spec, &user, tty);
|
||||
|
||||
// `working_dir: null` is `docker exec` without `-w`: the container's
|
||||
// WorkingDir, which is the agent's cwd at startup. Checked here so a bad
|
||||
// directory is a clean 126 with runc's message rather than an errno that
|
||||
// happens to collide with "no such binary".
|
||||
let cwd = match &spec.working_dir {
|
||||
Some(d) if !d.is_empty() => PathBuf::from(d),
|
||||
_ => cfg.default_cwd.clone(),
|
||||
};
|
||||
if !cwd.is_dir() {
|
||||
return Err(PrepareError::cannot_start(
|
||||
126,
|
||||
format!(
|
||||
"chdir to cwd (\"{}\") set in config.json failed: no such file or directory",
|
||||
cwd.display()
|
||||
),
|
||||
));
|
||||
}
|
||||
|
||||
let program = resolve_program(&spec.cmd[0], &env, &cwd).ok_or_else(|| {
|
||||
// runc's text for a missing binary; Docker reports it with 127 the
|
||||
// way a shell does, and the app's callers check that code.
|
||||
PrepareError::cannot_start(
|
||||
127,
|
||||
format!(
|
||||
"OCI runtime exec failed: exec failed: unable to start container process: exec: \"{}\": executable file not found in $PATH: unknown",
|
||||
spec.cmd[0]
|
||||
),
|
||||
)
|
||||
})?;
|
||||
|
||||
Ok(Prepared {
|
||||
program,
|
||||
argv: spec.cmd.clone(),
|
||||
env,
|
||||
cwd: CString::new(cwd.as_os_str().as_bytes()).map_err(|_| {
|
||||
PrepareError::Refused(StatusCode::BAD_REQUEST, "working_dir contains a NUL byte".into())
|
||||
})?,
|
||||
switch_ids: is_root,
|
||||
user,
|
||||
})
|
||||
}
|
||||
|
||||
/// Build the exec's environment.
|
||||
///
|
||||
/// Order, matching Docker's `ReplaceOrAppendEnvValues(CreateDaemonEnvironment(…), spec.Env)`:
|
||||
/// the base env; `PATH` defaulted if absent and `TERM=xterm` for a tty if
|
||||
/// absent (both Docker daemon defaults); then the spec's entries, later ones
|
||||
/// winning. An entry with no `=` *removes* that variable, as Docker does.
|
||||
/// Finally `HOME` comes from passwd unless the **spec** set it — the agent's
|
||||
/// own `HOME=/root` must not leak into a `claude` exec. Only `HOME`: runc sets
|
||||
/// nothing else for an exec, so `USER`/`LOGNAME` stay unset unless the
|
||||
/// container's env or the spec has them (verified against `docker exec` by
|
||||
/// the app's parity test, `host::agent::live_tests`).
|
||||
fn merge_env(cfg: &Config, spec: &ExecSpec, user: &UserInfo, tty: bool) -> Vec<(OsString, OsString)> {
|
||||
let mut env: Vec<(OsString, OsString)> = cfg.base_env.clone();
|
||||
fn set(env: &mut Vec<(OsString, OsString)>, k: &OsStr, v: &OsStr) {
|
||||
match env.iter_mut().find(|(ek, _)| ek == k) {
|
||||
Some(slot) => slot.1 = v.to_os_string(),
|
||||
None => env.push((k.to_os_string(), v.to_os_string())),
|
||||
}
|
||||
}
|
||||
if !env.iter().any(|(k, _)| k == "PATH") {
|
||||
set(&mut env, OsStr::new("PATH"), OsStr::new(DEFAULT_PATH));
|
||||
}
|
||||
if tty && !env.iter().any(|(k, _)| k == "TERM") {
|
||||
set(&mut env, OsStr::new("TERM"), OsStr::new("xterm"));
|
||||
}
|
||||
let mut spec_set: Vec<&str> = Vec::new();
|
||||
for entry in &spec.env {
|
||||
match entry.split_once('=') {
|
||||
Some((k, v)) => {
|
||||
set(&mut env, OsStr::new(k), OsStr::new(v));
|
||||
spec_set.push(k);
|
||||
}
|
||||
None => {
|
||||
env.retain(|(k, _)| k != entry.as_str());
|
||||
spec_set.push(entry);
|
||||
}
|
||||
}
|
||||
}
|
||||
if !spec_set.contains(&"HOME") {
|
||||
set(&mut env, OsStr::new("HOME"), OsStr::new(&user.home));
|
||||
}
|
||||
env.retain(|(k, v)| {
|
||||
!k.is_empty() && !k.as_bytes().contains(&b'=') && !k.as_bytes().contains(&0) && !v.as_bytes().contains(&0)
|
||||
});
|
||||
env
|
||||
}
|
||||
|
||||
/// `cmd[0]` through the **merged** env's `PATH` (spec), like runc's
|
||||
/// `LookPath`. A name containing `/` is used as given (relative to the
|
||||
/// working directory, as it will be after `chdir`).
|
||||
fn resolve_program(cmd0: &str, env: &[(OsString, OsString)], cwd: &Path) -> Option<PathBuf> {
|
||||
if cmd0.contains('/') {
|
||||
return Some(PathBuf::from(cmd0));
|
||||
}
|
||||
let path = env.iter().find(|(k, _)| k == "PATH").map(|(_, v)| v.clone())?;
|
||||
for dir in std::env::split_paths(&path) {
|
||||
let dir = if dir.as_os_str().is_empty() { cwd.to_path_buf() } else { cwd.join(dir) };
|
||||
let candidate = dir.join(cmd0);
|
||||
if let Ok(meta) = std::fs::metadata(&candidate) {
|
||||
if meta.is_file() && meta.permissions().mode() & 0o111 != 0 {
|
||||
return Some(candidate);
|
||||
}
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
|
||||
/// What the child's stdio should be.
|
||||
pub enum Io {
|
||||
Null,
|
||||
Piped,
|
||||
Fd(std::os::fd::OwnedFd),
|
||||
}
|
||||
|
||||
impl Io {
|
||||
fn stdio(self) -> Stdio {
|
||||
match self {
|
||||
Io::Null => Stdio::null(),
|
||||
Io::Piped => Stdio::piped(),
|
||||
Io::Fd(fd) => Stdio::from(fd),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Prepared {
|
||||
/// Spawn. `controlling_tty` makes stdin (which must be a pty slave) the
|
||||
/// session's controlling terminal.
|
||||
///
|
||||
/// Every exec runs in **its own session, hence its own process group**
|
||||
/// (`setsid`), so the output-limit kill and the disconnect `SIGHUP` reach
|
||||
/// everything it started, not just the first pid.
|
||||
pub fn spawn(
|
||||
&self,
|
||||
stdin: Io,
|
||||
stdout: Io,
|
||||
stderr: Io,
|
||||
controlling_tty: bool,
|
||||
) -> Result<tokio::process::Child, PrepareError> {
|
||||
let mut cmd = std::process::Command::new(&self.program);
|
||||
cmd.arg0(&self.argv[0])
|
||||
.args(&self.argv[1..])
|
||||
.env_clear()
|
||||
.envs(self.env.iter().map(|(k, v)| (k, v)))
|
||||
.stdin(stdin.stdio())
|
||||
.stdout(stdout.stdio())
|
||||
.stderr(stderr.stdio());
|
||||
|
||||
let switch = self.switch_ids;
|
||||
let (uid, gid) = (self.user.uid, self.user.gid);
|
||||
let groups = self.user.groups.clone();
|
||||
let cwd = self.cwd.clone();
|
||||
let dev_null = dev_null_rdev();
|
||||
// Runs in the forked child: only async-signal-safe syscalls, nothing
|
||||
// that allocates (everything it touches was built above).
|
||||
//
|
||||
// Order matters. Signal state first: dispositions and mask back to
|
||||
// the defaults. `docker exec` children start from runc's clean state,
|
||||
// but ours would otherwise inherit whatever started the agent — the
|
||||
// entrypoint's supervisor runs it under `trap '' HUP`, and an ignored
|
||||
// disposition survives `exec`, so every tty session would ignore the
|
||||
// hangup a client disconnect sends and outlive its client. (Handled
|
||||
// signals already reset on exec; std clears the mask too, but that
|
||||
// is std's detail, so we do not rely on it.) Then `setsid` (new
|
||||
// session and process group). Then the controlling tty, which needs a
|
||||
// session leader. Then stdio ownership, while still root. Then gid,
|
||||
// supplementary groups (what `initgroups` sets — resolved in the
|
||||
// parent, see `UserInfo::groups`), and uid last, since dropping uid
|
||||
// first would forfeit the right to change the other two. `chdir` comes
|
||||
// after the switch so a directory the user cannot enter fails the way
|
||||
// it does under runc, rather than succeeding as root.
|
||||
unsafe {
|
||||
cmd.pre_exec(move || {
|
||||
reset_signals()?;
|
||||
if libc::setsid() < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if controlling_tty && libc::ioctl(0, libc::TIOCSCTTY, 0) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if switch {
|
||||
fix_stdio_ownership(uid, dev_null)?;
|
||||
if libc::setgid(gid) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if libc::setgroups(groups.len() as _, groups.as_ptr()) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
if libc::setuid(uid) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
if libc::chdir(cwd.as_ptr()) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
});
|
||||
}
|
||||
// Never `kill_on_drop`: a dropped `docker exec` stream does not kill
|
||||
// the process, and neither does losing our handle to it.
|
||||
tokio::process::Command::from(cmd).spawn().map_err(|e| {
|
||||
// ENOENT here means a relative or explicit path that was not
|
||||
// there (PATH lookups were checked already): 127 like a shell.
|
||||
// Anything else — EACCES, ENOEXEC, a failed setuid — is 126,
|
||||
// "found but could not run".
|
||||
let code = if e.kind() == io::ErrorKind::NotFound { 127 } else { 126 };
|
||||
PrepareError::cannot_start(
|
||||
code,
|
||||
format!(
|
||||
"OCI runtime exec failed: exec failed: unable to start container process: exec: \"{}\": {e}: unknown",
|
||||
self.argv[0]
|
||||
),
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
/// Linux's `_NSIG`: signals are 1..=64. libc does not export it.
|
||||
const NSIG: libc::c_int = 65;
|
||||
|
||||
/// Every catchable signal to `SIG_DFL` and an empty signal mask — the state a
|
||||
/// `docker exec` child starts in. For the forked child only.
|
||||
///
|
||||
/// Raw `rt_sigaction`/`rt_sigprocmask` syscalls, not the libc wrappers: libc
|
||||
/// refuses to touch the real-time signals it reserves for itself (glibc's 32
|
||||
/// and 33), and those can arrive ignored too — seen in practice, SigIgn bit
|
||||
/// 32 set under a test harness. The syscalls also take no libc locks, which
|
||||
/// matters between fork and exec. An all-zero kernel `struct sigaction` is
|
||||
/// `SIG_DFL` with no flags, no restorer and an empty mask on every Linux
|
||||
/// architecture, whatever its field order; its size is 4 words.
|
||||
///
|
||||
/// SIGKILL and SIGSTOP cannot be changed and are skipped. Any failure fails
|
||||
/// the exec rather than running a child in an unknown signal state.
|
||||
unsafe fn reset_signals() -> io::Result<()> {
|
||||
const SIGSET_BYTES: libc::size_t = 8; // the kernel's sigset_t: 64 bits
|
||||
let dfl = [0u64; 4];
|
||||
for sig in 1..NSIG {
|
||||
if sig == libc::SIGKILL || sig == libc::SIGSTOP {
|
||||
continue;
|
||||
}
|
||||
let r = libc::syscall(
|
||||
libc::SYS_rt_sigaction,
|
||||
sig as libc::c_long,
|
||||
dfl.as_ptr(),
|
||||
std::ptr::null_mut::<u64>(),
|
||||
SIGSET_BYTES,
|
||||
);
|
||||
if r < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
}
|
||||
let empty: u64 = 0;
|
||||
let r = libc::syscall(
|
||||
libc::SYS_rt_sigprocmask,
|
||||
libc::SIG_SETMASK as libc::c_long,
|
||||
&empty as *const u64,
|
||||
std::ptr::null_mut::<u64>(),
|
||||
SIGSET_BYTES,
|
||||
);
|
||||
if r < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// The device number of `/dev/null`, read in the parent (`stat` is not
|
||||
/// something to do between fork and exec when it can be done before).
|
||||
/// Falls back to Linux's fixed 1:3.
|
||||
fn dev_null_rdev() -> libc::dev_t {
|
||||
use std::os::unix::fs::MetadataExt;
|
||||
std::fs::metadata("/dev/null")
|
||||
.map(|m| m.rdev() as libc::dev_t)
|
||||
.unwrap_or_else(|_| libc::makedev(1, 3))
|
||||
}
|
||||
|
||||
/// runc's `fixStdioPermissions`: give fds 0–2 to the exec's user, so it can
|
||||
/// reopen them — `echo x > /dev/stderr`, `tee /dev/stdout`, and on a tty the
|
||||
/// slave itself (gpg's pinentry, `screen`, `script`). Without it they stay
|
||||
/// `root`'s: a pipe is `prw------- root` and a pty slave `root tty`.
|
||||
///
|
||||
/// Like runc: only the uid changes (the gid is left to the mount), `/dev/null`
|
||||
/// is skipped (chowning it would hand the device to the user), so is an fd
|
||||
/// the user already owns, and `EINVAL`/`EPERM`/`EROFS` are ignored — an owner
|
||||
/// outside our user namespace or a read-only mount is not worth failing the
|
||||
/// exec over. For the forked child only: `fstat` and `fchown` are
|
||||
/// async-signal-safe.
|
||||
unsafe fn fix_stdio_ownership(uid: libc::uid_t, dev_null: libc::dev_t) -> io::Result<()> {
|
||||
for fd in 0..=2 {
|
||||
let mut st: libc::stat = std::mem::zeroed();
|
||||
if libc::fstat(fd, &mut st) < 0 {
|
||||
return Err(io::Error::last_os_error());
|
||||
}
|
||||
let is_dev_null = st.st_mode & libc::S_IFMT == libc::S_IFCHR && st.st_rdev == dev_null;
|
||||
if st.st_uid == uid || is_dev_null {
|
||||
continue;
|
||||
}
|
||||
if libc::fchown(fd, uid, libc::gid_t::MAX) < 0 {
|
||||
let e = io::Error::last_os_error();
|
||||
if !matches!(e.raw_os_error(), Some(libc::EINVAL | libc::EPERM | libc::EROFS)) {
|
||||
return Err(e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Docker's exit code for a process: its status, or 128 + signal when a
|
||||
/// signal killed it (what `docker inspect` and a shell both report).
|
||||
pub fn exit_code(status: ExitStatus) -> i32 {
|
||||
match (status.code(), status.signal()) {
|
||||
(Some(c), _) => c,
|
||||
(None, Some(s)) => 128 + s,
|
||||
(None, None) => 255,
|
||||
}
|
||||
}
|
||||
|
||||
/// Signal a whole exec: its pid is its process-group id (it called `setsid`).
|
||||
pub fn signal_group(pid: Option<u32>, sig: libc::c_int) {
|
||||
if let Some(pid) = pid.filter(|&p| p > 0) {
|
||||
unsafe {
|
||||
libc::kill(-(pid as libc::pid_t), sig);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
//! `GET /v1/tunnel?port=`: a WebSocket piped to `127.0.0.1:<port>` inside the
|
||||
//! container — what the auth bridge and browser view do with `socat` through
|
||||
//! the Docker API today.
|
||||
|
||||
use std::sync::Arc;
|
||||
use std::time::Duration;
|
||||
|
||||
use axum::extract::rejection::QueryRejection;
|
||||
use axum::extract::ws::{Message, WebSocket, WebSocketUpgrade};
|
||||
use axum::extract::{Query, State};
|
||||
use axum::http::StatusCode;
|
||||
use axum::response::Response;
|
||||
use serde::Deserialize;
|
||||
use tokio::io::{AsyncReadExt, AsyncWriteExt};
|
||||
use tokio::net::TcpStream;
|
||||
|
||||
use crate::{error, AppState, CountGuard, MAX_WS_MESSAGE};
|
||||
|
||||
const CONNECT_TIMEOUT: Duration = Duration::from_secs(10);
|
||||
|
||||
#[derive(Deserialize)]
|
||||
pub struct TunnelQuery {
|
||||
port: u16,
|
||||
}
|
||||
|
||||
pub async fn upgrade(
|
||||
State(st): State<Arc<AppState>>,
|
||||
q: Result<Query<TunnelQuery>, QueryRejection>,
|
||||
ws: WebSocketUpgrade,
|
||||
) -> Response {
|
||||
let port = match q {
|
||||
Ok(Query(q)) if q.port != 0 => q.port,
|
||||
Ok(_) => return error(StatusCode::BAD_REQUEST, "port must be 1-65535"),
|
||||
Err(r) => return error(StatusCode::BAD_REQUEST, format!("invalid query: {}", r.body_text())),
|
||||
};
|
||||
ws.max_message_size(MAX_WS_MESSAGE)
|
||||
.max_frame_size(MAX_WS_MESSAGE)
|
||||
.on_upgrade(move |socket| session(socket, st, port))
|
||||
}
|
||||
|
||||
async fn session(mut socket: WebSocket, st: Arc<AppState>, port: u16) {
|
||||
// Loopback only, by construction: the tunnel reaches in-container
|
||||
// listeners, never the network the container can see.
|
||||
let tcp = match tokio::time::timeout(CONNECT_TIMEOUT, TcpStream::connect(("127.0.0.1", port))).await {
|
||||
Ok(Ok(s)) => s,
|
||||
Ok(Err(e)) => return fail(socket, format!("cannot connect to 127.0.0.1:{port}: {e}")).await,
|
||||
Err(_) => return fail(socket, format!("timed out connecting to 127.0.0.1:{port}")).await,
|
||||
};
|
||||
let _ = tcp.set_nodelay(true);
|
||||
let _active = CountGuard::tunnels(&st);
|
||||
let (mut rd, mut wr) = tcp.into_split();
|
||||
let mut buf = vec![0u8; 32 * 1024];
|
||||
// When either side ends, the other is closed (spec): TCP EOF sends a
|
||||
// WebSocket close; a WebSocket close drops the TCP connection.
|
||||
loop {
|
||||
tokio::select! {
|
||||
r = rd.read(&mut buf) => match r {
|
||||
Ok(n) if n > 0 => {
|
||||
st.activity.touch();
|
||||
if socket.send(Message::Binary(buf[..n].to_vec().into())).await.is_err() {
|
||||
return;
|
||||
}
|
||||
}
|
||||
_ => {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
return;
|
||||
}
|
||||
},
|
||||
m = socket.recv() => match m {
|
||||
Some(Ok(Message::Binary(d))) => {
|
||||
st.activity.touch();
|
||||
if wr.write_all(&d).await.is_err() {
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
return;
|
||||
}
|
||||
}
|
||||
Some(Ok(Message::Text(_) | Message::Ping(_) | Message::Pong(_))) => {}
|
||||
Some(Ok(Message::Close(_))) | Some(Err(_)) | None => return,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async fn fail(mut socket: WebSocket, msg: String) {
|
||||
let _ = socket
|
||||
.send(Message::Text(serde_json::json!({ "type": "error", "error": msg }).to_string().into()))
|
||||
.await;
|
||||
let _ = socket.send(Message::Close(None)).await;
|
||||
}
|
||||
@@ -0,0 +1,131 @@
|
||||
//! User resolution through `/etc/passwd` and `/etc/group`.
|
||||
//!
|
||||
//! The static musl build has no NSS, so `getpwnam_r` and `getgrouplist` read
|
||||
//! the container's files directly — which is exactly the database `docker exec
|
||||
//! --user` resolves against (runc parses `/etc/passwd` itself), so the two
|
||||
//! cannot disagree about who a name is.
|
||||
|
||||
use std::ffi::{CStr, CString};
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct UserInfo {
|
||||
pub name: String,
|
||||
pub uid: libc::uid_t,
|
||||
pub gid: libc::gid_t,
|
||||
pub home: String,
|
||||
/// Supplementary groups, primary included — what `initgroups` would set.
|
||||
/// Resolved here, in the parent, because `pre_exec` runs between `fork`
|
||||
/// and `exec` in a multi-threaded process where reading files and
|
||||
/// allocating are not safe; the child only calls `setgroups` on this list.
|
||||
pub groups: Vec<libc::gid_t>,
|
||||
}
|
||||
|
||||
/// Resolve a user name (or, as Docker also allows, a numeric uid).
|
||||
///
|
||||
/// A numeric uid with no passwd entry still resolves, with gid 0 and HOME `/`,
|
||||
/// because that is what runc does for `--user 1234` in an image without that
|
||||
/// user.
|
||||
pub fn lookup(name: &str) -> Option<UserInfo> {
|
||||
let cname = CString::new(name).ok()?;
|
||||
if let Some(u) = getpw(|pwd, buf, len, res| unsafe {
|
||||
libc::getpwnam_r(cname.as_ptr(), pwd, buf, len, res)
|
||||
}) {
|
||||
return Some(u);
|
||||
}
|
||||
let uid: libc::uid_t = name.parse().ok()?;
|
||||
if let Some(u) = getpw(|pwd, buf, len, res| unsafe { libc::getpwuid_r(uid, pwd, buf, len, res) }) {
|
||||
return Some(u);
|
||||
}
|
||||
Some(UserInfo {
|
||||
name: name.to_string(),
|
||||
uid,
|
||||
gid: 0,
|
||||
home: "/".to_string(),
|
||||
groups: vec![0],
|
||||
})
|
||||
}
|
||||
|
||||
/// The user the agent itself runs as.
|
||||
pub fn current() -> Option<UserInfo> {
|
||||
let uid = unsafe { libc::geteuid() };
|
||||
getpw(|pwd, buf, len, res| unsafe { libc::getpwuid_r(uid, pwd, buf, len, res) })
|
||||
}
|
||||
|
||||
fn getpw(
|
||||
call: impl Fn(*mut libc::passwd, *mut libc::c_char, libc::size_t, *mut *mut libc::passwd) -> libc::c_int,
|
||||
) -> Option<UserInfo> {
|
||||
let mut size = 1024usize;
|
||||
loop {
|
||||
let mut pwd: libc::passwd = unsafe { std::mem::zeroed() };
|
||||
let mut buf = vec![0 as libc::c_char; size];
|
||||
let mut res: *mut libc::passwd = std::ptr::null_mut();
|
||||
let rc = call(&mut pwd, buf.as_mut_ptr(), buf.len(), &mut res);
|
||||
if rc == libc::ERANGE && size < (1 << 20) {
|
||||
size *= 4;
|
||||
continue;
|
||||
}
|
||||
if rc != 0 || res.is_null() {
|
||||
return None;
|
||||
}
|
||||
let name = unsafe { CStr::from_ptr(pwd.pw_name) }.to_string_lossy().into_owned();
|
||||
let home = unsafe { CStr::from_ptr(pwd.pw_dir) }.to_string_lossy().into_owned();
|
||||
let groups = group_list(&name, pwd.pw_gid);
|
||||
return Some(UserInfo {
|
||||
name,
|
||||
uid: pwd.pw_uid,
|
||||
gid: pwd.pw_gid,
|
||||
home,
|
||||
groups,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
fn group_list(name: &str, gid: libc::gid_t) -> Vec<libc::gid_t> {
|
||||
let Ok(cname) = CString::new(name) else {
|
||||
return vec![gid];
|
||||
};
|
||||
let mut n: libc::c_int = 32;
|
||||
loop {
|
||||
let mut groups = vec![0 as libc::gid_t; n as usize];
|
||||
let mut count = n;
|
||||
let rc = unsafe { libc::getgrouplist(cname.as_ptr(), gid, groups.as_mut_ptr(), &mut count) };
|
||||
if rc >= 0 {
|
||||
groups.truncate(count.max(0) as usize);
|
||||
return groups;
|
||||
}
|
||||
// -1 means "too small" and `count` now holds the size needed (glibc
|
||||
// and musl both). Guard against a libc that does not update it.
|
||||
if count <= n {
|
||||
if n >= 65536 {
|
||||
return vec![gid];
|
||||
}
|
||||
n *= 2;
|
||||
} else {
|
||||
n = count;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn resolves_the_current_user_by_name_and_uid() {
|
||||
let me = current().expect("current user has a passwd entry");
|
||||
let by_name = lookup(&me.name).unwrap();
|
||||
assert_eq!(by_name.uid, me.uid);
|
||||
assert!(by_name.groups.contains(&me.gid));
|
||||
let by_uid = lookup(&me.uid.to_string()).unwrap();
|
||||
assert_eq!(by_uid.name, me.name);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn unknown_names_do_not_resolve() {
|
||||
assert!(lookup("no-such-user-triple-c").is_none());
|
||||
assert!(lookup("bad\0name").is_none());
|
||||
// A bare uid nobody owns still resolves, as runc does.
|
||||
let u = lookup("4000123").unwrap();
|
||||
assert_eq!((u.uid, u.gid, u.home.as_str()), (4000123, 0, "/"));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,167 @@
|
||||
//! Execs must start with default signal dispositions and an empty mask, no
|
||||
//! matter what the agent itself inherited — the clean state a `docker exec`
|
||||
//! child gets from runc.
|
||||
//!
|
||||
//! In production the entrypoint's supervisor runs the agent under
|
||||
//! `trap '' HUP`, and an ignored disposition survives `exec`. These tests
|
||||
//! reproduce that by ignoring SIGHUP (and SIGUSR1) in *this* test process,
|
||||
//! which is where the in-process server spawns from. A disposition is
|
||||
//! process-wide, which is why this is its own test binary rather than part of
|
||||
//! `protocol.rs`.
|
||||
|
||||
use std::net::SocketAddr;
|
||||
use std::sync::Once;
|
||||
use std::time::{Duration, Instant};
|
||||
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use serde_json::{json, Value};
|
||||
use tokio_tungstenite::tungstenite::client::IntoClientRequest;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
use triple_c_agent::{serve, Config};
|
||||
|
||||
const TOKEN: &str = "test-token-0123456789abcdef0123456789abcdef";
|
||||
|
||||
fn ignore_signals_like_the_supervisor() {
|
||||
static ONCE: Once = Once::new();
|
||||
ONCE.call_once(|| unsafe {
|
||||
libc::signal(libc::SIGHUP, libc::SIG_IGN);
|
||||
libc::signal(libc::SIGUSR1, libc::SIG_IGN);
|
||||
});
|
||||
}
|
||||
|
||||
/// A `/proc/<pid>/status` mask field (`SigIgn`, `SigBlk`, …) as a bitmask.
|
||||
fn mask(status: &str, field: &str) -> u64 {
|
||||
let line = status
|
||||
.lines()
|
||||
.find(|l| l.starts_with(&format!("{field}:")))
|
||||
.unwrap_or_else(|| panic!("no {field} in status"));
|
||||
u64::from_str_radix(line.split_whitespace().nth(1).unwrap(), 16).unwrap()
|
||||
}
|
||||
|
||||
fn bit(sig: libc::c_int) -> u64 {
|
||||
1 << (sig - 1)
|
||||
}
|
||||
|
||||
struct Agent {
|
||||
addr: SocketAddr,
|
||||
http: reqwest::Client,
|
||||
_cwd: tempfile::TempDir,
|
||||
user: String,
|
||||
}
|
||||
|
||||
async fn start() -> Agent {
|
||||
ignore_signals_like_the_supervisor();
|
||||
// The precondition, checked rather than assumed.
|
||||
let own = std::fs::read_to_string("/proc/self/status").unwrap();
|
||||
assert_ne!(mask(&own, "SigIgn") & bit(libc::SIGHUP), 0, "the test process must ignore SIGHUP");
|
||||
|
||||
let cwd = tempfile::tempdir().unwrap();
|
||||
let config = Config {
|
||||
token: TOKEN.to_string(),
|
||||
default_cwd: cwd.path().to_path_buf(),
|
||||
base_env: vec![("PATH".into(), "/usr/local/bin:/usr/bin:/bin".into())],
|
||||
max_file_bytes: 1024,
|
||||
header_read_timeout: triple_c_agent::HEADER_READ_TIMEOUT,
|
||||
max_connections: triple_c_agent::MAX_CONNECTIONS,
|
||||
};
|
||||
let listener = tokio::net::TcpListener::bind("127.0.0.1:0").await.unwrap();
|
||||
let addr = listener.local_addr().unwrap();
|
||||
tokio::spawn(serve(listener, config));
|
||||
let user = String::from_utf8(std::process::Command::new("id").arg("-un").output().unwrap().stdout)
|
||||
.unwrap()
|
||||
.trim()
|
||||
.to_string();
|
||||
Agent { addr, http: reqwest::Client::new(), _cwd: cwd, user }
|
||||
}
|
||||
|
||||
impl Agent {
|
||||
async fn get(&self, path: &str) -> Value {
|
||||
let r = self.http.get(format!("http://{}{path}", self.addr)).bearer_auth(TOKEN).send().await.unwrap();
|
||||
assert_eq!(r.status().as_u16(), 200);
|
||||
r.json().await.unwrap()
|
||||
}
|
||||
}
|
||||
|
||||
fn process_gone(pid: i32) -> bool {
|
||||
match std::fs::read_to_string(format!("/proc/{pid}/stat")) {
|
||||
Err(_) => true,
|
||||
Ok(s) => s.rsplit(')').next().unwrap_or("").trim_start().starts_with('Z'),
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn execs_start_with_no_ignored_or_blocked_signals() {
|
||||
let a = start().await;
|
||||
let r = a
|
||||
.http
|
||||
.post(format!("http://{}/v1/exec", a.addr))
|
||||
.bearer_auth(TOKEN)
|
||||
.json(&json!({ "cmd": ["cat", "/proc/self/status"], "user": a.user }))
|
||||
.send()
|
||||
.await
|
||||
.unwrap();
|
||||
assert_eq!(r.status().as_u16(), 200);
|
||||
let v: Value = r.json().await.unwrap();
|
||||
assert_eq!(v["exit_code"], 0, "{v}");
|
||||
use base64::Engine;
|
||||
let status: String = v["chunks"]
|
||||
.as_array()
|
||||
.unwrap()
|
||||
.iter()
|
||||
.filter(|c| c["s"] == "out")
|
||||
.map(|c| {
|
||||
let b = base64::engine::general_purpose::STANDARD.decode(c["d"].as_str().unwrap()).unwrap();
|
||||
String::from_utf8(b).unwrap()
|
||||
})
|
||||
.collect();
|
||||
assert_eq!(mask(&status, "SigIgn"), 0, "inherited ignored signals:\n{status}");
|
||||
assert_eq!(mask(&status, "SigBlk"), 0, "inherited blocked signals:\n{status}");
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn a_dropped_tty_attach_still_hangs_up_when_the_agent_ignores_sighup() {
|
||||
let a = start().await;
|
||||
let mut req = format!("ws://{}/v1/attach", a.addr).into_client_request().unwrap();
|
||||
req.headers_mut().insert("Authorization", format!("Bearer {TOKEN}").parse().unwrap());
|
||||
let mut ws = tokio_tungstenite::connect_async(req).await.unwrap().0;
|
||||
let spec = json!({
|
||||
"cmd": ["sh", "-c", "echo pid=$$; exec sleep 600"],
|
||||
"user": a.user, "tty": true, "attach_stdin": true,
|
||||
});
|
||||
ws.send(Message::Text(spec.to_string().into())).await.unwrap();
|
||||
|
||||
let mut out = String::new();
|
||||
let pid: i32 = loop {
|
||||
match tokio::time::timeout(Duration::from_secs(10), ws.next()).await.expect("timed out") {
|
||||
Some(Ok(Message::Text(_))) => {} // "started"
|
||||
Some(Ok(Message::Binary(b))) => {
|
||||
out.push_str(&String::from_utf8_lossy(&b[1..]));
|
||||
if let Some(rest) = out.split("pid=").nth(1) {
|
||||
if let Some(line) = rest.split_once(['\r', '\n']) {
|
||||
break line.0.trim().parse().unwrap();
|
||||
}
|
||||
}
|
||||
}
|
||||
other => panic!("unexpected {other:?}"),
|
||||
}
|
||||
};
|
||||
assert_eq!(a.get("/v1/activity").await["active_execs"], 1);
|
||||
// Let `exec sleep` happen, so the process we watch is the sleep.
|
||||
tokio::time::sleep(Duration::from_millis(200)).await;
|
||||
drop(ws);
|
||||
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
while !process_gone(pid) {
|
||||
assert!(Instant::now() < deadline, "sleep (pid {pid}) survived the client disconnect");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
let deadline = Instant::now() + Duration::from_secs(5);
|
||||
loop {
|
||||
let v = a.get("/v1/activity").await;
|
||||
if v["active_execs"] == 0 {
|
||||
break;
|
||||
}
|
||||
assert!(Instant::now() < deadline, "active_execs never returned to 0: {v}");
|
||||
tokio::time::sleep(Duration::from_millis(50)).await;
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -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.
|
||||
@@ -0,0 +1,146 @@
|
||||
# triple-c-agent — wire protocol (v1)
|
||||
|
||||
Date: 2026-11-13
|
||||
Status: draft, phase 2 of `2026-11-13-aws-ecs-host-design.md` ("Connectivity")
|
||||
|
||||
## Purpose
|
||||
|
||||
`triple-c-agent` is a small static binary inside the project container. On AWS, the app reaches
|
||||
it through one SSM port-forwarding session. Locally it can be reached through a published
|
||||
loopback port, which is how it is tested. It does, over HTTP, everything `host::ContainerTarget`
|
||||
does through the Docker API today: one-shot, attached and detached execs, resize, exit status,
|
||||
file upload, archive download, and TCP tunnels to in-container loopback ports.
|
||||
|
||||
**Docker parity is the requirement.** The same `ExecSpec` must behave the same through the agent
|
||||
as through `docker exec`: same user, working directory, environment and output split. Where Docker
|
||||
has a quirk the app relies on, the agent reproduces it, and this document names it.
|
||||
|
||||
## Process
|
||||
|
||||
- **Binary:** `/usr/local/bin/triple-c-agent`, built from `agent/` in the repo, static
|
||||
(`*-unknown-linux-musl`), for amd64 and arm64.
|
||||
- **Runs as root**, started by `entrypoint.sh` before the keepalive, so a `claude`-user
|
||||
`pkill`/`kill -9 -1` can't kill it (the same rule as the keepalive; see CLAUDE.md).
|
||||
- **Listens on** `TRIPLEC_AGENT_ADDR`, default `127.0.0.1:47900`. It never binds a non-loopback
|
||||
address unless that address is given explicitly. The local test harness publishes the port to
|
||||
the host's loopback.
|
||||
- **Token:** `TRIPLEC_AGENT_TOKEN` (env) or the file at `TRIPLEC_AGENT_TOKEN_FILE`. If neither
|
||||
gives a non-empty token of at least 32 bytes, **the agent refuses to start** — there is no
|
||||
unauthenticated mode. After reading the token, the agent removes `TRIPLEC_AGENT_TOKEN` from its
|
||||
own environment, so execs don't inherit it.
|
||||
- **Base environment for execs:** the agent's own environment at startup (minus the token), which
|
||||
is the container's configured env — the same thing `docker exec` inherits.
|
||||
- **Default working directory:** the agent's cwd at startup (the container's `WorkingDir`), used
|
||||
when a spec gives `working_dir: null`. This matches `docker exec` without `-w`.
|
||||
|
||||
## Common rules
|
||||
|
||||
- **Every request** needs `Authorization: Bearer <token>`. A missing or wrong token gets `401`
|
||||
with an empty body. The comparison is constant-time.
|
||||
- **Errors** are `4xx`/`5xx` with body `{"error": "<message>"}`, Content-Type `application/json`.
|
||||
- **JSON** is UTF-8. Raw bytes inside JSON are standard base64 with padding.
|
||||
- **Users** are names resolved through `/etc/passwd` and `/etc/group` in the container. An exec
|
||||
runs with the user's uid, primary gid and supplementary groups (`initgroups`). `HOME` is set
|
||||
from passwd **unless the spec's env already sets it**; `USER` and `LOGNAME` are *not* added
|
||||
(runc sets only `HOME` for an exec, verified by the app's parity test). When the agent is
|
||||
not root (tests), a spec naming a user other than the agent's own fails with `403`.
|
||||
- **ExecSpec** (JSON):
|
||||
```json
|
||||
{"cmd": ["sh","-c","…"], "user": "claude", "working_dir": "/workspace" | null,
|
||||
"env": ["K=V", …], "tty": false}
|
||||
```
|
||||
`cmd` must be non-empty. `env` entries are `K=V`; later entries win, and they override the base
|
||||
environment. `cmd[0]` is resolved through `PATH` from the **merged** environment.
|
||||
|
||||
## Endpoints
|
||||
|
||||
### `GET /v1/health`
|
||||
`200 {"agent": "triple-c-agent", "version": "<semver>", "protocol": 1}`.
|
||||
|
||||
### `POST /v1/exec` — one-shot
|
||||
Body: an ExecSpec plus `"limit": <bytes>`. stdin is `/dev/null`, there is no tty, and the agent
|
||||
waits for exit.
|
||||
|
||||
`200 {"chunks": [{"s": "out"|"err", "d": "<base64>"}, …], "exit_code": <int>}`
|
||||
|
||||
- `chunks` are in **arrival order**. The client rebuilds the combined text, stdout only, and
|
||||
stderr only from them, which is exactly what `docker/exec.rs`'s `OneshotOutput` does.
|
||||
- When the combined size would exceed `limit`, the agent kills the process group and returns
|
||||
`413 {"error": "OUTPUT_LIMIT: Command output exceeded <limit> bytes and was abandoned"}`. The
|
||||
client passes that message through unchanged, so `OUTPUT_LIMIT_MARKER` checks keep working.
|
||||
- A process killed by a signal reports `exit_code` 128 + signal, as Docker does.
|
||||
- If the command can't be started (no such binary or user), the response is `200` with
|
||||
`exit_code` 126 or 127 and the error text in one `err` chunk. This mirrors what a shell and
|
||||
Docker report.
|
||||
|
||||
### `POST /v1/exec/detached`
|
||||
Body: an ExecSpec. Starts the command with stdin, stdout and stderr all on `/dev/null`.
|
||||
`200 {"exec_id": "<id>"}`.
|
||||
|
||||
### `GET /v1/exec/{id}`
|
||||
`200 {"running": bool, "exit_code": <int> | null}`. Ids come from `detached` and `attach`. The
|
||||
agent keeps finished entries for at least 10 minutes. An unknown id gets `404`.
|
||||
|
||||
### `POST /v1/exec/{id}/resize`
|
||||
Body: `{"cols": u16, "rows": u16}`. `204`. Allowed only for tty execs; anything else gets `409`.
|
||||
|
||||
### `GET /v1/attach` — WebSocket
|
||||
1. The client sends one **text** frame: an ExecSpec plus `"attach_stdin": bool`, and for tty
|
||||
execs optionally `"cols"` and `"rows"`.
|
||||
2. The agent replies with **text** `{"type": "started", "exec_id": "<id>"}`, or with
|
||||
`{"type": "error", "error": "…"}` followed by a close.
|
||||
3. **Agent → client binary frames:** byte 0 is the channel (`1` = stdout, `2` = stderr) and the
|
||||
rest is payload. With `tty: true` the command runs on a pty and everything is channel `1`
|
||||
(Docker's `Console` maps to stdout).
|
||||
4. **Client → agent binary frames:** stdin bytes. These are ignored when `attach_stdin` is false;
|
||||
in that case stdin is `/dev/null`.
|
||||
5. **Client → agent text** `{"type": "close_stdin"}`: closes the child's stdin (EOF). Without a
|
||||
tty, closing the WebSocket's write side has the same effect.
|
||||
6. When the process exits and all output has been sent, the agent sends **text**
|
||||
`{"type": "exit", "code": <int>}` and closes. The exit code is also kept for
|
||||
`GET /v1/exec/{id}`.
|
||||
7. If the client goes away, the agent sends SIGHUP to the process group (tty) or closes stdin
|
||||
(non-tty), as a dropped `docker exec` does. **It does not kill a non-tty process**: Docker
|
||||
doesn't either, and `gh_login`/`setup-token` clean up with their own `pkill`.
|
||||
|
||||
### `PUT /v1/files?dir=<abs>&name=<name>&mode=<octal>&uid=<n>&gid=<n>`
|
||||
The body is the raw file bytes, at most 256 MiB plus 1 (`MAX_DROP_BYTES`). Writes
|
||||
`dir/name` with `mode`, owned by `uid:gid`, mtime now, replacing an existing file the way tar
|
||||
extraction does (unlink, then create). `dir` must exist; it is not created. `name` must be a
|
||||
single path component. `200 {"path": "<dir>/<name>"}`.
|
||||
|
||||
### `GET /v1/archive?path=<abs>`
|
||||
`200 application/x-tar`, streamed: a tar of `path`, whose root member is `basename(path)`, with
|
||||
recursion for directories. This matches Docker's `GET /containers/{id}/archive`. A missing path
|
||||
gets `404`. Dropping the connection cancels the read.
|
||||
|
||||
### `GET /v1/tunnel?port=<n>` — WebSocket
|
||||
The agent connects to `127.0.0.1:<n>` **inside the container** and pipes binary frames both ways.
|
||||
If the connection is refused, the agent sends text `{"type": "error", "error": "…"}` and closes.
|
||||
When either side ends, the other is closed.
|
||||
|
||||
### `GET /v1/activity`
|
||||
`200 {"last_activity_unix": <int>, "active_execs": <int>, "active_tunnels": <int>}`.
|
||||
"Activity" means any request other than `health` and `activity`, or any byte through an attach or
|
||||
tunnel. This is used by idle auto-stop later.
|
||||
|
||||
## Client mapping (`host::ContainerTarget::Agent`)
|
||||
|
||||
| ContainerTarget | Agent call |
|
||||
|---|---|
|
||||
| `exec_oneshot*` (all variants) | `POST /v1/exec`, with `limit` = the variant's limit (`MAX_ONESHOT_OUTPUT` or explicit) |
|
||||
| `exec_detached` | `POST /v1/exec/detached` |
|
||||
| `attach` / `attach_exec` | `GET /v1/attach`; `input` is an `AsyncWrite` whose shutdown sends `close_stdin` |
|
||||
| `wait_exec_exit` | poll `GET /v1/exec/{id}` (same budget as the Docker version: 200 × 25 ms, shared constants in `docker/exec.rs`) |
|
||||
| `resize_exec` | `POST /v1/exec/{id}/resize` |
|
||||
| `user_ids` | one-shot `sh -c 'id -u; id -g'` as `claude`, falling back to `(1000, 1000)` |
|
||||
| `upload_bytes*`, `upload_host_file*` | `PUT /v1/files`; a multi-component `name` (the terminal drop's `triple-c-drops/<file>`) is split into `dir` + last component, `.`/`..` refused |
|
||||
| `download_archive` | `GET /v1/archive` |
|
||||
| `is_running` | `GET /v1/health` succeeds → `true`; a connection error → `false` |
|
||||
| `has_exited` | `false` (only the host can say positively; an AWS host answers from ECS) |
|
||||
| (future) tunnels | `GET /v1/tunnel` |
|
||||
|
||||
## Not in v1
|
||||
|
||||
TLS (the transport is the SSM channel or loopback), multiplexing within one connection (the SSM
|
||||
port-forward multiplexes TCP connections), filesystem watch, and auth rotation while running.
|
||||
Loaded 100 of 103 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user