diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 63c9701..fb13e68 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -61,6 +61,13 @@ name: Build App (Preview) # for real releases. See triple-c#32.) env: + # The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after + # any apt-get install stops at an interactive "Daemons using outdated + # libraries" prompt and hangs the job until it times out. `sudo` resets the + # environment, so the apt-get calls below also pass these via `sudo env`; + # here they reach the NodeSource setup script through `sudo -E`. + NEEDRESTART_MODE: a + DEBIAN_FRONTEND: noninteractive GITEA_URL: ${{ gitea.server_url }} REPO: ${{ gitea.repository }} # How many preview releases survive a run, newest first — including the one @@ -262,7 +269,7 @@ jobs: fi if [ "$NEED_INSTALL" = true ]; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs hash -r fi node --version @@ -273,8 +280,8 @@ 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 \ libayatana-appindicator3-dev \ @@ -344,7 +351,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,8 +374,8 @@ 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 \ libayatana-appindicator3-dev \ @@ -655,7 +662,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 ) diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 96be408..913e535 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -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,8 +147,8 @@ 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 \ libayatana-appindicator3-dev \ @@ -596,7 +603,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 ) diff --git a/.gitea/workflows/build-stt.yml b/.gitea/workflows/build-stt.yml index 8341957..a8c38c5 100644 --- a/.gitea/workflows/build-stt.yml +++ b/.gitea/workflows/build-stt.yml @@ -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 diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 3c3d8dc..ecd4453 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -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 }} diff --git a/CLAUDE.md b/CLAUDE.md index 6040fd9..56f6cba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -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-/…` 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-`), a managed `ViewerRegistry`, - resolution by probing `/workspace/

` then `/workspace//

` in one exec as `claude`, + resolution by probing `/workspace/

` then `/workspace//

` in one exec as `claude` + (`~/p` is `/home/claude/p`; a relative miss gets a second exec at `/home/claude/

`, `/tmp/

`), 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 diff --git a/app/package-lock.json b/app/package-lock.json index 5d3ca19..5ab65a9 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -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 } } }, diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index ed81dbc..22b1543 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -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]] @@ -6570,7 +6628,7 @@ dependencies = [ "keyring", "local-ip-address", "log", - "rand 0.9.2", + "rand 0.9.5", "reqwest 0.12.28", "serde", "serde_json", @@ -6606,7 +6664,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.5", "sha1", "thiserror 2.0.18", "utf-8", @@ -7045,18 +7103,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 +7177,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]] diff --git a/app/src-tauri/src/commands/file_viewer_commands.rs b/app/src-tauri/src/commands/file_viewer_commands.rs index 2af9605..dc9f65f 100644 --- a/app/src-tauri/src/commands/file_viewer_commands.rs +++ b/app/src-tauri/src/commands/file_viewer_commands.rs @@ -15,7 +15,7 @@ use crate::file_viewer::poll::{poll_file, ViewerPoll}; use crate::file_viewer::registry::{ Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState, }; -use crate::file_viewer::resolve::{candidate_paths, probe_candidates}; +use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates}; use crate::file_viewer::window::open_viewer_window; use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES}; use crate::models::Project; @@ -159,8 +159,17 @@ pub async fn open_file_viewer( let container_id = running_container_of(&project, "opening files").await?; let mounts: Vec = project.paths.iter().map(|p| p.mount_name.clone()).collect(); - let candidates = candidate_paths(&path, &mounts)?; - let matches = probe_candidates(&container_id, &candidates).await?; + let mut candidates = candidate_paths(&path, &mounts)?; + let mut matches = probe_candidates(&container_id, &candidates).await?; + // Not in the project: try home and /tmp, as a second exec so a project file + // never becomes a chooser because a namesake sits in /tmp. + if matches.is_empty() { + let fallbacks = fallback_candidate_paths(&path); + if !fallbacks.is_empty() { + matches = probe_candidates(&container_id, &fallbacks).await?; + candidates.extend(fallbacks); + } + } let initial = Location { line, col, end_line }; let target_state = match matches.len() { diff --git a/app/src-tauri/src/commands/inspect_commands.rs b/app/src-tauri/src/commands/inspect_commands.rs index d9d85b7..0bb1059 100644 --- a/app/src-tauri/src/commands/inspect_commands.rs +++ b/app/src-tauri/src/commands/inspect_commands.rs @@ -63,6 +63,7 @@ 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::AppState; /// Newest N session transcripts to inspect. Caps the work done inside the @@ -72,8 +73,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. diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 6bda607..3d0ced1 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -124,6 +124,16 @@ Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it on every container start. To turn sandbox off, stop the container in Triple-C, flip the "Sandbox mode" switch off, then start the container."#; +/// The Triple-C terminal opens a printed path on click (Shift/Option+click while +/// a program holds the mouse — `opensOnClick` in TerminalView) by looking for it +/// in the project, then `~` and `/tmp`. A relative name for a file elsewhere +/// (Claude Code's scratchpad under `/tmp/claude-/...` above all) cannot +/// be found, so ask for the whole path. Part of the instructions fingerprint, +/// so adding or changing it recreates each container once on its next start. +const FILE_LINK_INSTRUCTIONS: &str = r#"## Referring to files + +The user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened."#; + /// Build the full CLAUDE_INSTRUCTIONS value by merging global + project /// instructions, appending port mapping docs, and appending scheduler docs. /// Used by both create_container() and container_needs_recreation() to ensure @@ -163,6 +173,11 @@ fn build_claude_instructions( None => SCHEDULER_INSTRUCTIONS.to_string(), }); + combined = Some(match combined { + Some(existing) => format!("{}\n\n{}", existing, FILE_LINK_INSTRUCTIONS), + None => FILE_LINK_INSTRUCTIONS.to_string(), + }); + if sandbox_enabled { combined = Some(match combined { Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS), @@ -4137,6 +4152,31 @@ mod tests { const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL; const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL; + /// The terminal can only open a file it can find: a bare name is looked + /// for in the project, home and /tmp, but a long scratchpad path printed + /// as `scratchpad/notes.md` is nowhere. Every container gets the ask to + /// print the full path, whatever else is configured. + #[test] + fn every_container_is_asked_to_print_full_paths_for_files_outside_the_project() { + for (global, project, sandbox) in [(None, None, false), (Some("g"), Some("p"), true)] { + let s = build_claude_instructions(global, project, &[], false, sandbox).unwrap(); + assert!(s.contains(FILE_LINK_INSTRUCTIONS), "{:?}", (global, project, sandbox)); + } + assert!(FILE_LINK_INSTRUCTIONS.contains("full absolute path")); + assert!(FILE_LINK_INSTRUCTIONS.contains("/tmp")); + } + + /// The gesture is TerminalView's `opensOnClick`: a plain click, unless a + /// program holds the mouse — then xterm's force-selection modifier, which + /// is Shift except on macOS, where it is Option. Naming only Shift sent Mac + /// users to a key that does nothing. + #[test] + fn the_file_link_instructions_name_the_gesture_for_every_platform() { + assert!(FILE_LINK_INSTRUCTIONS.contains("can click a file path")); + assert!(FILE_LINK_INSTRUCTIONS.contains("Shift+click")); + assert!(FILE_LINK_INSTRUCTIONS.contains("Option+click on macOS")); + } + fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> { compute_model_aliases(model, haiku) } diff --git a/app/src-tauri/src/docker/gateway.rs b/app/src-tauri/src/docker/gateway.rs index c32d60a..f9d69ab 100644 --- a/app/src-tauri/src/docker/gateway.rs +++ b/app/src-tauri/src/docker/gateway.rs @@ -45,10 +45,11 @@ 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"; +/// 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_IMAGE: &str = "ghcr.io/berriai/litellm:v1.106.6"; const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest"; const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile"); diff --git a/app/src-tauri/src/docker/image.rs b/app/src-tauri/src/docker/image.rs index 7a664b3..7c0970a 100644 --- a/app/src-tauri/src/docker/image.rs +++ b/app/src-tauri/src/docker/image.rs @@ -339,3 +339,54 @@ 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' }}")); + } +} diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 97113fe..2bcc6a5 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -8,6 +8,9 @@ pub mod legacy_cleanup; pub mod migration; pub mod stt; +/// The container user `claude`'s home directory, as the image creates it. +pub(crate) const CONTAINER_HOME: &str = "/home/claude"; + #[allow(unused_imports)] pub use gateway::*; #[allow(unused_imports)] diff --git a/app/src-tauri/src/file_viewer/resolve.rs b/app/src-tauri/src/file_viewer/resolve.rs index b026444..f9f1730 100644 --- a/app/src-tauri/src/file_viewer/resolve.rs +++ b/app/src-tauri/src/file_viewer/resolve.rs @@ -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/`, so those are the roots probed, in that order. The probe +//! `/workspace/`, 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; 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, 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 = 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, 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`]`/

`. 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 { + 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 { + 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('/'); @@ -129,6 +170,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()); diff --git a/app/src/components/terminal/TerminalView.test.tsx b/app/src/components/terminal/TerminalView.test.tsx index 8de6e72..b7799bb 100644 --- a/app/src/components/terminal/TerminalView.test.tsx +++ b/app/src/components/terminal/TerminalView.test.tsx @@ -60,6 +60,8 @@ const ptyOutput = vi.hoisted(() => ({ const xterm = vi.hoisted(() => ({ options: null as Record | 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 | 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((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((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", 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; + + 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"); + }); +}); diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 180d60e..1db5253 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -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), ); diff --git a/app/src/components/terminal/copyCleanup.test.ts b/app/src/components/terminal/copyCleanup.test.ts new file mode 100644 index 0000000..b1ff19d --- /dev/null +++ b/app/src/components/terminal/copyCleanup.test.ts @@ -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", + ); + }); +}); diff --git a/app/src/components/terminal/copyCleanup.ts b/app/src/components/terminal/copyCleanup.ts new file mode 100644 index 0000000..db72cd3 --- /dev/null +++ b/app/src/components/terminal/copyCleanup.ts @@ -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 = [ + [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)); +} diff --git a/app/src/components/terminal/filePathLinkProvider.test.ts b/app/src/components/terminal/filePathLinkProvider.test.ts index 43119c3..b3d5945 100644 --- a/app/src/components/terminal/filePathLinkProvider.test.ts +++ b/app/src/components/terminal/filePathLinkProvider.test.ts @@ -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(); diff --git a/app/src/components/terminal/filePathLinkProvider.ts b/app/src/components/terminal/filePathLinkProvider.ts index e6fbda5..565635f 100644 --- a/app/src/components/terminal/filePathLinkProvider.ts +++ b/app/src/components/terminal/filePathLinkProvider.ts @@ -17,7 +17,7 @@ export interface FilePathHover { } export function createFilePathLinkProvider( - term: Pick, + term: Pick, 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-/...` 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); diff --git a/app/src/components/terminal/osc52.test.ts b/app/src/components/terminal/osc52.test.ts index ba1f442..63f4f8d 100644 --- a/app/src/components/terminal/osc52.test.ts +++ b/app/src/components/terminal/osc52.test.ts @@ -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(""); }); }); diff --git a/app/src/components/terminal/osc52.ts b/app/src/components/terminal/osc52.ts new file mode 100644 index 0000000..d94cf86 --- /dev/null +++ b/app/src/components/terminal/osc52.ts @@ -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); +} diff --git a/app/src/lib/filePathLinks.ts b/app/src/lib/filePathLinks.ts index c5f64a9..2466741 100644 --- a/app/src/lib/filePathLinks.ts +++ b/app/src/lib/filePathLinks.ts @@ -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+)+$/; diff --git a/app/src/lib/urlDetector.test.ts b/app/src/lib/urlDetector.test.ts index 875bb9d..a222b7e 100644 --- a/app/src/lib/urlDetector.test.ts +++ b/app/src/lib/urlDetector.test.ts @@ -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 diff --git a/app/src/lib/urlDetector.ts b/app/src/lib/urlDetector.ts index b2fbb22..f0a9215 100644 --- a/app/src/lib/urlDetector.ts +++ b/app/src/lib/urlDetector.ts @@ -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; diff --git a/app/src/lib/urlMatch.test.ts b/app/src/lib/urlMatch.test.ts new file mode 100644 index 0000000..97864bd --- /dev/null +++ b/app/src/lib/urlMatch.test.ts @@ -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/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(); + }); +}); diff --git a/app/src/lib/urlMatch.ts b/app/src/lib/urlMatch.ts new file mode 100644 index 0000000..4641abc --- /dev/null +++ b/app/src/lib/urlMatch.ts @@ -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; +} diff --git a/app/src/lib/xtermLineJoin.test.ts b/app/src/lib/xtermLineJoin.test.ts index b50d08b..b777b3b 100644 --- a/app/src/lib/xtermLineJoin.test.ts +++ b/app/src/lib/xtermLineJoin.test.ts @@ -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] }; diff --git a/app/src/lib/xtermLineJoin.ts b/app/src/lib/xtermLineJoin.ts index 1be6501..c1f9af8 100644 --- a/app/src/lib/xtermLineJoin.ts +++ b/app/src/lib/xtermLineJoin.ts @@ -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-/…` 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 & Partial>, + 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 }; } diff --git a/container/Dockerfile b/container/Dockerfile index fc81ee9..0a16ad5 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -28,12 +28,33 @@ ENV DEBIAN_FRONTEND=noninteractive # archive.ubuntu.com returns stale Packages.gz files with mismatched hashes # during hourly resyncs. Clearing /var/lib/apt/lists/* between attempts # forces a fresh fetch. -RUN for i in 1 2 3 4 5; do \ +# +# `apt-get upgrade` is what picks up security fixes for packages the +# `ubuntu:24.04` image already ships. `install` below never touches those, so +# without it a fix that lands in noble-security between two refreshes of the +# base tag stays out of this image until the next refresh. Packages the later +# layers install come straight from the archive, so this one upgrade is enough. +# +# CI builds with `cache-from: type=gha`, so this layer (and everything after +# it) only re-runs when its cache key changes: the base digest, this layer's +# text, or `APT_REFRESH`. CI sets that to the ISO week (`2026-W45`) and also +# rebuilds weekly on a schedule, so a security fix published after the last +# commit is in the image within a week. An ARG only changes the key of a RUN +# that uses it, hence the `echo`. Local builds leave it empty. +# +# Scanned on 2026-11-02, the published image of 2026-10-16 carried +# libbrotli1 1.1.0-2build2 (CVE-2026-111497, HIGH) and zlib1g 1.3.dfsg-3.1ubuntu2.2 +# (CVE-2026-85091), both with fixed versions in the archive, and pnpm's bundled +# undici 6.29.0 (three HIGH CVEs, fixed in the pnpm 12.16.1 a rebuild installs). +ARG APT_REFRESH= +RUN echo "apt refresh: ${APT_REFRESH:-unset}" \ + && for i in 1 2 3 4 5; do \ apt-get -o Acquire::Retries=3 update && break; \ echo "apt-get update failed (attempt $i), retrying in 10s..."; \ rm -rf /var/lib/apt/lists/*; \ sleep 10; \ done \ + && apt-get -o Acquire::Retries=3 upgrade -y \ && apt-get install -y --no-install-recommends \ git \ curl \ @@ -274,7 +295,9 @@ RUN npx --yes playwright@latest --version \ && rm -rf /var/lib/apt/lists/* /root/.npm # ── Python 3 + pip + uv + ruff ────────────────────────────────────────────── -RUN for i in 1 2 3 4 5; do \ +ARG APT_REFRESH= +RUN echo "apt refresh: ${APT_REFRESH:-unset}" \ + && for i in 1 2 3 4 5; do \ apt-get -o Acquire::Retries=3 update && break; \ echo "apt-get update failed (attempt $i), retrying in 10s..."; \ rm -rf /var/lib/apt/lists/*; \ diff --git a/gateway-container/Dockerfile b/gateway-container/Dockerfile index 362e344..f654126 100644 --- a/gateway-container/Dockerfile +++ b/gateway-container/Dockerfile @@ -28,19 +28,26 @@ # to keep it that way: a floating tag re-resolves, and this container ends up # holding an OpenAI API key. # -# v1.96.0 is chosen rather than the nearest clean post-incident release (1.83.0) +# v1.106.6 is chosen rather than the nearest clean post-incident release (1.83.0) # because the intervening releases fix a run of *ordinary* proxy CVEs that matter # for a gateway published on a host port: SQL injection in API-key verification # (CVE-2026-42208, fixed 1.83.7), auth bypass via Host header injection -# (CVE-2026-49468, fixed 1.84.0) and MCP auth bypass (CVE-2026-59822, fixed -# 1.84.0). 1.84.0 is the floor; v1.96.0 is the newest release with no open OSV -# advisories at the time of writing. Note LiteLLM has retired the `main-*` tag -# family (`main-latest` is no longer updated, `main-stable` is deprecated) in -# favour of plain semver tags, which is why this is `v1.96.0` and not -# `main-v1.96.0-stable`. +# (CVE-2026-49468, fixed 1.84.0), MCP auth bypass (CVE-2026-59822, fixed +# 1.84.0), authenticated SSRF and provider-credential exfiltration via +# unvalidated request-body URLs (CVE-2026-84377 / GHSA-3cv6-jpf6-8222, fixed +# 1.96.2 on the line this file previously pinned) and privilege escalation to +# proxy admin via cross-domain reuse of the salt key (CVE-2026-114612 / +# GHSA-7hp6-4w63-5g45, fixed 1.103.1; no 1.96.x fix). The SSRF one is the +# sharpest for Triple-C: this container holds a billed provider key. v1.106.6 is +# the newest stable release with no open OSV advisories at the time of writing +# (2026-11-02), and its digest was checked with +# `cosign verify --key https://raw.githubusercontent.com/BerriAI/litellm/e82a3c646908ba0a6ee0beddc4310d7a6cac4065/cosign.pub`. +# Note LiteLLM has retired the `main-*` tag family (`main-latest` is no longer +# updated, `main-stable` is deprecated) in favour of plain semver tags, which is +# why this is `v1.106.6` and not `main-v1.106.6-stable`. # # Bump deliberately, never automatically, and re-check the digest when you do. -FROM ghcr.io/berriai/litellm:v1.96.0@sha256:90d8de0ea6fbb3cad145d1019d00a0149ae400b1e18e2011a60f1988f143f672 +FROM ghcr.io/berriai/litellm:v1.106.6@sha256:37e2dafb088a7723beb998590682957386dfc83ffea1848486f8e0e5cfac0b06 # A placeholder config so the image is runnable on its own. Triple-C overwrites # /etc/litellm/config.yaml (a named volume) with the generated one before the diff --git a/scripts/finalize-appimage.sh b/scripts/finalize-appimage.sh index c928bd7..ed4e41d 100755 --- a/scripts/finalize-appimage.sh +++ b/scripts/finalize-appimage.sh @@ -235,6 +235,16 @@ done echo "Repacking." +# The AppDir must be readable (and its directories and programs executable) by +# everyone: the image is mounted with these modes and run by users other than +# the packager. `--appimage-extract` writes it under the runner's umask, which +# on some runners is 007 — the extracted root came out 0700 and AppRun.wrapped +# 0770, and we used to repack exactly that. appimagetool's continuous build of +# 2026-11-01 started refusing such an AppDir, which is right, so fix the modes +# rather than pass --no-check-permissions. `a+rX` adds x only where some x bit +# is already set (directories and programs), and grants no write to anyone. +chmod -R a+rX "$root" + tool="$work/appimagetool" curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL" chmod +x "$tool" @@ -271,6 +281,15 @@ if [ "$demoted" = true ]; then fi [ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c." +# Every file readable by all, every program executable by all — what the +# `chmod -R a+rX` before the repack promised (AppRun.wrapped used to ship 0770). +# Files only: the AppImage runtime's --appimage-extract creates every +# *directory* 0700 whatever the image stores (verified by packing a 0755 +# AppDir and extracting it under umask 022), so directory modes cannot be +# checked from an extraction. appimagetool's own check covers them pre-pack. +unreadable="$(find "$out" -type f \( ! -perm -a+r -o \( -perm /111 ! -perm -a+x \) \) -print -quit)" +[ -z "$unreadable" ] || fail "${unreadable#"$out"/} is not readable/executable by all users." + # An empty Categories or missing metadata ships an image a manager cannot file # or describe, and both fail silently at runtime rather than at build time. # Asserted positively, over every entry: the earlier form checked only that no diff --git a/stt-container/Dockerfile b/stt-container/Dockerfile index 3d1a912..aa91330 100644 --- a/stt-container/Dockerfile +++ b/stt-container/Dockerfile @@ -1,6 +1,14 @@ FROM python:3.11-slim -RUN pip install --no-cache-dir \ +# The python:3.11 images freeze the pip and setuptools that shipped with that +# CPython branch (pip 24.0, setuptools 79), and both have since collected +# advisories: pip path traversal / tar-symlink / index-URL handling +# (CVE-2025-8869, CVE-2026-1703, CVE-2026-3219, CVE-2026-6357, CVE-2026-8643, +# CVE-2026-13346) and setuptools sdist/legacy-wheel path issues +# (CVE-2026-59890, GHSA-xmj3-9gf7-h52w). Upgrade them first, so the installs +# below also run under a patched pip. +RUN pip install --no-cache-dir --upgrade "pip>=26.2" "setuptools>=84.1" \ + && pip install --no-cache-dir \ faster-whisper \ fastapi \ uvicorn[standard] \