Security updates + terminal URL, /tmp file-link and copy fixes #68

Merged
jknapp merged 22 commits from fix/nov-2026-security-and-terminal into main 2026-11-02 20:23:38 +00:00
33 changed files with 2028 additions and 358 deletions

No files matched your search

+14 -7
View File
@@ -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
)
+11 -4
View File
@@ -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
)
+6 -1
View File
@@ -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
+16 -1
View File
@@ -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 }}
+12 -1
View File
@@ -73,6 +73,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
- **Copy paths** (`copyCleanup.ts`, `osc52.ts`). OSC 52 base64 is UTF-8 *bytes* — never hand
`atob()`'s output to the clipboard (that was the `’` mojibake); OSC 52 text then gets only
`sanitizeInvisibles`. Ctrl+Shift+C / "Copy trimmed" run `cleanSelection`, which also undoes
Claude Code's hard wraps (it wraps its own output and moves the cursor, so rows are never
`isWrapped`) using `term.cols` and the selection's start column. Ctrl+Shift+Alt+C stays raw.
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` →
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
@@ -83,6 +88,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
in `file_viewer/mod.rs` is the only thing pinning this.
**Claude Code wraps its own output, so xterm's `isWrapped` misses most wrapped paths.** A word
longer than the line (a `/tmp/claude-<uid>/…` scratchpad path) is broken mid-word and the rest
drawn on the next row with cursor moves. `lib/xtermLineJoin.ts` bridges that when given `cols`
(row runs to the edge, next row continues with path characters, and the two runs could not
have fit on one line — the last test is what keeps prose from being glued together).
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
and the editors reused by Project Home
@@ -172,7 +182,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
`auth_token_commands.rs`.
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`
(`~/p` is `/home/claude/p`; a relative miss gets a second exec at `/home/claude/<p>`, `/tmp/<p>`),
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
user (spec §5 says why the archive API never writes to the target directory). Commands take
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
+207 -190
View File
@@ -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
}
}
},
+114 -56
View File
@@ -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]]
@@ -15,7 +15,7 @@ use crate::file_viewer::poll::{poll_file, ViewerPoll};
use crate::file_viewer::registry::{
Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState,
};
use crate::file_viewer::resolve::{candidate_paths, probe_candidates};
use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates};
use crate::file_viewer::window::open_viewer_window;
use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES};
use crate::models::Project;
@@ -159,8 +159,17 @@ pub async fn open_file_viewer(
let container_id = running_container_of(&project, "opening files").await?;
let mounts: Vec<String> = project.paths.iter().map(|p| p.mount_name.clone()).collect();
let candidates = candidate_paths(&path, &mounts)?;
let matches = probe_candidates(&container_id, &candidates).await?;
let mut candidates = candidate_paths(&path, &mounts)?;
let mut matches = probe_candidates(&container_id, &candidates).await?;
// Not in the project: try home and /tmp, as a second exec so a project file
// never becomes a chooser because a namesake sits in /tmp.
if matches.is_empty() {
let fallbacks = fallback_candidate_paths(&path);
if !fallbacks.is_empty() {
matches = probe_candidates(&container_id, &fallbacks).await?;
candidates.extend(fallbacks);
}
}
let initial = Location { line, col, end_line };
let target_state = match matches.len() {
@@ -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.
+40
View File
@@ -124,6 +124,16 @@ Do not change `sandbox.enabled` in `settings.json` — Triple-C overwrites it
on every container start. To turn sandbox off, stop the container in
Triple-C, flip the "Sandbox mode" switch off, then start the container."#;
/// The Triple-C terminal opens a printed path on click (Shift/Option+click while
/// a program holds the mouse — `opensOnClick` in TerminalView) by looking for it
/// in the project, then `~` and `/tmp`. A relative name for a file elsewhere
/// (Claude Code's scratchpad under `/tmp/claude-<uid>/...` above all) cannot
/// be found, so ask for the whole path. Part of the instructions fingerprint,
/// so adding or changing it recreates each container once on its next start.
const FILE_LINK_INSTRUCTIONS: &str = r#"## Referring to files
The user can click a file path in this terminal to open it in a viewer (while a program holds the mouse: Shift+click, or Option+click on macOS). Paths relative to the project work. When you mention a file outside the project (for example in `/tmp`, including your scratchpad directory, or under `~`), print its full absolute path, not a shortened or relative one, so it can be opened."#;
/// Build the full CLAUDE_INSTRUCTIONS value by merging global + project
/// instructions, appending port mapping docs, and appending scheduler docs.
/// Used by both create_container() and container_needs_recreation() to ensure
@@ -163,6 +173,11 @@ fn build_claude_instructions(
None => SCHEDULER_INSTRUCTIONS.to_string(),
});
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, FILE_LINK_INSTRUCTIONS),
None => FILE_LINK_INSTRUCTIONS.to_string(),
});
if sandbox_enabled {
combined = Some(match combined {
Some(existing) => format!("{}\n\n{}", existing, SANDBOX_INSTRUCTIONS),
@@ -4137,6 +4152,31 @@ mod tests {
const HAIKU: &str = ANTHROPIC_DEFAULT_HAIKU_MODEL;
const FABLE: &str = ANTHROPIC_DEFAULT_FABLE_MODEL;
/// The terminal can only open a file it can find: a bare name is looked
/// for in the project, home and /tmp, but a long scratchpad path printed
/// as `scratchpad/notes.md` is nowhere. Every container gets the ask to
/// print the full path, whatever else is configured.
#[test]
fn every_container_is_asked_to_print_full_paths_for_files_outside_the_project() {
for (global, project, sandbox) in [(None, None, false), (Some("g"), Some("p"), true)] {
let s = build_claude_instructions(global, project, &[], false, sandbox).unwrap();
assert!(s.contains(FILE_LINK_INSTRUCTIONS), "{:?}", (global, project, sandbox));
}
assert!(FILE_LINK_INSTRUCTIONS.contains("full absolute path"));
assert!(FILE_LINK_INSTRUCTIONS.contains("/tmp"));
}
/// The gesture is TerminalView's `opensOnClick`: a plain click, unless a
/// program holds the mouse — then xterm's force-selection modifier, which
/// is Shift except on macOS, where it is Option. Naming only Shift sent Mac
/// users to a key that does nothing.
#[test]
fn the_file_link_instructions_name_the_gesture_for_every_platform() {
assert!(FILE_LINK_INSTRUCTIONS.contains("can click a file path"));
assert!(FILE_LINK_INSTRUCTIONS.contains("Shift+click"));
assert!(FILE_LINK_INSTRUCTIONS.contains("Option+click on macOS"));
}
fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> {
compute_model_aliases(model, haiku)
}
+5 -4
View File
@@ -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");
+51
View File
@@ -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' }}"));
}
}
+3
View File
@@ -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)]
+78 -5
View File
@@ -2,13 +2,16 @@
//!
//! Relative paths are the common case (Claude prints project-relative paths). The
//! terminal exec's cwd is `/workspace`, and each project path is mounted at
//! `/workspace/<mount_name>`, so those are the roots probed, in that order. The probe
//! `/workspace/<mount_name>`, so those are the roots probed, in that order.
//! A relative path none of them has is then tried under `/home/claude` and `/tmp`
//! ([`fallback_candidate_paths`]); `~/p` is `/home/claude/p`. The probe
//! is one exec as the container user and prints `realpath -e` of every candidate that
//! is a regular file: `fetch_container_file` refuses a symlink, so the registry must
//! hold the resolved path, not the one that was clicked.
use crate::commands::file_commands::validate_container_path;
use crate::docker::exec::exec_oneshot_streams_as;
use crate::docker::CONTAINER_HOME;
pub const MAX_CANDIDATES: usize = 16;
const MAX_RAW_LEN: usize = 4096;
@@ -36,12 +39,20 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>,
return Ok(vec![normalised]);
}
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
let rel = rel.trim_start_matches("./");
if rel.is_empty() {
return Err("The path is empty.".into());
// Claude Code prints paths under $HOME as `~/...`. Only the bare `~/` form:
// `~user/` names some other account's home and stays an ordinary name.
if let Some(rest) = raw.strip_prefix("~/") {
let rest = collapse(rest);
if rest.is_empty() {
return Err("The path is empty.".into());
}
let p = format!("{}/{}", CONTAINER_HOME, rest);
validate_container_path("File", &p)?;
return Ok(vec![p]);
}
let rel = relative_part(raw).ok_or_else(|| "The path is empty.".to_string())?;
let mut out: Vec<String> = Vec::new();
let mut push = |candidate: String| {
if out.len() < MAX_CANDIDATES && !out.contains(&candidate) {
@@ -61,6 +72,36 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result<Vec<String>,
Ok(out)
}
/// Where a relative path is looked for when no project root has it, in order.
/// The container user's home, then `/tmp` (drops, pastes and scratch files).
/// Probed only after [`candidate_paths`] found nothing, so a project file never
/// turns into a "which one?" chooser because a same-named file sits in `/tmp`.
const FALLBACK_ROOTS: &[&str] = &[CONTAINER_HOME, "/tmp"];
/// The second tier for a relative path: [`FALLBACK_ROOTS`]`/<p>`. Empty for an
/// absolute or `~/` path (those name one place already) and for anything
/// [`candidate_paths`] refuses — it is always called after that succeeded, but
/// must not be a way around its checks.
pub fn fallback_candidate_paths(raw: &str) -> Vec<String> {
if raw.starts_with('/') || raw.starts_with("~/") || candidate_paths(raw, &[]).is_err() {
return Vec::new();
}
let Some(rel) = relative_part(raw) else { return Vec::new() };
FALLBACK_ROOTS
.iter()
.map(|root| format!("{}/{}", root, rel))
.filter(|c| validate_container_path("File", c).is_ok())
.take(MAX_CANDIDATES)
.collect()
}
/// `./a//b` → `a/b`; `None` when nothing is left.
fn relative_part(raw: &str) -> Option<String> {
let rel = collapse(raw.strip_prefix("./").unwrap_or(raw));
let rel = rel.trim_start_matches("./");
(!rel.is_empty()).then(|| rel.to_string())
}
/// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs).
fn collapse(path: &str) -> String {
let absolute = path.starts_with('/');
@@ -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());
@@ -60,6 +60,8 @@ const ptyOutput = vi.hoisted(() => ({
const xterm = vi.hoisted(() => ({
options: null as Record<string, unknown> | null,
instances: [] as unknown[],
/** Every link provider registered on any terminal, for asking what gets underlined. */
linkProviders: [] as import("@xterm/xterm").ILinkProvider[],
}));
/**
@@ -82,6 +84,10 @@ vi.mock("@xterm/xterm", async (importOriginal) => {
xterm.options = (options ?? null) as Record<string, unknown> | null;
xterm.instances.push(this);
}
override registerLinkProvider(provider: import("@xterm/xterm").ILinkProvider) {
xterm.linkProviders.push(provider);
return super.registerLinkProvider(provider);
}
}
return { ...actual, Terminal: SpyTerminal };
});
@@ -1974,3 +1980,151 @@ describe("the plain-text URL path is gated the same way", () => {
expect(openUrlExternal).not.toHaveBeenCalled();
});
});
/**
* The underline is the link: `WebLinksAddon` paints exactly what its regex
* matched and hands that same text to the click handler. So trailing prose
* punctuation has to be kept out of the *match*, not trimmed afterwards —
* otherwise `See https://x/docs.` underlines the full stop and opens it.
*/
describe("plain-text URLs stop before trailing punctuation", () => {
function term() {
return xterm.instances.at(-1) as unknown as {
write(d: string, cb: () => void): void;
};
}
async function write(data: string) {
await act(() => new Promise<void>((resolve) => term().write(data, resolve)));
}
/** The links every registered provider reports for buffer row 1. */
async function linksOnFirstRow() {
const found: Array<{ text: string; startX: number; endX: number }> = [];
for (const provider of xterm.linkProviders) {
await new Promise<void>((resolve) =>
provider.provideLinks(1, (links) => {
for (const l of links ?? []) {
found.push({ text: l.text, startX: l.range.start.x, endX: l.range.end.x });
}
resolve();
}),
);
}
return found.filter((l) => l.text.startsWith("http"));
}
beforeEach(() => {
xterm.linkProviders.length = 0;
});
it.each([
["See https://example.com/docs.", "https://example.com/docs", 5],
["(https://example.com/foo)", "https://example.com/foo", 2],
["[text](https://x.example/z).", "https://x.example/z", 8],
["<https://x.example/a>", "https://x.example/a", 2],
["https://en.wikipedia.org/wiki/Foo_(bar)", "https://en.wikipedia.org/wiki/Foo_(bar)", 1],
])("underlines only the URL in %j", async (line, url, startX) => {
mountSession("bash");
await write(line);
expect(await linksOnFirstRow()).toEqual([
{ text: url, startX, endX: startX + url.length - 1 },
]);
});
});
describe("TerminalView — copying text out", () => {
let writeText: ReturnType<typeof vi.fn>;
beforeEach(() => {
writeText = vi.fn(async () => {});
Object.defineProperty(navigator, "clipboard", {
value: { writeText },
configurable: true,
});
});
async function emitBytes(bytes: Uint8Array) {
const emit = ptyOutput.listeners.get("terminal-output-s1");
if (!emit) throw new Error("no terminal-output listener registered");
await act(async () => {
emit({ payload: Array.from(bytes) });
await new Promise((r) => setTimeout(r, 0));
await new Promise((r) => setTimeout(r, 0));
});
}
const utf8 = (s: string) => new TextEncoder().encode(s);
function pressCopy(container: HTMLElement, altKey = false) {
helperTextarea(container).dispatchEvent(
new KeyboardEvent("keydown", {
key: "C",
ctrlKey: true,
shiftKey: true,
altKey,
bubbles: true,
cancelable: true,
}),
);
}
it("puts OSC 52 text on the clipboard as UTF-8, not mojibake", async () => {
mountSession("claude");
await act(async () => {});
// What the xclip shim (`printf '%s' | base64`) and Claude Code both send.
const text = "it’s café — naïve 日本語\u00A0👍";
const b64 = Buffer.from(text, "utf8").toString("base64");
await emitBytes(utf8(`\x1b]52;c;${b64}\x07`));
expect(writeText).toHaveBeenCalledTimes(1);
// NBSP normalized; everything else exactly as sent.
expect(writeText).toHaveBeenCalledWith("it’s café — naïve 日本語 👍");
});
it("does not write anything for an OSC 52 read request", async () => {
mountSession("claude");
await act(async () => {});
await emitBytes(utf8("\x1b]52;c;?\x07"));
expect(writeText).not.toHaveBeenCalled();
});
// Claude Code's own hard wrap: it moves to the next row itself (CR + cursor
// moves in the real stream), so the rows are not `isWrapped` and the
// selection carries a newline at every break.
const CLAUDE_ROWS = [
"● The café’s naïve résumé handling means the parser — which was",
" written years ago by someone who has since left the team — silently",
" drops every accented character it cannot map.",
];
it("Ctrl+Shift+C rejoins Claude's wrapped rows and drops its gutter", async () => {
const { container } = mountSession("claude");
await act(async () => {});
const term = xterm.instances[0] as Terminal;
act(() => term.resize(70, 24));
await emitBytes(utf8(CLAUDE_ROWS.join("\r\n")));
term.selectAll();
pressCopy(container);
expect(writeText).toHaveBeenCalledWith(
"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
);
});
it("Ctrl+Shift+Alt+C copies the selection raw", async () => {
const { container } = mountSession("claude");
await act(async () => {});
const term = xterm.instances[0] as Terminal;
act(() => term.resize(70, 24));
await emitBytes(utf8(CLAUDE_ROWS.join("\r\n")));
term.selectAll();
pressCopy(container, true);
expect(writeText).toHaveBeenCalledTimes(1);
expect(writeText.mock.calls[0][0]).toBe(term.getSelection());
expect(writeText.mock.calls[0][0]).toContain("● The café’s");
});
});
+34 -17
View File
@@ -16,6 +16,7 @@ import {
} from "../../lib/tauri-commands";
import { getCurrentWebview } from "@tauri-apps/api/webview";
import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
import { TERMINAL_URL_REGEX } from "../../lib/urlMatch";
import {
RelayRateLimiter,
URL_RELAY_OSC,
@@ -31,7 +32,8 @@ import UrlToast, {
URL_TOAST_SELECTOR,
URL_TOAST_SHORTCUT,
} from "./UrlToast";
import { trimSelection } from "./trimSelection";
import { cleanSelection, sanitizeInvisibles, type CopyGeometry } from "./copyCleanup";
import { decodeOsc52 } from "./osc52";
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
import TerminalContextMenu from "./TerminalContextMenu";
import { createFilePathLinkProvider } from "./filePathLinkProvider";
@@ -122,6 +124,16 @@ export function supersedes(
*/
export const OSC8_HOVER_CLASS = "xterm-hover";
/**
* What `cleanSelection` needs to tell Claude's hard wraps from real line
* breaks: the width it wrapped to, and where the selection's first row began.
* `getSelectionPosition().start.x` is documented as 1-based but xterm 5.5
* returns the selection model's 0-based column — which is what is wanted.
*/
function selectionGeometry(term: Terminal): CopyGeometry {
return { cols: term.cols, startCol: term.getSelectionPosition()?.start.x ?? 0 };
}
/**
* Report a failed handoff to the host's browser.
*
@@ -1069,9 +1081,10 @@ export default function TerminalView({ sessionId, active }: Props) {
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
// that matches URLs even if they lack trailing path segments (the default regex
// misses OAuth URLs that end mid-line).
// eslint-disable-next-line no-control-regex
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
// misses OAuth URLs that end mid-line). `TERMINAL_URL_REGEX` also leaves
// trailing prose punctuation and wrapping brackets out of the match, which
// is what the addon underlines *and* what it hands the handler below.
const urlRegex = TERMINAL_URL_REGEX;
const webLinksAddon = new WebLinksAddon((event, uri) => {
// Same gate and same sink as `createOsc8LinkHandler`, because this
// reaches the same `openUrlExternal` through the same
@@ -1126,15 +1139,17 @@ export default function TerminalView({ sessionId, active }: Props) {
term.open(containerRef.current);
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
// stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw.
// Ctrl+Shift+C copies the selection cleaned up for pasting (UI padding,
// Claude's gutter markers and hard wraps, and invisible characters removed;
// internal indentation preserved — see `copyCleanup.ts`). Ctrl+Shift+Alt+C
// copies raw.
// Both prevent the keystroke from reaching the container (where Ctrl+C
// would send SIGINT and cancel running work).
term.attachCustomKeyEventHandler((event) => {
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
const sel = term.getSelection();
if (sel) {
const out = event.altKey ? sel : trimSelection(sel);
const out = event.altKey ? sel : cleanSelection(sel, selectionGeometry(term));
navigator.clipboard.writeText(out).catch((e) =>
console.error("Ctrl+Shift+C clipboard write failed:", e),
);
@@ -1214,16 +1229,18 @@ export default function TerminalView({ sessionId, active }: Props) {
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
const idx = data.indexOf(";");
if (idx === -1) return false;
const payload = data.substring(idx + 1);
if (payload === "?") return false; // clipboard read request, not supported
try {
const decoded = atob(payload);
navigator.clipboard.writeText(decoded).catch((e) =>
console.error("OSC 52 clipboard write failed:", e),
);
} catch (e) {
console.error("OSC 52 decode failed:", e);
if (data.substring(idx + 1) === "?") return false; // clipboard read request, not supported
// Decoded as UTF-8 — `atob` alone mojibaked every non-ASCII character.
// Only invisible characters are normalized: the program chose this text,
// so it is not dedented or reflowed the way a terminal selection is.
const decoded = decodeOsc52(data);
if (decoded === null) {
console.error("OSC 52 decode failed: invalid base64");
return true;
}
navigator.clipboard.writeText(sanitizeInvisibles(decoded)).catch((e) =>
console.error("OSC 52 clipboard write failed:", e),
);
return true;
});
@@ -1625,7 +1642,7 @@ export default function TerminalView({ sessionId, active }: Props) {
if (!term) return;
const sel = term.getSelection();
if (!sel) return;
const out = mode === "raw" ? sel : trimSelection(sel);
const out = mode === "raw" ? sel : cleanSelection(sel, selectionGeometry(term));
navigator.clipboard.writeText(out).catch((e) =>
console.error("Context menu clipboard write failed:", e),
);
@@ -0,0 +1,287 @@
import { describe, it, expect } from "vitest";
import {
sanitizeInvisibles,
stripClaudeGutter,
rejoinHardWraps,
cleanSelection,
cellWidth,
} from "./copyCleanup";
/**
* Rows of a real Claude Code 2.1.316 reply, captured from a 70-column pty and
* replayed through @xterm/headless 5.5 (what `getSelection()` would return for
* a drag from column 0 of the first row to the end of the last). Claude
* hard-wraps its own output — a real `\r` + cursor move between rows, never an
* xterm soft wrap, so every row has `isWrapped === false` and the selection
* carries a `\n` at each of Claude's breaks. Note the URL split mid-token at
* exactly column 70, and the code line Claude also wrapped at a space.
*/
const CLAUDE_ROWS_70 = [
"● The café’s naïve résumé handling means the parser — which was",
" written years ago by someone who has since left the team — silently",
" drops every accented character it cannot map.",
"",
" See https://example.com/a/very/long/path/that/keeps/going/and/going/",
" until/it/wraps/index.html?query=1 for details.",
"",
" - The first item is a long sentence that describes one important",
" consideration about the parser in considerable detail, so that it",
" lands near twenty-five words.",
" - The second item is another long sentence that explains the second",
" consideration for the reader, written to contain roughly the same",
" number of words as the first.",
"",
" curl -fsSL https://example.com/install.sh | bash -s --",
" --prefix=/opt/some/really/long/prefix/path --yes",
];
describe("sanitizeInvisibles", () => {
it("turns non-breaking and odd-width spaces into plain spaces", () => {
expect(sanitizeInvisibles("git\u00A0status")).toBe("git status");
expect(sanitizeInvisibles("a\u202Fb\u2007c\u2002d\u2009e\u200Af\u205Fg")).toBe("a b c d e f g");
});
it("removes zero-width spaces, word joiners and BOMs", () => {
expect(sanitizeInvisibles("\uFEFFnpm\u200B run\u2060 build")).toBe("npm run build");
});
it("removes ZWJ/ZWNJ next to ASCII, where they only break commands", () => {
expect(sanitizeInvisibles("ls\u200D -la\u200C")).toBe("ls -la");
});
it("keeps ZWJ inside emoji sequences and ZWNJ inside non-Latin words", () => {
const family = "👨\u200D👩\u200D👧";
expect(sanitizeInvisibles(`ok ${family} ok`)).toBe(`ok ${family} ok`);
const persian = "می\u200Cخواهم"; // ZWNJ is orthographically required here
expect(sanitizeInvisibles(persian)).toBe(persian);
const devanagari = "क्\u200Dष";
expect(sanitizeInvisibles(devanagari)).toBe(devanagari);
});
it("leaves legitimate non-ASCII text alone", () => {
const text = "it’s café — naïve “quotes” 日本語\u3000テキスト 👍🏽 ● ⎿ │";
expect(sanitizeInvisibles(text)).toBe(text); // U+3000 ideographic space kept
});
it("turns Unicode line/paragraph separators into newlines", () => {
expect(sanitizeInvisibles("a\u2028b\u2029c")).toBe("a\nb\nc");
});
});
describe("cellWidth", () => {
it("counts wide CJK and emoji as two cells and zero-width marks as none", () => {
expect(cellWidth("abc")).toBe(3);
expect(cellWidth("日本")).toBe(4);
expect(cellWidth("👍")).toBe(2);
expect(cellWidth("é")).toBe(1);
expect(cellWidth("—●⎿")).toBe(3);
});
});
describe("stripClaudeGutter", () => {
it("blanks Claude's message bullet at column 0, keeping columns", () => {
expect(stripClaudeGutter("● Hello\n world", 0)).toBe(" Hello\n world");
});
it("blanks the tool-result elbow", () => {
expect(stripClaudeGutter("● Bash(ls)\n ⎿ a.txt\n b.txt", 0)).toBe(
" Bash(ls)\n a.txt\n b.txt",
);
});
it("does not touch a bullet that is not in the gutter", () => {
expect(stripClaudeGutter(" ● item", 0)).toBe(" ● item");
// Selection started mid-row: the first line's column 0 is not the gutter.
expect(stripClaudeGutter("● x", 5)).toBe("● x");
});
it("strips box borders only when every line is inside the box", () => {
const box = [
"╭────────────────╮",
"│ Do you want to │",
"│ proceed? │",
"╰────────────────╯",
].join("\n");
expect(stripClaudeGutter(box, 0)).toBe(" Do you want to\n proceed?");
});
it("leaves tables and partial boxes alone", () => {
const table = "│ a │ b │\n├───┼───┤\n│ 1 │ 2 │";
expect(stripClaudeGutter(table, 0)).toBe(table);
const mixed = "│ inside │\noutside";
expect(stripClaudeGutter(mixed, 0)).toBe(mixed);
});
it("leaves a selection of only a table's body rows alone", () => {
const body = "│ a │ b │\n│ 1 │ 2 │";
expect(stripClaudeGutter(body, 0)).toBe(body);
expect(stripClaudeGutter("│ only │ row │", 0)).toBe("│ only │ row │");
});
});
describe("rejoinHardWraps", () => {
it("rejoins a real Claude Code reply captured at 70 columns", () => {
const sel = stripClaudeGutter(CLAUDE_ROWS_70.join("\n"), 0);
expect(rejoinHardWraps(sel, { cols: 70 })).toBe(
[
" The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
"",
" See https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.",
"",
" - The first item is a long sentence that describes one important consideration about the parser in considerable detail, so that it lands near twenty-five words.",
" - The second item is another long sentence that explains the second consideration for the reader, written to contain roughly the same number of words as the first.",
"",
" curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes",
].join("\n"),
);
});
it("rejoins Claude's echo of a submitted prompt, which wraps one column short", () => {
// Same capture: the transcript's echo of the prompt that was typed.
const echo = [
"❯ Reply with exactly the following markdown and nothing else (no",
" tools). First a paragraph: \"The café’s naïve résumé handling means",
" the parser — which was written years ago by someone who has since",
" left the team — silently drops every accented character it cannot",
" map.\" Then a line: \"See https://example.com/a/very/long/path/that/k",
" eeps/going/and/going/until/it/wraps/index.html?query=1 for",
" details.\" Then a bullet list with two items, each a long sentence",
" of about 25 words. Then a bash code block containing: curl -fsSL",
" https://example.com/install.sh | bash -s --",
" --prefix=/opt/some/really/long/prefix/path --yes",
].join("\n");
const typed =
"Reply with exactly the following markdown and nothing else (no tools). First a paragraph: \"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.\" Then a line: \"See https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\" Then a bullet list with two items, each a long sentence of about 25 words. Then a bash code block containing: curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes";
expect(rejoinHardWraps(echo, { cols: 70 })).toBe(`❯ ${typed}`);
});
it("joins a word that exactly filled the row with a space", () => {
// 70 cells: the last token ("wordabc") and the next ("next") are short,
// so this was a break at a space that happened to land on the edge.
const full = " " + "x".repeat(60) + " wordabc";
expect(cellWidth(full)).toBe(70);
expect(rejoinHardWraps(`${full}\n next words`, { cols: 70 })).toBe(`${full} next words`);
});
it("does not join short lines (code, prose that ended early)", () => {
const code = " def foo():\n return 1\n print(foo())";
expect(rejoinHardWraps(code, { cols: 70 })).toBe(code);
const prose = " This sentence ends well before the edge.\n So does this one.";
expect(rejoinHardWraps(prose, { cols: 70 })).toBe(prose);
});
it("does not join a line whose next word would have fitted", () => {
const line = " " + "a ".repeat(30).trimEnd(); // 61 cells
expect(rejoinHardWraps(`${line}\n bb`, { cols: 70 })).toBe(`${line}\n bb`);
});
it("does not join across a change of indentation", () => {
const a = " " + "word ".repeat(13).trimEnd(); // 66 cells
expect(rejoinHardWraps(`${a}\n indented-continuation`, { cols: 70 })).toBe(
`${a}\n indented-continuation`,
);
});
it("does not join into the next list item or a border", () => {
const a = " - " + "word ".repeat(12).trimEnd(); // 63 cells
const next = " - another item with a longword";
expect(rejoinHardWraps(`${a}\n${next}`, { cols: 70 })).toBe(`${a}\n${next}`);
expect(rejoinHardWraps(`${a}\n │ table`, { cols: 70 })).toBe(`${a}\n │ table`);
});
it("leaves column-0 shell output alone (ls-style columns, plain output)", () => {
const ls =
"Cargo.lock Cargo.toml README.md build.rs capabilities gen icons s\nsrc target tauri.conf.json";
expect(rejoinHardWraps(ls, { cols: 70 })).toBe(ls);
const plain = "x".repeat(65) + "\nyyyyyyyyyy";
expect(rejoinHardWraps(plain, { cols: 70 })).toBe(plain);
});
it("leaves tabular rows alone even when indented", () => {
const t = " NAME STATUS PORTS CREATED AT SIZE\n triple-c-abc running 8080/tcp";
expect(rejoinHardWraps(t, { cols: 70 })).toBe(t);
});
it("joins wide-character text without inserting a space", () => {
const a = " " + "日本語".repeat(11); // 2 + 66 = 68 cells; the next wide char needs 2 more
expect(cellWidth(a)).toBe(68);
expect(rejoinHardWraps(`${a}\n の続き`, { cols: 69 })).toBe(`${a}の続き`);
});
it("joins astral wide characters (CJK Ext-B) without inserting a space", () => {
const a = " " + "\u{20000}".repeat(33); // 2 + 66 = 68 cells
expect(cellWidth(a)).toBe(68);
expect(rejoinHardWraps(`${a}\n \u{20001}\u{20002}`, { cols: 69 })).toBe(`${a}\u{20001}\u{20002}`);
const e = " " + "\u{1F600}".repeat(33); // emoji, also two cells each
expect(rejoinHardWraps(`${e}\n \u{1F601}`, { cols: 69 })).toBe(`${e}\u{1F601}`);
});
it("keeps the space when a short word ended at the edge and a URL that fits was moved down whole", () => {
const prev = " " + "x".repeat(64) + " the";
expect(cellWidth(prev)).toBe(70);
const url = "https://example.com/" + "p".repeat(35) + "/index.html";
expect(url.length).toBe(66); // fits in the 68 cells after the gutter
expect(rejoinHardWraps(`${prev}\n ${url}`, { cols: 70 })).toBe(`${prev} ${url}`);
});
it("still joins without a space when a row-filling piece of a long token is followed by a URL start", () => {
// A redirect URL whose split happened to land just before its inner `https://`.
const url = "https://r.example/" + "r".repeat(47) + "?u=https://inner.example/x";
const rows = [" " + url.slice(0, 68), " " + url.slice(68) + " ok"];
expect(cellWidth(rows[0])).toBe(70);
expect(rows[1].startsWith(" https://")).toBe(true);
expect(rejoinHardWraps(rows.join("\n"), { cols: 70 })).toBe(` ${url} ok`);
});
it("rejoins a URL split over three rows without spaces", () => {
const url = "https://example.com/" + "q".repeat(130) + "/end.html";
const rows = [" See " + url.slice(0, 64), " " + url.slice(64, 132), " " + url.slice(132) + " ok"];
expect(rows.map(cellWidth).slice(0, 2)).toEqual([70, 70]);
expect(rejoinHardWraps(rows.join("\n"), { cols: 70 })).toBe(` See ${url} ok`);
});
it("uses the selection's start column for a first line that began mid-row", () => {
// Selection started at column 18, so the first line is only 51
// characters but ended at column 69 of its row.
const first = "written years ago by someone who has since left the";
expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70, startCol: 18 })).toBe(
`${first} team — silently`,
);
// Without the start column it looks short and is left alone.
expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70 })).toBe(
`${first}\n team — silently`,
);
});
it("leaves text unchanged when cols is unknown", () => {
const sel = CLAUDE_ROWS_70.join("\n");
expect(rejoinHardWraps(sel, { cols: 0 })).toBe(sel);
});
});
describe("cleanSelection", () => {
it("produces paste-ready text from a Claude reply (gutter, wraps, dedent)", () => {
const out = cleanSelection(CLAUDE_ROWS_70.join("\n"), { cols: 70 });
expect(out.split("\n")[0]).toBe(
"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
);
expect(out).toContain(
"\nSee https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\n",
);
expect(out.endsWith("\ncurl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes")).toBe(true);
});
it("normalizes Windows line endings from xterm's getSelection", () => {
expect(cleanSelection(" a\r\n b\r\n", { cols: 70 })).toBe("a\nb");
});
it("strips invisible characters", () => {
expect(cleanSelection(" git\u202Fcommit\u200B -m x", { cols: 70 })).toBe("git commit -m x");
});
it("keeps the old trimSelection behaviour for ordinary text", () => {
expect(cleanSelection(" def foo():\n return 1\n", { cols: 80 })).toBe(
"def foo():\n return 1",
);
});
});
+278
View File
@@ -0,0 +1,278 @@
import { trimSelection } from "./trimSelection";
/**
* Clean-up applied to text copied out of a terminal, so that pasting it into
* an editor, a shell or a chat works.
*
* Three separate problems, fixed by separate pure functions:
*
* - **Invisible characters** (`sanitizeInvisibles`). Claude Code prints
* `❯` + U+00A0 for its prompt, and model output can carry NBSP / narrow
* NBSP / zero-width spaces; pasted into a shell they make `git status` an
* unknown command. xterm's `getSelection()` already maps U+00A0 (only that
* one) to a space; everything else survived until now.
* - **Claude's TUI chrome** (`stripClaudeGutter`): the `●` message bullet and
* `⎿` tool-result elbow in the left gutter, and `│ … │` dialog borders.
* - **Claude's hard wraps** (`rejoinHardWraps`). Claude Code wraps its own
* output to the terminal width and moves the cursor to the next row itself,
* so xterm never marks those rows `isWrapped` and a selection carries a real
* newline (plus the 2-column gutter indent) at every break — mid-sentence,
* and mid-URL when a long token was split at the edge.
*
* `cleanSelection` composes them with `trimSelection` for the trimmed copy
* (Ctrl+Shift+C, context-menu "Copy"). OSC 52 content gets only
* `sanitizeInvisibles`: the program chose that text, so it is not reflowed or
* dedented — only made safe to paste. Raw copy (Ctrl+Shift+Alt+C) gets none.
*/
// ── invisible characters ────────────────────────────────────────────────────
/** NBSP, figure space, narrow NBSP, the U+2000–U+200A width spaces, medium
* math space. U+3000 (ideographic space) is deliberately absent: it is real
* CJK typography and occupies two cells. */
const ODD_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F]/g;
/** Zero-width space, word joiner, BOM / zero-width no-break space. */
const ZERO_WIDTH = /[\u200B\u2060\uFEFF]/g;
/** ZWNJ / ZWJ touching ASCII (or a string edge). Between two non-ASCII
* characters they are meaningful — emoji ZWJ sequences, Persian ZWNJ,
* Indic conjunct control — so only this case is removed. */
const JOINER_NEAR_ASCII = /(^|[\x00-\x7F])[\u200C\u200D]+|[\u200C\u200D]+(?=[\x00-\x7F]|$)/g;
export function sanitizeInvisibles(text: string): string {
if (!text) return text;
return text
.replace(/[\u2028\u2029]/g, "\n")
.replace(ODD_SPACES, " ")
.replace(ZERO_WIDTH, "")
.replace(JOINER_NEAR_ASCII, "$1");
}
// ── cell width ──────────────────────────────────────────────────────────────
const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
[0x1100, 0x115f],
[0x2e80, 0x303e],
[0x3041, 0x33ff],
[0x3400, 0x4dbf],
[0x4e00, 0x9fff],
[0xa000, 0xa4cf],
[0xac00, 0xd7a3],
[0xf900, 0xfaff],
[0xfe30, 0xfe4f],
[0xff00, 0xff60],
[0xffe0, 0xffe6],
[0x1f300, 0x1f64f],
[0x1f900, 0x1f9ff],
[0x20000, 0x3fffd],
];
const ZERO_CELL = /^[\p{M}\u200B-\u200D\u2060\uFE00-\uFE0F\uFEFF]$/u;
function charWidth(ch: string): number {
if (ZERO_CELL.test(ch)) return 0;
const cp = ch.codePointAt(0) ?? 0;
for (const [lo, hi] of WIDE_RANGES) if (cp >= lo && cp <= hi) return 2;
return 1;
}
/** Terminal cells a string occupies — an approximation of xterm's Unicode 6
* width table, good enough to tell whether a row reached the right edge. */
export function cellWidth(text: string): number {
let w = 0;
for (const ch of text) w += charWidth(ch);
return w;
}
// ── Claude Code's gutter and borders ────────────────────────────────────────
const BOX_TOP_BOTTOM = /^\s*[╭╰┌└][─━]*[╮╯┐┘]\s*$/;
const BOX_ROW = /^(\s*)│(.*)│\s*$/;
/** A vertical rule between the borders: a table cell separator, not a box. */
const INTERIOR_VERTICAL = /[│┃║]/;
const isBoxRow = (l: string) => {
const m = BOX_ROW.exec(l);
return !!m && !INTERIOR_VERTICAL.test(m[2]);
};
/**
* Blanks Claude Code's TUI markers without moving any text: `●` at column 0
* and `⎿` at column 2 become spaces (so `rejoinHardWraps` still sees true
* columns, and `trimSelection` dedents them away). A selection that is wholly
* inside a `╭─╮ │ … │ ╰─╯` box loses the box. Anything less certain — a table
* (`├─┼─┤` rows, or body rows alone: `│ a │ b │` has a `│` inside its borders),
* a bullet elsewhere, a box mixed with outside text — is left as it is.
*
* `startCol` is the column the selection began at; when it is not 0 the first
* line's column 0 is not the gutter.
*/
export function stripClaudeGutter(text: string, startCol = 0): string {
if (!text) return text;
let lines = text.split("\n");
const content = lines.filter((l) => l.trim() !== "" && !BOX_TOP_BOTTOM.test(l));
if (content.length > 0 && content.every(isBoxRow)) {
lines = lines
.filter((l) => !BOX_TOP_BOTTOM.test(l))
.map((l) => l.replace(BOX_ROW, (_m, lead: string, inner: string) => `${lead} ${inner}`.trimEnd()));
}
return lines
.map((line, i) => {
if (i === 0 && startCol !== 0) return line;
if (line.startsWith("● ")) return " " + line.slice(1);
if (line.startsWith(" ⎿ ")) return " " + line.slice(3);
return line;
})
.join("\n");
}
// ── Claude Code's hard wraps ────────────────────────────────────────────────
export interface CopyGeometry {
/** Terminal width in columns (`term.cols`). 0 / unknown disables rejoining. */
cols: number;
/** Column (0-based) where the selection's first row began. */
startCol?: number;
}
/** A list marker or Claude prompt glyph, and the spaces after it. */
const MARKER = /^([-*+•●❯⎿]|\d{1,3}[.)]) +/;
/** A row that starts a border, rule or table line. */
const BORDER_START = /^[│┃├┤┌┐└┘╭╮╰╯─━┼]/;
/** Two or more spaces between words: column-aligned output, not prose. */
const TABULAR = /\S {2,}\S/;
/** Claude Code always renders its transcript at least 2 columns in. */
const MIN_GUTTER = 2;
interface Row {
text: string;
/** Column of the first non-space character. */
lead: number;
/** Column just past the last character. */
end: number;
/** Text after the indent. */
body: string;
/** Column where text after a list marker starts (or `lead` if none). */
contentCol: number;
hasMarker: boolean;
}
function toRow(text: string, offset: number): Row {
const body = text.trimStart();
const lead = offset + cellWidth(text.slice(0, text.length - body.length));
const marker = body.match(MARKER);
return {
text,
lead,
end: offset + cellWidth(text),
body,
contentCol: lead + (marker ? cellWidth(marker[0]) : 0),
hasMarker: !!marker,
};
}
const lastToken = (s: string) => s.slice(s.lastIndexOf(" ") + 1);
const firstToken = (s: string) => s.split(" ", 1)[0];
/** First / last *code point* — an astral character (CJK Ext-B, most emoji) is two UTF-16 units. */
const firstChar = (s: string) => (s ? String.fromCodePoint(s.codePointAt(0)!) : "");
const lastChar = (s: string) => Array.from(s.slice(-2)).pop() ?? "";
const isWide = (ch: string) => !!ch && charWidth(ch) === 2;
/**
* Re-joins rows that Claude Code broke to fit the terminal width.
*
* A break is undone only when all of these hold — each is a property every
* Claude wrap has, so together they are conservative:
* - both rows have text, and the next row does not start a list item or a
* border/table line;
* - the next row is indented exactly to where the logical line's text starts
* (the gutter, or the hanging indent after `- ` / `1. `), and at least 2
* columns — so column-0 shell output is never touched;
* - the previous row is not column-aligned (no double spaces between words);
* - the next row's first word would *not* have fitted on the previous row,
* i.e. the wrapper had no choice but to break there.
*
* Joined with a space (Claude drops the space it broke at), except when a long
* token such as a URL was split mid-token (see `splitMidToken`) or the break
* is between two wide (CJK) characters.
*/
export function rejoinHardWraps(text: string, { cols, startCol = 0 }: CopyGeometry): string {
if (!text || !(cols > 0)) return text;
const rows = text.split("\n").map((t, i) => toRow(t, i === 0 ? startCol : 0));
if (rows.length < 2) return text;
const out: string[] = [rows[0].text];
let head = rows[0];
// A first row that began mid-row: what was left of it is unknown.
let headPartial = startCol > 0 && !rows[0].hasMarker;
for (let i = 1; i < rows.length; i++) {
const prev = rows[i - 1];
const cur = rows[i];
const joiner = joinerFor(prev, cur, head, headPartial, cols);
if (joiner === null) {
out.push(cur.text);
head = cur;
headPartial = false;
} else {
out[out.length - 1] += joiner + cur.body;
}
}
return out.join("\n");
}
function joinerFor(prev: Row, cur: Row, head: Row, headPartial: boolean, cols: number): string | null {
if (!prev.body || !cur.body) return null;
if (cur.hasMarker || BORDER_START.test(cur.body)) return null;
if (cur.lead < MIN_GUTTER) return null;
if (headPartial ? cur.lead > head.lead : cur.lead !== head.contentCol) return null;
const prevText = prev === head && prev.hasMarker ? prev.body.replace(MARKER, "") : prev.body;
if (TABULAR.test(prevText)) return null;
// Claude's echo of a submitted prompt (`❯ …`) wraps one column short of
// its replies — measured on 2.1.316 at 70 columns: rows end at 69, and a
// long token is split at column 69.
const width = head.body.startsWith("❯") ? cols - 1 : cols;
if (prev.end > width) return null;
const next = firstToken(cur.body);
if (prev.end + 1 + cellWidth(next) <= width) return null;
if (isWide(lastChar(prev.body)) && isWide(firstChar(cur.body))) return "";
return prev.end === width && splitMidToken(prev, cur, next, width) ? "" : " ";
}
/** The start of a URL: a token that begins here was not cut off mid-token. */
const URL_START = /^[a-z][a-z0-9+.-]*:\/\//i;
/**
* Did Claude break a single long token at the edge of `prev` (so the rows
* join with no space), rather than moving the next word down whole?
*
* Ink's wrap-ansi splits a word only when it is wider than the line, so the
* combined token must not fit in the line's available width. That alone
* cannot tell `the` + a 66-cell URL (two words; the URL fits on a row and was
* moved down) from one 69-cell token split after `the` — the lengths are the
* same. So: a next-row token that *starts a URL* (`scheme://`) was moved down
* whole, unless one of the two pieces fills its entire row, which only the
* middle piece of a token longer than a line does.
*/
function splitMidToken(prev: Row, cur: Row, next: string, width: number): boolean {
const tail = cellWidth(lastToken(prev.body));
const head = cellWidth(next);
if (tail + head <= width - cur.lead) return false; // would have been moved down whole
const prevIsPiece = tail >= width - prev.lead;
const curIsPiece = cur.end === width && head === cellWidth(cur.body);
if (prevIsPiece || curIsPiece) return true;
return !URL_START.test(next);
}
// ── the trimmed copy ────────────────────────────────────────────────────────
/** The full clean-up for a trimmed copy of an xterm selection. */
export function cleanSelection(text: string, geometry: CopyGeometry): string {
if (!text) return text;
const lf = text.replace(/\r\n?/g, "\n"); // getSelection() joins with CRLF on Windows
const visible = sanitizeInvisibles(lf);
const unframed = stripClaudeGutter(visible, geometry.startCol ?? 0);
return trimSelection(rejoinHardWraps(unframed, geometry));
}
@@ -1,7 +1,8 @@
import { describe, expect, it, vi } from "vitest";
import { createFilePathLinkProvider } from "./filePathLinkProvider";
const fakeTerm = (rows: Array<[string, boolean]>) => ({
const fakeTerm = (rows: Array<[string, boolean]>, cols?: number) => ({
cols,
buffer: {
active: {
getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] },
@@ -40,6 +41,29 @@ describe("createFilePathLinkProvider", () => {
expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } });
});
it("links a long /tmp path that Claude Code hard-wrapped, from either row", () => {
// Captured from Claude Code at 70 columns: the path is broken mid-word and
// the rest drawn two cells in on the next row, with no `isWrapped`.
const rows: Array<[string, boolean]> = [
["\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e", false],
[" 039e2a07562/scratchpad/notes.md ok", false],
];
const onOpen = vi.fn();
const provider = createFilePathLinkProvider(fakeTerm(rows, 70), onOpen, () => true);
for (const row of [1, 2]) {
const links = vi.fn();
provider.provideLinks(row, links);
const [list] = links.mock.calls[0];
expect(list).toHaveLength(1);
expect(list[0].range).toEqual({ start: { x: 18, y: 1 }, end: { x: 33, y: 2 } });
list[0].activate(new MouseEvent("click"), list[0].text);
}
const scratch = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
expect(onOpen).toHaveBeenCalledTimes(2);
expect(onOpen).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: scratch }));
expect(onOpen).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: scratch }));
});
it("answers undefined for a row with nothing", () => {
const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true);
const links = vi.fn();
@@ -17,7 +17,7 @@ export interface FilePathHover {
}
export function createFilePathLinkProvider(
term: Pick<Terminal, "buffer">,
term: Pick<Terminal, "buffer" | "cols">,
onOpen: (match: FilePathMatch) => void,
gate: (event: MouseEvent) => boolean,
hover?: FilePathHover,
@@ -27,7 +27,9 @@ export function createFilePathLinkProvider(
const row = bufferLineNumber - 1;
const line = term.buffer.active.getLine(row);
if (!line) return callback(undefined);
const joined = joinWrappedRows(term.buffer.active, row);
// `cols` lets the join bridge Claude Code's own mid-word wraps, which is
// how a long `/tmp/claude-<uid>/...` path reaches the screen.
const joined = joinWrappedRows(term.buffer.active, row, { cols: term.cols });
// `offsetToCell` has no row to map onto when nothing was joined.
if (joined.rowStarts.length === 0) return callback(undefined);
const matches = findFilePathLinks(joined.text);
+52 -35
View File
@@ -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("");
});
});
+36
View File
@@ -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);
}
+12 -1
View File
@@ -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+)+$/;
+56
View File
@@ -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
+9 -8
View File
@@ -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;
+127
View File
@@ -0,0 +1,127 @@
import { describe, it, expect } from "vitest";
import { TERMINAL_URL_REGEX, findUrls, trimUrlCandidate } from "./urlMatch";
/**
* Every URL `WebLinksAddon` would underline in `line`.
*
* Mirrors `LinkComputer.computeLink` in `@xterm/addon-web-links`: it rebuilds
* the regex with the `g` flag added to whatever flags it was given and
* `exec`s in a loop. Run the same way here so the test exercises the regex as
* the addon will, not as a one-off `match` would.
*/
function underlined(line: string): string[] {
const rex = new RegExp(TERMINAL_URL_REGEX.source, (TERMINAL_URL_REGEX.flags || "") + "g");
const out: string[] = [];
let m: RegExpExecArray | null;
while ((m = rex.exec(line))) out.push(m[0]);
return out;
}
describe("TERMINAL_URL_REGEX", () => {
it("carries no g or y flag, which the addon adds itself", () => {
expect(TERMINAL_URL_REGEX.flags).not.toMatch(/[gy]/);
});
it.each([
["See https://example.com/docs.", "https://example.com/docs"],
["at https://example.com/docs, then", "https://example.com/docs"],
["is it https://example.com/docs?", "https://example.com/docs"],
["wow https://example.com/docs!", "https://example.com/docs"],
["go to https://example.com/docs: it", "https://example.com/docs"],
["https://example.com/docs; and", "https://example.com/docs"],
["end https://example.com/docs...", "https://example.com/docs"],
["(https://example.com/foo)", "https://example.com/foo"],
["(see https://example.com/foo).", "https://example.com/foo"],
["[text](https://x.y/z)", "https://x.y/z"],
["[text](https://x.y/z).", "https://x.y/z"],
["<https://x.example>", "https://x.example"],
["[https://x.example/a]", "https://x.example/a"],
["{https://x.example/a}", "https://x.example/a"],
["\"https://x.example/a\"", "https://x.example/a"],
["'https://x.example/a'", "https://x.example/a"],
["`https://x.example/a`", "https://x.example/a"],
["**https://x.example/a**", "https://x.example/a"],
["https://x.example/a/", "https://x.example/a/"],
])("leaves the trailing punctuation off %j", (line, url) => {
expect(underlined(line)).toEqual([url]);
});
it.each([
"https://en.wikipedia.org/wiki/Foo_(bar)",
"https://example.com/search?q=a.b&x=1",
"https://example.com/a,b/c",
"https://example.com/path/file.tar.gz",
"https://example.com/#/route",
"https://example.com/a?arr[]=1&arr[]=2",
"http://[::1]:8080/x",
"https://example.com/a)b",
"https://example.com/a(b",
"https://example.com/api/{id}/x",
// OAuth state and code_challenge are base64url, which can end in `_`, `-`
// or `=` padding. None of those may be trimmed.
"https://claude.ai/oauth/authorize?code=true&state=abc-_",
"https://claude.ai/oauth/authorize?code_challenge=abc-",
"https://example.com/x?t=YWJj==",
"https://example.com/~user",
])("keeps %j whole", (url) => {
expect(underlined(url)).toEqual([url]);
expect(underlined(`see ${url} now`)).toEqual([url]);
});
it("keeps a balanced paren and drops the one that wraps the link", () => {
expect(underlined("(https://en.wikipedia.org/wiki/Foo_(bar))")).toEqual([
"https://en.wikipedia.org/wiki/Foo_(bar)",
]);
expect(underlined("[Foo](https://en.wikipedia.org/wiki/Foo_(bar)).")).toEqual([
"https://en.wikipedia.org/wiki/Foo_(bar)",
]);
});
it("finds several links on one line", () => {
expect(underlined("a https://a.example/1, b (https://b.example/2).")).toEqual([
"https://a.example/1",
"https://b.example/2",
]);
});
it("matches nothing after a bare scheme", () => {
expect(underlined("https://.")).toEqual([]);
expect(underlined("https:// x")).toEqual([]);
});
it("still stops at control characters", () => {
expect(underlined("https://example.com/a\x1bb")).toEqual(["https://example.com/a"]);
expect(underlined("https://example.com/a\x00b")).toEqual(["https://example.com/a"]);
expect(underlined("https://example.com/a\x7fb")).toEqual(["https://example.com/a"]);
});
it("does not hang on a long run of punctuation", () => {
const line = "https://example.com/" + "(.".repeat(5000) + ")".repeat(5000);
const t0 = Date.now();
underlined(line);
expect(Date.now() - t0).toBeLessThan(1000);
});
});
describe("findUrls", () => {
it("returns the trimmed URL and the extent of the raw run", () => {
expect(findUrls("See https://example.com/docs. next")).toEqual([
{ url: "https://example.com/docs", index: 4, rawEnd: 29 },
]);
});
});
describe("trimUrlCandidate", () => {
it("trims trailing punctuation and unbalanced closers", () => {
expect(trimUrlCandidate("https://example.com/docs.")).toBe("https://example.com/docs");
expect(trimUrlCandidate("https://example.com/foo).")).toBe("https://example.com/foo");
expect(trimUrlCandidate("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe(
"https://en.wikipedia.org/wiki/Foo_(bar)",
);
});
it("returns null for something that is not a URL at all", () => {
expect(trimUrlCandidate("https://.")).toBeNull();
expect(trimUrlCandidate("not a url")).toBeNull();
});
});
+93
View File
@@ -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;
}
+115 -1
View File
@@ -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] };
+108 -8
View File
@@ -1,6 +1,10 @@
/**
* Joins an xterm buffer row with its wrapped continuations.
*
* Two kinds: xterm's own soft wraps (`isWrapped`), and — when the caller
* passes the terminal width — the mid-word breaks Claude Code makes itself,
* which xterm cannot see (see `hardWrapIndent`).
*
* `WebLinksAddon` does the same in its private `LinkComputer`, which the
* built package does not export — so the walk is repeated here, with the same
* 2048-character budget. Rows are read with `translateToString(true)`, which
@@ -13,6 +17,8 @@
* lands a cell early; the click still resolves the same link).
*/
import { PATH_SEGMENT_CLASS } from "./filePathLinks";
export const MAX_JOINED_LENGTH = 2048;
/** Minimal slice of xterm's IBuffer this needs. */
@@ -20,28 +26,118 @@ export interface RowSource {
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
}
export interface JoinOptions {
/**
* The terminal's width. Given, rows that Claude Code broke mid-word are
* joined too (see `hardWrapIndent`); omitted, only xterm's soft wraps are.
*/
cols?: number;
}
export interface JoinedLine {
text: string;
/** 0-based index of the first buffer row that contributed. */
firstRow: number;
/** For each contributed row (in order), the string offset at which it starts. */
rowStarts: number[];
/**
* For each contributed row, how many leading cells were left out of `text`:
* the indent of a hard-wrapped continuation, 0 for every other row.
*/
rowIndents: number[];
}
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
/** The characters `findFilePathLinks`' token is made of: segment characters and `/`. */
const PATH_CLASS = `[${PATH_SEGMENT_CLASS}/]`;
const PATH_CHAR = new RegExp(PATH_CLASS);
const TRAILING_PATH_RUN = new RegExp(`${PATH_CLASS}+$`);
const LEADING_PATH_RUN = new RegExp(`^${PATH_CLASS}+`);
/** Cells a full row may stop short of the right edge (Ink boxes pad by one or two). */
const EDGE_SLACK = 2;
/** Claude Code draws its transcript (and so every continuation row) at least this far in. */
const MIN_CONTINUATION_INDENT = 2;
/**
* Is `next` the rest of a word that Claude Code broke at the end of `prev`?
* Returns the continuation's indent (cells to skip), or `null`.
*
* Claude Code wraps its own output (Ink / wrap-ansi) and draws continuation
* rows with cursor moves, so xterm never marks them `isWrapped` — captured at
* 70 columns: `…/5e4ab145-915e-4902-9329-e\r\x1b[2C\x1b[1B039e2a…/notes.md`.
* It breaks *inside* a word only when the word is longer than the line, which
* is exactly what a long `/tmp/claude-<uid>/…` path is. So bridge only when
* `prev` runs to (within {@link EDGE_SLACK} of) the right edge and ends in a
* path character, `next` starts (after an indent of at least
* {@link MIN_CONTINUATION_INDENT} cells — so two column-0 lines of shell
* output are never glued) with one, and the two runs
* together would not have fit on one line — i.e. word wrap could not have
* moved the word down whole. That last test is what keeps ordinary prose (a
* word that happens to end at the edge above `src/main.rs`) from being glued.
*/
function hardWrapIndent(prev: string, next: string, cols: number): number | null {
if (prev.length < cols - EDGE_SLACK || prev.length > cols) return null;
if (!PATH_CHAR.test(prev[prev.length - 1])) return null;
const indent = next.search(/\S/);
if (indent < MIN_CONTINUATION_INDENT || !PATH_CHAR.test(next[indent])) return null;
const tail = TRAILING_PATH_RUN.exec(prev)?.[0].length ?? 0;
const head = LEADING_PATH_RUN.exec(next.slice(indent))?.[0].length ?? 0;
if (tail + head <= cols - indent - EDGE_SLACK) return null;
return indent;
}
/**
* Cells to skip if row `y` continues row `y - 1`: 0 for an xterm soft wrap,
* the indent for a Claude Code hard wrap, `null` if `y` starts a new line.
*/
function continuationIndent(buffer: RowSource, y: number, cols: number | undefined): number | null {
if (y <= 0) return null;
const line = buffer.getLine(y);
if (!line) return null;
if (line.isWrapped) return 0;
if (!cols) return null;
const prev = buffer.getLine(y - 1);
if (!prev) return null;
return hardWrapIndent(prev.translateToString(true), line.translateToString(true), cols);
}
/**
* Joins `row` with the rows it wraps from and into. A chain longer than
* {@link MAX_JOINED_LENGTH} is windowed around `row`: the walk up stops after
* half the budget, the walk down after the whole of it, and `row` itself is
* always included — so a hover deep in a huge wrapped blob reads a bounded
* number of rows and still finds the text under the pointer.
*/
export function joinWrappedRows(buffer: RowSource, row: number, options: JoinOptions = {}): JoinedLine {
const { cols } = options;
let top = row;
while (top > 0 && buffer.getLine(top)?.isWrapped) top--;
let above = 0;
while (top > 0 && continuationIndent(buffer, top, cols) !== null) {
// Every row counts at least 1, so a run of blank soft-wrapped rows is bounded too.
const len = Math.max(1, buffer.getLine(top - 1)?.translateToString(true).length ?? 0);
if (above + len > MAX_JOINED_LENGTH / 2) break;
above += len;
top--;
}
const parts: string[] = [];
const rowIndents: number[] = [];
let length = 0;
let y = top;
for (;;) {
const line = buffer.getLine(y);
if (!line) break;
if (y !== top && !line.isWrapped) break;
const text = line.translateToString(true);
if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break;
let indent = 0;
const c = continuationIndent(buffer, y, cols);
if (y !== top) {
if (c === null) break;
indent = c;
} else if (c !== null) {
indent = c; // the window starts mid-chain: drop this row's indent as well
}
const text = line.translateToString(true).slice(indent);
if (length + text.length > MAX_JOINED_LENGTH && y > row) break;
parts.push(text);
rowIndents.push(indent);
length += text.length;
y++;
}
@@ -52,14 +148,18 @@ export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
rowStarts.push(offset);
offset += p.length;
}
return { text: parts.join(""), firstRow: top, rowStarts };
return { text: parts.join(""), firstRow: top, rowStarts, rowIndents };
}
/** String offset → 1-based {x, y} cell (y is the buffer row + 1). */
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } {
export function offsetToCell(
joined: Pick<JoinedLine, "firstRow" | "rowStarts"> & Partial<Pick<JoinedLine, "rowIndents">>,
offset: number,
): { x: number; y: number } {
let rowIdx = 0;
for (let i = 0; i < joined.rowStarts.length; i++) {
if (joined.rowStarts[i] <= offset) rowIdx = i;
}
return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 };
const indent = joined.rowIndents?.[rowIdx] ?? 0;
return { x: offset - joined.rowStarts[rowIdx] + indent + 1, y: joined.firstRow + rowIdx + 1 };
}
+25 -2
View File
@@ -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/*; \
+15 -8
View File
@@ -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
+19
View File
@@ -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
+9 -1
View File
@@ -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] \