Compare commits
2
Commits
v0.4.34
...
v0.4.36-mac
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e223a3f64b | ||
|
|
af59dc8bec |
No files matched your search
@@ -61,6 +61,13 @@ name: Build App (Preview)
|
||||
# for real releases. See triple-c#32.)
|
||||
|
||||
env:
|
||||
# The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after
|
||||
# any apt-get install stops at an interactive "Daemons using outdated
|
||||
# libraries" prompt and hangs the job until it times out. `sudo` resets the
|
||||
# environment, so the apt-get calls below also pass these via `sudo env`;
|
||||
# here they reach the NodeSource setup script through `sudo -E`.
|
||||
NEEDRESTART_MODE: a
|
||||
DEBIAN_FRONTEND: noninteractive
|
||||
GITEA_URL: ${{ gitea.server_url }}
|
||||
REPO: ${{ gitea.repository }}
|
||||
# How many preview releases survive a run, newest first — including the one
|
||||
@@ -262,7 +269,7 @@ jobs:
|
||||
fi
|
||||
if [ "$NEED_INSTALL" = true ]; then
|
||||
curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash -
|
||||
sudo apt-get install -y nodejs
|
||||
sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs
|
||||
hash -r
|
||||
fi
|
||||
node --version
|
||||
@@ -273,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
|
||||
)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -55,5 +55,10 @@ jobs:
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
ghcr.io/shadowdao/triple-c-stt:latest
|
||||
ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }}
|
||||
# The gha cache is served by the runner that picks the job up, and
|
||||
# is unreachable from the job on some of ours (the home box's
|
||||
# firewalled per-job bridge; ephemeral runners). Import failures
|
||||
# only warn, but an export failure fails the build unless told not
|
||||
# to -- the same setting build.yml already carries.
|
||||
cache-from: type=gha
|
||||
cache-to: type=gha,mode=max
|
||||
cache-to: type=gha,mode=max,ignore-error=true
|
||||
@@ -11,6 +11,11 @@ on:
|
||||
paths:
|
||||
- "container/**"
|
||||
- ".gitea/workflows/build.yml"
|
||||
# Weekly rebuild with no commit needed, so OS security fixes published since
|
||||
# the last push reach the image (see APT_REFRESH in container/Dockerfile).
|
||||
# Scheduled runs use the default branch, i.e. main.
|
||||
schedule:
|
||||
- cron: "17 4 * * 1"
|
||||
|
||||
env:
|
||||
REGISTRY: repo.anhonesthost.net
|
||||
@@ -64,13 +69,23 @@ jobs:
|
||||
username: shadowdao
|
||||
password: ${{ secrets.GH_PAT }}
|
||||
|
||||
# Changes once a week, which invalidates the cached `apt-get upgrade`
|
||||
# layer (container/Dockerfile) so later security fixes are picked up.
|
||||
- name: Compute the apt refresh stamp
|
||||
id: apt
|
||||
run: echo "stamp=$(date -u +%G-W%V)" >> $GITHUB_OUTPUT
|
||||
|
||||
- name: Build and push container image
|
||||
uses: docker/build-push-action@v5
|
||||
with:
|
||||
context: ./container
|
||||
file: ./container/Dockerfile
|
||||
platforms: linux/amd64,linux/arm64
|
||||
push: ${{ gitea.event_name == 'push' }}
|
||||
# Publish from main pushes and the weekly scheduled rebuild (which
|
||||
# runs on main); a pull_request run only validates.
|
||||
push: ${{ gitea.event_name != 'pull_request' }}
|
||||
build-args: |
|
||||
APT_REFRESH=${{ steps.apt.outputs.stamp }}
|
||||
tags: |
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest
|
||||
${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }}
|
||||
|
||||
@@ -73,6 +73,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
- **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`)
|
||||
- **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models
|
||||
- **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow
|
||||
- **Copy paths** (`copyCleanup.ts`, `osc52.ts`). OSC 52 base64 is UTF-8 *bytes* — never hand
|
||||
`atob()`'s output to the clipboard (that was the `’` mojibake); OSC 52 text then gets only
|
||||
`sanitizeInvisibles`. Ctrl+Shift+C / "Copy trimmed" run `cleanSelection`, which also undoes
|
||||
Claude Code's hard wraps (it wraps its own output and moves the cursor, so rows are never
|
||||
`isWrapped`) using `term.cols` and the selection's start column. Ctrl+Shift+Alt+C stays raw.
|
||||
- **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` →
|
||||
`src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is;
|
||||
`components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now
|
||||
@@ -83,6 +88,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and
|
||||
all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input`
|
||||
in `file_viewer/mod.rs` is the only thing pinning this.
|
||||
**Claude Code wraps its own output, so xterm's `isWrapped` misses most wrapped paths.** A word
|
||||
longer than the line (a `/tmp/claude-<uid>/…` scratchpad path) is broken mid-word and the rest
|
||||
drawn on the next row with cursor moves. `lib/xtermLineJoin.ts` bridges that when given `cols`
|
||||
(row runs to the edge, next row continues with path characters, and the two runs could not
|
||||
have fit on one line — the last test is what keeps prose from being glued together).
|
||||
- **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar
|
||||
- **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`,
|
||||
and the editors reused by Project Home
|
||||
@@ -172,7 +182,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li
|
||||
container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`,
|
||||
`auth_token_commands.rs`.
|
||||
- **`file_viewer/`** — one window per click (`file-viewer-<n>`), a managed `ViewerRegistry`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`,
|
||||
resolution by probing `/workspace/<p>` then `/workspace/<mount>/<p>` in one exec as `claude`
|
||||
(`~/p` is `/home/claude/p`; a relative miss gets a second exec at `/home/claude/<p>`, `/tmp/<p>`),
|
||||
polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container
|
||||
user (spec §5 says why the archive API never writes to the target directory). Commands take
|
||||
`window: tauri::Window`, gate on the label and act on the caller's own registry entry — no
|
||||
|
||||
Generated
+207
-190
@@ -124,13 +124,13 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/@babel/code-frame": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz",
|
||||
"integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz",
|
||||
"integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"js-tokens": "^4.0.0",
|
||||
"picocolors": "^1.1.1"
|
||||
},
|
||||
@@ -139,9 +139,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/compat-data": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz",
|
||||
"integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz",
|
||||
"integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -149,21 +149,21 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/core": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz",
|
||||
"integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz",
|
||||
"integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-compilation-targets": "^7.28.6",
|
||||
"@babel/helper-module-transforms": "^7.28.6",
|
||||
"@babel/helpers": "^7.28.6",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/traverse": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.7",
|
||||
"@babel/helper-compilation-targets": "^7.29.7",
|
||||
"@babel/helper-module-transforms": "^7.29.7",
|
||||
"@babel/helpers": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7",
|
||||
"@jridgewell/remapping": "^2.3.5",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"debug": "^4.1.0",
|
||||
@@ -180,14 +180,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/generator": {
|
||||
"version": "7.29.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz",
|
||||
"integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz",
|
||||
"integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/parser": "^7.29.12",
|
||||
"@babel/types": "^7.29.11",
|
||||
"@jridgewell/gen-mapping": "^0.3.12",
|
||||
"@jridgewell/trace-mapping": "^0.3.28",
|
||||
"jsesc": "^3.0.2"
|
||||
@@ -197,14 +197,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-compilation-targets": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz",
|
||||
"integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz",
|
||||
"integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/compat-data": "^7.28.6",
|
||||
"@babel/helper-validator-option": "^7.27.1",
|
||||
"@babel/compat-data": "^7.29.7",
|
||||
"@babel/helper-validator-option": "^7.29.7",
|
||||
"browserslist": "^4.24.0",
|
||||
"lru-cache": "^5.1.1",
|
||||
"semver": "^6.3.1"
|
||||
@@ -214,9 +214,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-globals": {
|
||||
"version": "7.28.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz",
|
||||
"integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz",
|
||||
"integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -224,29 +224,29 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-imports": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz",
|
||||
"integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz",
|
||||
"integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/traverse": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/traverse": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-module-transforms": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz",
|
||||
"integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz",
|
||||
"integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-module-imports": "^7.28.6",
|
||||
"@babel/helper-validator-identifier": "^7.28.5",
|
||||
"@babel/traverse": "^7.28.6"
|
||||
"@babel/helper-module-imports": "^7.29.7",
|
||||
"@babel/helper-validator-identifier": "^7.29.7",
|
||||
"@babel/traverse": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -266,9 +266,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-string-parser": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz",
|
||||
"integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==",
|
||||
"version": "7.29.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz",
|
||||
"integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -276,9 +276,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-identifier": {
|
||||
"version": "7.28.5",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz",
|
||||
"integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz",
|
||||
"integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -286,9 +286,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helper-validator-option": {
|
||||
"version": "7.27.1",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz",
|
||||
"integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz",
|
||||
"integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -296,27 +296,27 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/helpers": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz",
|
||||
"integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==",
|
||||
"version": "7.29.10",
|
||||
"resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz",
|
||||
"integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.8"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/parser": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz",
|
||||
"integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz",
|
||||
"integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/types": "^7.29.0"
|
||||
"@babel/types": "^7.29.11"
|
||||
},
|
||||
"bin": {
|
||||
"parser": "bin/babel-parser.js"
|
||||
@@ -368,33 +368,33 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/template": {
|
||||
"version": "7.28.6",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz",
|
||||
"integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==",
|
||||
"version": "7.29.7",
|
||||
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz",
|
||||
"integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.28.6",
|
||||
"@babel/parser": "^7.28.6",
|
||||
"@babel/types": "^7.28.6"
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/parser": "^7.29.7",
|
||||
"@babel/types": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/traverse": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz",
|
||||
"integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==",
|
||||
"version": "7.29.12",
|
||||
"resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz",
|
||||
"integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/code-frame": "^7.29.0",
|
||||
"@babel/generator": "^7.29.0",
|
||||
"@babel/helper-globals": "^7.28.0",
|
||||
"@babel/parser": "^7.29.0",
|
||||
"@babel/template": "^7.28.6",
|
||||
"@babel/types": "^7.29.0",
|
||||
"@babel/code-frame": "^7.29.7",
|
||||
"@babel/generator": "^7.29.12",
|
||||
"@babel/helper-globals": "^7.29.7",
|
||||
"@babel/parser": "^7.29.12",
|
||||
"@babel/template": "^7.29.7",
|
||||
"@babel/types": "^7.29.11",
|
||||
"debug": "^4.3.1"
|
||||
},
|
||||
"engines": {
|
||||
@@ -402,14 +402,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@babel/types": {
|
||||
"version": "7.29.0",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz",
|
||||
"integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==",
|
||||
"version": "7.29.11",
|
||||
"resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz",
|
||||
"integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@babel/helper-string-parser": "^7.27.1",
|
||||
"@babel/helper-validator-identifier": "^7.28.5"
|
||||
"@babel/helper-string-parser": "^7.29.11",
|
||||
"@babel/helper-validator-identifier": "^7.29.7"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=6.9.0"
|
||||
@@ -2526,31 +2526,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/expect": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz",
|
||||
"integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz",
|
||||
"integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.0.0",
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"@types/chai": "^5.2.2",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"chai": "^6.2.1",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"chai": "^6.2.2",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/mocker": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz",
|
||||
"integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz",
|
||||
"integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"estree-walker": "^3.0.3",
|
||||
"magic-string": "^0.30.21"
|
||||
},
|
||||
@@ -2559,7 +2559,7 @@
|
||||
},
|
||||
"peerDependencies": {
|
||||
"msw": "^2.4.9",
|
||||
"vite": "^6.0.0 || ^7.0.0-0"
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"msw": {
|
||||
@@ -2571,26 +2571,26 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/pretty-format": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz",
|
||||
"integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz",
|
||||
"integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/runner": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz",
|
||||
"integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz",
|
||||
"integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/utils": "4.0.18",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
"funding": {
|
||||
@@ -2598,13 +2598,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/snapshot": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz",
|
||||
"integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz",
|
||||
"integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"magic-string": "^0.30.21",
|
||||
"pathe": "^2.0.3"
|
||||
},
|
||||
@@ -2613,9 +2614,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/spy": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz",
|
||||
"integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz",
|
||||
"integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"funding": {
|
||||
@@ -2623,14 +2624,15 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@vitest/utils": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz",
|
||||
"integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz",
|
||||
"integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"tinyrainbow": "^3.0.3"
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"convert-source-map": "^2.0.0",
|
||||
"tinyrainbow": "^3.1.0"
|
||||
},
|
||||
"funding": {
|
||||
"url": "https://opencollective.com/vitest"
|
||||
@@ -2759,9 +2761,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/baseline-browser-mapping": {
|
||||
"version": "2.10.0",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz",
|
||||
"integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==",
|
||||
"version": "2.11.34",
|
||||
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz",
|
||||
"integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==",
|
||||
"dev": true,
|
||||
"license": "Apache-2.0",
|
||||
"bin": {
|
||||
@@ -2782,9 +2784,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/browserslist": {
|
||||
"version": "4.28.1",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz",
|
||||
"integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==",
|
||||
"version": "4.29.5",
|
||||
"resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz",
|
||||
"integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2802,11 +2804,11 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"baseline-browser-mapping": "^2.9.0",
|
||||
"caniuse-lite": "^1.0.30001759",
|
||||
"electron-to-chromium": "^1.5.263",
|
||||
"node-releases": "^2.0.27",
|
||||
"update-browserslist-db": "^1.2.0"
|
||||
"baseline-browser-mapping": "^2.11.33",
|
||||
"caniuse-lite": "^1.0.30001819",
|
||||
"electron-to-chromium": "^1.5.464",
|
||||
"node-releases": "^2.0.60",
|
||||
"update-browserslist-db": "^1.3.4"
|
||||
},
|
||||
"bin": {
|
||||
"browserslist": "cli.js"
|
||||
@@ -2816,9 +2818,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/caniuse-lite": {
|
||||
"version": "1.0.30001774",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz",
|
||||
"integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==",
|
||||
"version": "1.0.30001820",
|
||||
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz",
|
||||
"integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -2837,9 +2839,9 @@
|
||||
"license": "CC-BY-4.0"
|
||||
},
|
||||
"node_modules/chai": {
|
||||
"version": "6.2.2",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz",
|
||||
"integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==",
|
||||
"version": "6.3.0",
|
||||
"resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz",
|
||||
"integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -2981,9 +2983,9 @@
|
||||
"peer": true
|
||||
},
|
||||
"node_modules/electron-to-chromium": {
|
||||
"version": "1.5.302",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz",
|
||||
"integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==",
|
||||
"version": "1.5.472",
|
||||
"resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz",
|
||||
"integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==",
|
||||
"dev": true,
|
||||
"license": "ISC"
|
||||
},
|
||||
@@ -3015,9 +3017,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/es-module-lexer": {
|
||||
"version": "1.7.0",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz",
|
||||
"integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==",
|
||||
"version": "2.3.2",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz",
|
||||
"integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -3616,9 +3618,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.11",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz",
|
||||
"integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==",
|
||||
"version": "3.3.20",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz",
|
||||
"integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3635,11 +3637,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/node-releases": {
|
||||
"version": "2.0.27",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz",
|
||||
"integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==",
|
||||
"version": "2.0.63",
|
||||
"resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz",
|
||||
"integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
"node": ">=18"
|
||||
}
|
||||
},
|
||||
"node_modules/obug": {
|
||||
"version": "2.1.1",
|
||||
@@ -3680,9 +3685,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/picomatch": {
|
||||
"version": "4.0.3",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz",
|
||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||
"version": "4.0.7",
|
||||
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz",
|
||||
"integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -3693,9 +3698,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.6",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz",
|
||||
"integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==",
|
||||
"version": "8.5.32",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz",
|
||||
"integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3713,9 +3718,9 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.11",
|
||||
"nanoid": "^3.3.20",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
"source-map-js": "^1.2.2"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^10 || ^12 || >=14"
|
||||
@@ -3899,9 +3904,9 @@
|
||||
"license": "ISC"
|
||||
},
|
||||
"node_modules/source-map-js": {
|
||||
"version": "1.2.1",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz",
|
||||
"integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==",
|
||||
"version": "1.2.2",
|
||||
"resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz",
|
||||
"integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==",
|
||||
"dev": true,
|
||||
"license": "BSD-3-Clause",
|
||||
"engines": {
|
||||
@@ -3916,9 +3921,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/std-env": {
|
||||
"version": "3.10.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz",
|
||||
"integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==",
|
||||
"version": "4.3.0",
|
||||
"resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz",
|
||||
"integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
@@ -4004,9 +4009,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tinyrainbow": {
|
||||
"version": "3.0.3",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz",
|
||||
"integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==",
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz",
|
||||
"integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4074,9 +4079,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/undici": {
|
||||
"version": "7.22.0",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz",
|
||||
"integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==",
|
||||
"version": "7.30.1",
|
||||
"resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz",
|
||||
"integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"engines": {
|
||||
@@ -4084,9 +4089,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/update-browserslist-db": {
|
||||
"version": "1.2.3",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz",
|
||||
"integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==",
|
||||
"version": "1.3.4",
|
||||
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz",
|
||||
"integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -4115,9 +4120,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vite": {
|
||||
"version": "6.4.1",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz",
|
||||
"integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==",
|
||||
"version": "6.4.5",
|
||||
"resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz",
|
||||
"integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -4190,31 +4195,31 @@
|
||||
}
|
||||
},
|
||||
"node_modules/vitest": {
|
||||
"version": "4.0.18",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz",
|
||||
"integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==",
|
||||
"version": "4.1.12",
|
||||
"resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz",
|
||||
"integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@vitest/expect": "4.0.18",
|
||||
"@vitest/mocker": "4.0.18",
|
||||
"@vitest/pretty-format": "4.0.18",
|
||||
"@vitest/runner": "4.0.18",
|
||||
"@vitest/snapshot": "4.0.18",
|
||||
"@vitest/spy": "4.0.18",
|
||||
"@vitest/utils": "4.0.18",
|
||||
"es-module-lexer": "^1.7.0",
|
||||
"expect-type": "^1.2.2",
|
||||
"@vitest/expect": "4.1.12",
|
||||
"@vitest/mocker": "4.1.12",
|
||||
"@vitest/pretty-format": "4.1.12",
|
||||
"@vitest/runner": "4.1.12",
|
||||
"@vitest/snapshot": "4.1.12",
|
||||
"@vitest/spy": "4.1.12",
|
||||
"@vitest/utils": "4.1.12",
|
||||
"es-module-lexer": "^2.0.0",
|
||||
"expect-type": "^1.3.0",
|
||||
"magic-string": "^0.30.21",
|
||||
"obug": "^2.1.1",
|
||||
"pathe": "^2.0.3",
|
||||
"picomatch": "^4.0.3",
|
||||
"std-env": "^3.10.0",
|
||||
"std-env": "^4.0.0-rc.1",
|
||||
"tinybench": "^2.9.0",
|
||||
"tinyexec": "^1.0.2",
|
||||
"tinyglobby": "^0.2.15",
|
||||
"tinyrainbow": "^3.0.3",
|
||||
"vite": "^6.0.0 || ^7.0.0",
|
||||
"tinyrainbow": "^3.1.0",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0",
|
||||
"why-is-node-running": "^2.3.0"
|
||||
},
|
||||
"bin": {
|
||||
@@ -4230,12 +4235,15 @@
|
||||
"@edge-runtime/vm": "*",
|
||||
"@opentelemetry/api": "^1.9.0",
|
||||
"@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0",
|
||||
"@vitest/browser-playwright": "4.0.18",
|
||||
"@vitest/browser-preview": "4.0.18",
|
||||
"@vitest/browser-webdriverio": "4.0.18",
|
||||
"@vitest/ui": "4.0.18",
|
||||
"@vitest/browser-playwright": "4.1.12",
|
||||
"@vitest/browser-preview": "4.1.12",
|
||||
"@vitest/browser-webdriverio": "^4.1.11",
|
||||
"@vitest/coverage-istanbul": "4.1.12",
|
||||
"@vitest/coverage-v8": "4.1.12",
|
||||
"@vitest/ui": "4.1.12",
|
||||
"happy-dom": "*",
|
||||
"jsdom": "*"
|
||||
"jsdom": "*",
|
||||
"vite": "^6.0.0 || ^7.0.0 || ^8.0.0"
|
||||
},
|
||||
"peerDependenciesMeta": {
|
||||
"@edge-runtime/vm": {
|
||||
@@ -4256,6 +4264,12 @@
|
||||
"@vitest/browser-webdriverio": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-istanbul": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/coverage-v8": {
|
||||
"optional": true
|
||||
},
|
||||
"@vitest/ui": {
|
||||
"optional": true
|
||||
},
|
||||
@@ -4264,6 +4278,9 @@
|
||||
},
|
||||
"jsdom": {
|
||||
"optional": true
|
||||
},
|
||||
"vite": {
|
||||
"optional": false
|
||||
}
|
||||
}
|
||||
},
|
||||
|
||||
Generated
+114
-56
@@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cipher",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -84,9 +84,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "anyhow"
|
||||
version = "1.0.102"
|
||||
version = "1.0.104"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c"
|
||||
checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470"
|
||||
|
||||
[[package]]
|
||||
name = "arc-swap"
|
||||
@@ -105,7 +105,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072"
|
||||
dependencies = [
|
||||
"base64ct",
|
||||
"blake2",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"password-hash",
|
||||
]
|
||||
|
||||
@@ -674,6 +674,17 @@ version = "0.2.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724"
|
||||
|
||||
[[package]]
|
||||
name = "chacha20"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures 0.3.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "chrono"
|
||||
version = "0.4.44"
|
||||
@@ -816,6 +827,15 @@ dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "cpufeatures"
|
||||
version = "0.3.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566"
|
||||
dependencies = [
|
||||
"libc",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "crc32fast"
|
||||
version = "1.5.0"
|
||||
@@ -1367,16 +1387,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "event-listener"
|
||||
version = "5.4.1"
|
||||
version = "5.4.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab"
|
||||
checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2"
|
||||
dependencies = [
|
||||
"concurrent-queue",
|
||||
"parking",
|
||||
"pin-project-lite",
|
||||
]
|
||||
@@ -1768,11 +1787,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"wasip2",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -1782,10 +1799,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"js-sys",
|
||||
"libc",
|
||||
"r-efi",
|
||||
"rand_core 0.10.1",
|
||||
"wasip2",
|
||||
"wasip3",
|
||||
"wasm-bindgen",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -3742,9 +3762,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "mio"
|
||||
version = "1.1.1"
|
||||
version = "1.2.4"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc"
|
||||
checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"wasi 0.11.1+wasi-snapshot-preview1",
|
||||
@@ -4303,7 +4323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6"
|
||||
dependencies = [
|
||||
"phf_shared 0.10.0",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4313,7 +4333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d"
|
||||
dependencies = [
|
||||
"phf_shared 0.11.3",
|
||||
"rand 0.8.5",
|
||||
"rand 0.8.8",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4433,9 +4453,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c"
|
||||
|
||||
[[package]]
|
||||
name = "plist"
|
||||
version = "1.8.0"
|
||||
version = "1.10.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07"
|
||||
checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85"
|
||||
dependencies = [
|
||||
"base64 0.22.1",
|
||||
"indexmap 2.13.0",
|
||||
@@ -4491,7 +4511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"opaque-debug",
|
||||
"universal-hash",
|
||||
]
|
||||
@@ -4661,9 +4681,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quick-xml"
|
||||
version = "0.38.4"
|
||||
version = "0.41.0"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c"
|
||||
checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1"
|
||||
dependencies = [
|
||||
"memchr",
|
||||
]
|
||||
@@ -4690,15 +4710,16 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "quinn-proto"
|
||||
version = "0.11.13"
|
||||
version = "0.11.19"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31"
|
||||
checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"bytes",
|
||||
"getrandom 0.3.4",
|
||||
"getrandom 0.4.1",
|
||||
"lru-slab",
|
||||
"rand 0.9.2",
|
||||
"rand 0.10.3",
|
||||
"rand_pcg 0.10.2",
|
||||
"ring",
|
||||
"rustc-hash",
|
||||
"rustls",
|
||||
@@ -4721,7 +4742,7 @@ dependencies = [
|
||||
"once_cell",
|
||||
"socket2",
|
||||
"tracing",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -4750,14 +4771,14 @@ dependencies = [
|
||||
"rand_chacha 0.2.2",
|
||||
"rand_core 0.5.1",
|
||||
"rand_hc",
|
||||
"rand_pcg",
|
||||
"rand_pcg 0.2.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.8.5"
|
||||
version = "0.8.8"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404"
|
||||
checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"rand_chacha 0.3.1",
|
||||
@@ -4766,14 +4787,25 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.9.2"
|
||||
version = "0.9.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1"
|
||||
checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41"
|
||||
dependencies = [
|
||||
"rand_chacha 0.9.0",
|
||||
"rand_core 0.9.5",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand"
|
||||
version = "0.10.3"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af"
|
||||
dependencies = [
|
||||
"chacha20",
|
||||
"getrandom 0.4.1",
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_chacha"
|
||||
version = "0.2.2"
|
||||
@@ -4831,6 +4863,12 @@ dependencies = [
|
||||
"getrandom 0.3.4",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_core"
|
||||
version = "0.10.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69"
|
||||
|
||||
[[package]]
|
||||
name = "rand_hc"
|
||||
version = "0.2.0"
|
||||
@@ -4849,6 +4887,15 @@ dependencies = [
|
||||
"rand_core 0.5.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rand_pcg"
|
||||
version = "0.10.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a"
|
||||
dependencies = [
|
||||
"rand_core 0.10.1",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "raw-window-handle"
|
||||
version = "0.6.2"
|
||||
@@ -5071,14 +5118,14 @@ dependencies = [
|
||||
"errno",
|
||||
"libc",
|
||||
"linux-raw-sys",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "rustls"
|
||||
version = "0.23.37"
|
||||
version = "0.23.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4"
|
||||
checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"once_cell",
|
||||
@@ -5129,7 +5176,7 @@ dependencies = [
|
||||
"security-framework 3.7.0",
|
||||
"security-framework-sys",
|
||||
"webpki-root-certs",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5140,9 +5187,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f"
|
||||
|
||||
[[package]]
|
||||
name = "rustls-webpki"
|
||||
version = "0.103.9"
|
||||
version = "0.103.16"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53"
|
||||
checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281"
|
||||
dependencies = [
|
||||
"aws-lc-rs",
|
||||
"ring",
|
||||
@@ -5517,7 +5564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -5534,7 +5581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283"
|
||||
dependencies = [
|
||||
"cfg-if",
|
||||
"cpufeatures",
|
||||
"cpufeatures 0.2.17",
|
||||
"digest",
|
||||
]
|
||||
|
||||
@@ -5608,12 +5655,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03"
|
||||
|
||||
[[package]]
|
||||
name = "socket2"
|
||||
version = "0.6.2"
|
||||
version = "0.6.5"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0"
|
||||
checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4"
|
||||
dependencies = [
|
||||
"libc",
|
||||
"windows-sys 0.60.2",
|
||||
"windows-sys 0.61.2",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -5764,6 +5811,17 @@ dependencies = [
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "syn"
|
||||
version = "3.0.9"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"unicode-ident",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
name = "sync_wrapper"
|
||||
version = "1.0.2"
|
||||
@@ -5850,9 +5908,9 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tar"
|
||||
version = "0.4.44"
|
||||
version = "0.4.46"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a"
|
||||
checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840"
|
||||
dependencies = [
|
||||
"filetime",
|
||||
"libc",
|
||||
@@ -6169,10 +6227,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0"
|
||||
dependencies = [
|
||||
"fastrand",
|
||||
"getrandom 0.4.1",
|
||||
"getrandom 0.3.4",
|
||||
"once_cell",
|
||||
"rustix",
|
||||
"windows-sys 0.61.2",
|
||||
"windows-sys 0.52.0",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -6294,9 +6352,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20"
|
||||
|
||||
[[package]]
|
||||
name = "tokio"
|
||||
version = "1.49.0"
|
||||
version = "1.54.1"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86"
|
||||
checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3"
|
||||
dependencies = [
|
||||
"bytes",
|
||||
"libc",
|
||||
@@ -6311,13 +6369,13 @@ dependencies = [
|
||||
|
||||
[[package]]
|
||||
name = "tokio-macros"
|
||||
version = "2.6.0"
|
||||
version = "2.7.2"
|
||||
source = "registry+https://github.com/rust-lang/crates.io-index"
|
||||
checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5"
|
||||
checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e"
|
||||
dependencies = [
|
||||
"proc-macro2",
|
||||
"quote",
|
||||
"syn 2.0.117",
|
||||
"syn 3.0.9",
|
||||
]
|
||||
|
||||
[[package]]
|
||||
@@ -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]]
|
||||
|
||||
@@ -107,7 +107,6 @@
|
||||
"allow-get-gateway-auth-token",
|
||||
"allow-regenerate-gateway-auth-token",
|
||||
"allow-list-claude-sessions",
|
||||
"allow-resume-session-command",
|
||||
"allow-list-container-capabilities",
|
||||
"allow-list-scheduled-tasks",
|
||||
"allow-add-scheduled-task",
|
||||
|
||||
@@ -1 +1 @@
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
|
||||
{"__app-acl__":{"default_permission":null,"permissions":{"allow-acquire-claude-token":{"identifier":"allow-acquire-claude-token","description":"Enables the acquire_claude_token command without any pre-configured scope.","commands":{"allow":["acquire_claude_token"],"deny":[]}},"allow-add-marketplace":{"identifier":"allow-add-marketplace","description":"Enables the add_marketplace command without any pre-configured scope.","commands":{"allow":["add_marketplace"],"deny":[]}},"allow-add-marketplace-gh-host-account":{"identifier":"allow-add-marketplace-gh-host-account","description":"Enables the add_marketplace_gh_host_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_gh_host_account"],"deny":[]}},"allow-add-marketplace-token-account":{"identifier":"allow-add-marketplace-token-account","description":"Enables the add_marketplace_token_account command without any pre-configured scope.","commands":{"allow":["add_marketplace_token_account"],"deny":[]}},"allow-add-project":{"identifier":"allow-add-project","description":"Enables the add_project command without any pre-configured scope.","commands":{"allow":["add_project"],"deny":[]}},"allow-add-scheduled-task":{"identifier":"allow-add-scheduled-task","description":"Enables the add_scheduled_task command without any pre-configured scope.","commands":{"allow":["add_scheduled_task"],"deny":[]}},"allow-apply-marketplace-now":{"identifier":"allow-apply-marketplace-now","description":"Enables the apply_marketplace_now command without any pre-configured scope.","commands":{"allow":["apply_marketplace_now"],"deny":[]}},"allow-apply-settings-import":{"identifier":"allow-apply-settings-import","description":"Enables the apply_settings_import command without any pre-configured scope.","commands":{"allow":["apply_settings_import"],"deny":[]}},"allow-aws-sso-refresh":{"identifier":"allow-aws-sso-refresh","description":"Enables the aws_sso_refresh command without any pre-configured scope.","commands":{"allow":["aws_sso_refresh"],"deny":[]}},"allow-build-gateway-image":{"identifier":"allow-build-gateway-image","description":"Enables the build_gateway_image command without any pre-configured scope.","commands":{"allow":["build_gateway_image"],"deny":[]}},"allow-build-image":{"identifier":"allow-build-image","description":"Enables the build_image command without any pre-configured scope.","commands":{"allow":["build_image"],"deny":[]}},"allow-build-stt-image":{"identifier":"allow-build-stt-image","description":"Enables the build_stt_image command without any pre-configured scope.","commands":{"allow":["build_stt_image"],"deny":[]}},"allow-cancel-claude-token":{"identifier":"allow-cancel-claude-token","description":"Enables the cancel_claude_token command without any pre-configured scope.","commands":{"allow":["cancel_claude_token"],"deny":[]}},"allow-cancel-marketplace-gh-login":{"identifier":"allow-cancel-marketplace-gh-login","description":"Enables the cancel_marketplace_gh_login command without any pre-configured scope.","commands":{"allow":["cancel_marketplace_gh_login"],"deny":[]}},"allow-check-browser-view-support":{"identifier":"allow-check-browser-view-support","description":"Enables the check_browser_view_support command without any pre-configured scope.","commands":{"allow":["check_browser_view_support"],"deny":[]}},"allow-check-docker":{"identifier":"allow-check-docker","description":"Enables the check_docker command without any pre-configured scope.","commands":{"allow":["check_docker"],"deny":[]}},"allow-check-for-updates":{"identifier":"allow-check-for-updates","description":"Enables the check_for_updates command without any pre-configured scope.","commands":{"allow":["check_for_updates"],"deny":[]}},"allow-check-gateway-health":{"identifier":"allow-check-gateway-health","description":"Enables the check_gateway_health command without any pre-configured scope.","commands":{"allow":["check_gateway_health"],"deny":[]}},"allow-check-image-exists":{"identifier":"allow-check-image-exists","description":"Enables the check_image_exists command without any pre-configured scope.","commands":{"allow":["check_image_exists"],"deny":[]}},"allow-check-image-update":{"identifier":"allow-check-image-update","description":"Enables the check_image_update command without any pre-configured scope.","commands":{"allow":["check_image_update"],"deny":[]}},"allow-clear-claude-token":{"identifier":"allow-clear-claude-token","description":"Enables the clear_claude_token command without any pre-configured scope.","commands":{"allow":["clear_claude_token"],"deny":[]}},"allow-clear-gateway-api-key":{"identifier":"allow-clear-gateway-api-key","description":"Enables the clear_gateway_api_key command without any pre-configured scope.","commands":{"allow":["clear_gateway_api_key"],"deny":[]}},"allow-clear-scheduler-notifications":{"identifier":"allow-clear-scheduler-notifications","description":"Enables the clear_scheduler_notifications command without any pre-conLine truncated
|
||||
@@ -1 +1 @@
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
{"default":{"identifier":"default","description":"Default capabilities for Triple-C. Every entry here is an IPC command a compromised webview can call directly, so the set is an enumeration of what `app/src` actually invokes — plugin and core grants verified against tauri 2.11.0's `PLUGINS` table in tauri's own `build.rs` rather than assumed from a plugin's `default` set, app-command grants (the bare `allow-*` entries) cross-checked by this crate's `build.rs` against `generate_handler!`. `core:default` in particular is NOT used: it is an alias for `core:{path,event,window,webview,app,image,resources,menu,tray}:default`, and `core:image:default` carries `allow-from-path`, whose handler (`tauri-2.11.0/src/image/plugin.rs:41` → `src/image/mod.rs:96`) is a bare `std::fs::read(path)` with no scope mechanism of any kind. Nothing imports `@tauri-apps/api/image`, so the whole plugin is dropped rather than scoped — there is nothing to scope it with. `core:menu` and `core:tray` are dropped for the same reason (no menu, no tray icon); `core:window` and `core:path` because nothing imports them; `core:resources:allow-close` because no frontend value is a `Resource`; and `core:event`'s `allow-emit`/`allow-emit-to` because the frontend only ever *listens* — every emit in this app originates in Rust. Three notes on what is deliberately kept or accepted: (1) `core:webview:allow-internal-toggle-devtools` is not called by `app/src` at all — it is called by Tauri's own injected `toggle-devtools.js`, which binds Ctrl/Cmd+Shift+I. Both that script and the command behind it are `#[cfg(any(debug_assertions, feature = \"devtools\"))]`, so this grant is a `tauri dev` convenience that does not exist in a release bundle. (2) `opener:allow-open-url` is **gone**. It could not be narrowed by host — `TerminalView`'s `WebLinksAddon` opens links Claude printed inside the container, which are arbitrary by construction, so a host allowlist would have deleted the feature rather than bounded it — and it was carried here as an accepted residual risk: a compromised webview could make the OS open an attacker-chosen http(s) URL, an outbound channel. That risk is now closed rather than recorded. Every host-browser open in the app goes through the `open_url_external` command in `url_open.rs`, which exists because the AppImage environment leaks into a cold-launched browser on Linux (triple-c#34) and which re-validates the URL in Rust — scheme allowlist, no embedded credentials, no control characters, length cap, ASCII asserted before `execvp`. On macOS and Windows that command reaches the same plugin as before, via `OpenerExt::open_url`, whose desktop implementation calls `crate::open::open` directly and is therefore not gated by this file at all (`tauri-plugin-opener-2.5.3/src/lib.rs:60`). The plugin stays a dependency for exactly that reason; what is removed is the webview's ability to reach it without passing the Rust validation. (3) `drag:allow-start-drag` is **gone**, together with the OS drag-out it existed for. It could not be scoped — `tauri-plugin-drag` takes the item paths from the caller and has no scope mechanism, so a compromised webview could call `startDrag({ item: ['~/.ssh/id_rsa'] })` against any host path the user can read — and it was carried as an accepted residual risk for one gesture. Drag-out was held back for separate hardening (see branch `hold/disk-and-dragout`) and the plugin is no longer a dependency. Getting a file *out* of a container is either \"Back up container\" on the project's Overview tab, which archives a tree through the Docker API, or the Files tab's per-row \"Save to host…\", which copies one file; getting one *in* is a drop on the Terminal or the Files tab's \"Upload…\". None of the four touches this permission. The Files tab's two are worth separating out here, because they are the only host-path commands in the app whose dialog is opened by **Rust** rather than by the webview — `pick_save_path` and `pick_files_to_upload` in `commands/file_commands.rs` drive `tauri-plugin-dialog` from the backend, so a compromised webview can ask for a picker and nothing more: it cannot name a host path as an *input* to either command. Be precise about the limit of that claim — host paths do still travel outward in error text (`Failed to create /home/j/Documents/x.txt.triple-c-part-1a2b3c4d: Permission denied`), including canonicalized ones, which disclose symlink targets. That is accepted; the app already hands the webview the project paths. What is closed is the direction that mattered — the webview naming where bytes go. That is the shape an earlier revision of this file named as the honest one if the Files tab ever regained host I/O, and it is the shape it regained it in. The `dialog:allow-open` / `dialog:allow-save` grants below are therefore *not* what those two use; they remain for the frontend pickers in Add Project, the Config tab's workspace and access sections, the CA-certificate fielLine truncated
|
||||
@@ -896,12 +896,6 @@
|
||||
"const": "allow-rename-container-path",
|
||||
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the resume_session_command command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-resume-session-command",
|
||||
"markdownDescription": "Enables the resume_session_command command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1712,12 +1706,6 @@
|
||||
"const": "deny-rename-container-path",
|
||||
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the resume_session_command command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-resume-session-command",
|
||||
"markdownDescription": "Denies the resume_session_command command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -896,12 +896,6 @@
|
||||
"const": "allow-rename-container-path",
|
||||
"markdownDescription": "Enables the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the resume_session_command command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "allow-resume-session-command",
|
||||
"markdownDescription": "Enables the resume_session_command command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Enables the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
@@ -1712,12 +1706,6 @@
|
||||
"const": "deny-rename-container-path",
|
||||
"markdownDescription": "Denies the rename_container_path command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the resume_session_command command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
"const": "deny-resume-session-command",
|
||||
"markdownDescription": "Denies the resume_session_command command without any pre-configured scope."
|
||||
},
|
||||
{
|
||||
"description": "Denies the rollback_migration command without any pre-configured scope.",
|
||||
"type": "string",
|
||||
|
||||
@@ -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.
|
||||
@@ -301,7 +300,7 @@ fn validate_task_id(id: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
/// Strict allowlist for session ids (Claude Code uses UUIDs).
|
||||
fn validate_session_id(id: &str) -> Result<(), String> {
|
||||
pub(crate) fn validate_session_id(id: &str) -> Result<(), String> {
|
||||
let valid = !id.is_empty()
|
||||
&& id.len() <= 64
|
||||
&& id.starts_with(|c: char| c.is_ascii_alphanumeric())
|
||||
@@ -422,36 +421,6 @@ pub async fn list_claude_sessions(
|
||||
.collect())
|
||||
}
|
||||
|
||||
/// Build the shell command line that resumes a session, for the frontend to
|
||||
/// drop into a terminal.
|
||||
///
|
||||
/// The flag spelling was checked against the CLI in the container image:
|
||||
/// `claude --resume <session-id>` (short form `-r`).
|
||||
///
|
||||
/// The project's permission mode is folded in so the resumed session behaves
|
||||
/// like a freshly opened one. The session id is validated first, and the
|
||||
/// returned string contains only allowlisted characters.
|
||||
#[tauri::command]
|
||||
pub async fn resume_session_command(
|
||||
project_id: String,
|
||||
session_id: String,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<String, String> {
|
||||
validate_session_id(&session_id)?;
|
||||
|
||||
let project = state
|
||||
.projects_store
|
||||
.get(&project_id)
|
||||
.ok_or_else(|| format!("Project {} not found", project_id))?;
|
||||
|
||||
let mut parts = vec!["claude".to_string()];
|
||||
parts.extend(project.effective_permission_mode().cli_args());
|
||||
parts.push("--resume".to_string());
|
||||
parts.push(session_id);
|
||||
|
||||
Ok(parts.join(" "))
|
||||
}
|
||||
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
// 2. Capabilities
|
||||
// ─────────────────────────────────────────────────────────────────────────────
|
||||
|
||||
@@ -11,12 +11,18 @@ use crate::AppState;
|
||||
/// validates the AWS session first, and runs `aws sso login` if it has expired
|
||||
/// so the user can re-authenticate (the URL is clickable via xterm.js
|
||||
/// WebLinksAddon).
|
||||
fn build_terminal_cmd(project: &Project, state: &AppState, session_name: Option<&str>) -> Vec<String> {
|
||||
fn build_terminal_cmd(
|
||||
project: &Project,
|
||||
state: &AppState,
|
||||
session_name: Option<&str>,
|
||||
resume_session_id: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let settings = state.settings_store.get();
|
||||
build_claude_terminal_cmd(
|
||||
project,
|
||||
settings.global_aws.aws_profile.as_deref(),
|
||||
session_name,
|
||||
resume_session_id,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -53,10 +59,16 @@ fn shell_quote_arg(arg: &str) -> String {
|
||||
|
||||
/// The testable core of [`build_terminal_cmd`], taking the resolved global AWS
|
||||
/// profile rather than the whole [`AppState`].
|
||||
///
|
||||
/// `resume_session_id` adds `--resume <id>` (flag spelling checked against the
|
||||
/// CLI in the container image) and drops `-n`: the resumed conversation keeps
|
||||
/// the name it already has, and the tab label the caller passes as
|
||||
/// `session_name` — often just "resume" — must not overwrite it.
|
||||
fn build_claude_terminal_cmd(
|
||||
project: &Project,
|
||||
global_aws_profile: Option<&str>,
|
||||
session_name: Option<&str>,
|
||||
resume_session_id: Option<&str>,
|
||||
) -> Vec<String> {
|
||||
let is_bedrock_profile = project.backend == Backend::Bedrock
|
||||
&& project
|
||||
@@ -69,12 +81,15 @@ fn build_claude_terminal_cmd(
|
||||
|
||||
// The args are interpolated into a shell script string, so single-quote
|
||||
// each one.
|
||||
let name_flag = session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||
.unwrap_or_default();
|
||||
let session_flags = match resume_session_id {
|
||||
Some(id) => format!(" --resume{}", shell_quote_arg(id)),
|
||||
None => session_name
|
||||
.filter(|n| !n.is_empty())
|
||||
.map(|n| format!(" -n{}", shell_quote_arg(n)))
|
||||
.unwrap_or_default(),
|
||||
};
|
||||
let permission_flags: String = permission_args.iter().map(|a| shell_quote_arg(a)).collect();
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, name_flag);
|
||||
let claude_cmd = format!("exec claude{}{}", permission_flags, session_flags);
|
||||
|
||||
if !is_bedrock_profile {
|
||||
return vec![
|
||||
@@ -135,6 +150,7 @@ pub async fn open_terminal_session(
|
||||
session_id: String,
|
||||
session_type: Option<String>,
|
||||
session_name: Option<String>,
|
||||
resume_session_id: Option<String>,
|
||||
app_handle: AppHandle,
|
||||
state: State<'_, AppState>,
|
||||
) -> Result<(), String> {
|
||||
@@ -148,9 +164,21 @@ pub async fn open_terminal_session(
|
||||
.as_ref()
|
||||
.ok_or_else(|| "Container not running".to_string())?;
|
||||
|
||||
if let Some(id) = resume_session_id.as_deref() {
|
||||
crate::commands::inspect_commands::validate_session_id(id)?;
|
||||
}
|
||||
|
||||
let cmd = match session_type.as_deref() {
|
||||
Some("bash") if resume_session_id.is_some() => {
|
||||
return Err("Only a Claude session can resume a conversation".to_string());
|
||||
}
|
||||
Some("bash") => vec!["bash".to_string(), "-l".to_string()],
|
||||
_ => build_terminal_cmd(&project, &state, session_name.as_deref()),
|
||||
_ => build_terminal_cmd(
|
||||
&project,
|
||||
&state,
|
||||
session_name.as_deref(),
|
||||
resume_session_id.as_deref(),
|
||||
),
|
||||
};
|
||||
|
||||
let output_event = format!("terminal-output-{}", session_id);
|
||||
@@ -414,7 +442,7 @@ mod tests {
|
||||
/// wrapper rather than a bare `claude` argv.
|
||||
#[test]
|
||||
fn build_terminal_cmd_updates_before_launching_claude() {
|
||||
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None);
|
||||
let cmd = build_claude_terminal_cmd(&project("anthropic", serde_json::Value::Null), None, None, None);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
assert_eq!(cmd[1], "-c");
|
||||
@@ -444,6 +472,7 @@ mod tests {
|
||||
&project("anthropic", serde_json::Value::Null),
|
||||
None,
|
||||
Some("Bob's tab; rm -rf /"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert!(
|
||||
@@ -459,7 +488,7 @@ mod tests {
|
||||
fn build_terminal_cmd_quotes_permission_flags_and_omits_an_empty_name() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.full_permissions = true;
|
||||
let cmd = build_claude_terminal_cmd(&p, None, Some(""));
|
||||
let cmd = build_claude_terminal_cmd(&p, None, Some(""), None);
|
||||
|
||||
assert!(
|
||||
cmd[2].contains("exec claude '--dangerously-skip-permissions'\n"),
|
||||
@@ -474,7 +503,7 @@ mod tests {
|
||||
fn build_terminal_cmd_passes_auto_permission_mode() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.permission_mode = Some(crate::models::project::PermissionMode::Auto);
|
||||
let cmd = build_claude_terminal_cmd(&p, None, None);
|
||||
let cmd = build_claude_terminal_cmd(&p, None, None, None);
|
||||
|
||||
assert!(
|
||||
cmd[2].contains("exec claude '--permission-mode' 'auto'"),
|
||||
@@ -497,6 +526,7 @@ mod tests {
|
||||
})),
|
||||
None,
|
||||
Some("it's fine"),
|
||||
None,
|
||||
);
|
||||
|
||||
assert_eq!(cmd[0], "bash");
|
||||
@@ -512,6 +542,31 @@ mod tests {
|
||||
);
|
||||
}
|
||||
|
||||
/// Resume goes through the same launch as a new session — prelude,
|
||||
/// permission flags — and passes the id instead of the tab label, so a
|
||||
/// label of "resume" does not rename the conversation.
|
||||
#[test]
|
||||
fn build_terminal_cmd_resumes_without_renaming() {
|
||||
let mut p = project("anthropic", serde_json::Value::Null);
|
||||
p.full_permissions = true;
|
||||
let cmd = build_claude_terminal_cmd(
|
||||
&p,
|
||||
None,
|
||||
Some("resume"),
|
||||
Some("e13d312d-2f38-4cf6-b0e0-1db60208a74c"),
|
||||
);
|
||||
|
||||
assert!(cmd[2].contains(UPDATE_PRELUDE), "got: {}", cmd[2]);
|
||||
assert!(
|
||||
cmd[2].contains(
|
||||
"exec claude '--dangerously-skip-permissions' --resume 'e13d312d-2f38-4cf6-b0e0-1db60208a74c'\n"
|
||||
),
|
||||
"got: {}",
|
||||
cmd[2]
|
||||
);
|
||||
assert!(!cmd[2].contains(" -n "), "resume must not rename: {}", cmd[2]);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn a_dropped_file_keeps_the_name_the_user_dropped() {
|
||||
use crate::commands::file_commands::host_upload_name;
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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");
|
||||
|
||||
@@ -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' }}"));
|
||||
}
|
||||
}
|
||||
@@ -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)]
|
||||
|
||||
@@ -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());
|
||||
|
||||
@@ -645,7 +645,6 @@ pub fn run() {
|
||||
commands::gateway_commands::regenerate_gateway_auth_token,
|
||||
// Container introspection (sessions / capabilities / scheduler)
|
||||
commands::inspect_commands::list_claude_sessions,
|
||||
commands::inspect_commands::resume_session_command,
|
||||
commands::inspect_commands::list_container_capabilities,
|
||||
commands::inspect_commands::list_scheduled_tasks,
|
||||
commands::inspect_commands::add_scheduled_task,
|
||||
|
||||
@@ -17,6 +17,7 @@ vi.mock("../../hooks/useProjectActions", () => ({
|
||||
handleBackup: vi.fn(),
|
||||
openClaudeTerminal: mockOpenClaudeTerminal,
|
||||
openShell: vi.fn(),
|
||||
resumeClaudeSession: vi.fn(),
|
||||
openTerminalWithCommand: vi.fn(),
|
||||
}),
|
||||
}));
|
||||
|
||||
@@ -4,7 +4,6 @@ import {
|
||||
listClaudeSessions,
|
||||
listScheduledTasks,
|
||||
getSchedulerNotifications,
|
||||
resumeSessionCommand,
|
||||
} from "../../../lib/tauri-commands";
|
||||
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import type { SaveState } from "../../../hooks/useSaveState";
|
||||
@@ -78,14 +77,8 @@ export default function OverviewTab({
|
||||
};
|
||||
}, [project.id, running, project.container_id]);
|
||||
|
||||
const handleResume = async (session: ClaudeSession) => {
|
||||
try {
|
||||
const command = await resumeSessionCommand(project.id, session.id);
|
||||
await actions.openTerminalWithCommand(command, session.name ?? "resume");
|
||||
} catch (e) {
|
||||
console.error("Failed to build the resume command:", e);
|
||||
}
|
||||
};
|
||||
const handleResume = (session: ClaudeSession) =>
|
||||
actions.resumeClaudeSession(session.id, session.name ?? "resume");
|
||||
|
||||
return (
|
||||
<div className="p-4 space-y-6 max-w-4xl">
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import type { ClaudeSession, Project } from "../../../lib/types";
|
||||
import { listClaudeSessions, resumeSessionCommand } from "../../../lib/tauri-commands";
|
||||
import { listClaudeSessions } from "../../../lib/tauri-commands";
|
||||
import type { useProjectActions } from "../../../hooks/useProjectActions";
|
||||
import { useAppState } from "../../../store/appState";
|
||||
import Button from "../../ui/Button";
|
||||
import { formatAge, formatBytes } from "./format";
|
||||
|
||||
@@ -18,7 +17,6 @@ interface Props {
|
||||
export default function SessionsTab({ project, actions }: Props) {
|
||||
const [sessions, setSessions] = useState<ClaudeSession[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const pushToast = useAppState((s) => s.pushToast);
|
||||
const running = project.status === "running";
|
||||
|
||||
const load = useCallback(() => {
|
||||
@@ -35,18 +33,8 @@ export default function SessionsTab({ project, actions }: Props) {
|
||||
|
||||
useEffect(load, [load]);
|
||||
|
||||
const resume = async (session: ClaudeSession) => {
|
||||
try {
|
||||
const command = await resumeSessionCommand(project.id, session.id);
|
||||
await actions.openTerminalWithCommand(command, session.name ?? "resume");
|
||||
} catch (e) {
|
||||
pushToast({
|
||||
kind: "error",
|
||||
message: "Could not resume that session",
|
||||
detail: String(e),
|
||||
});
|
||||
}
|
||||
};
|
||||
const resume = (session: ClaudeSession) =>
|
||||
actions.resumeClaudeSession(session.id, session.name ?? "resume");
|
||||
|
||||
return (
|
||||
<div className="p-4 max-w-4xl">
|
||||
|
||||
@@ -60,6 +60,8 @@ const ptyOutput = vi.hoisted(() => ({
|
||||
const xterm = vi.hoisted(() => ({
|
||||
options: null as Record<string, unknown> | null,
|
||||
instances: [] as unknown[],
|
||||
/** Every link provider registered on any terminal, for asking what gets underlined. */
|
||||
linkProviders: [] as import("@xterm/xterm").ILinkProvider[],
|
||||
}));
|
||||
|
||||
/**
|
||||
@@ -82,6 +84,10 @@ vi.mock("@xterm/xterm", async (importOriginal) => {
|
||||
xterm.options = (options ?? null) as Record<string, unknown> | null;
|
||||
xterm.instances.push(this);
|
||||
}
|
||||
override registerLinkProvider(provider: import("@xterm/xterm").ILinkProvider) {
|
||||
xterm.linkProviders.push(provider);
|
||||
return super.registerLinkProvider(provider);
|
||||
}
|
||||
}
|
||||
return { ...actual, Terminal: SpyTerminal };
|
||||
});
|
||||
@@ -1974,3 +1980,151 @@ describe("the plain-text URL path is gated the same way", () => {
|
||||
expect(openUrlExternal).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* The underline is the link: `WebLinksAddon` paints exactly what its regex
|
||||
* matched and hands that same text to the click handler. So trailing prose
|
||||
* punctuation has to be kept out of the *match*, not trimmed afterwards —
|
||||
* otherwise `See https://x/docs.` underlines the full stop and opens it.
|
||||
*/
|
||||
describe("plain-text URLs stop before trailing punctuation", () => {
|
||||
function term() {
|
||||
return xterm.instances.at(-1) as unknown as {
|
||||
write(d: string, cb: () => void): void;
|
||||
};
|
||||
}
|
||||
|
||||
async function write(data: string) {
|
||||
await act(() => new Promise<void>((resolve) => term().write(data, resolve)));
|
||||
}
|
||||
|
||||
/** The links every registered provider reports for buffer row 1. */
|
||||
async function linksOnFirstRow() {
|
||||
const found: Array<{ text: string; startX: number; endX: number }> = [];
|
||||
for (const provider of xterm.linkProviders) {
|
||||
await new Promise<void>((resolve) =>
|
||||
provider.provideLinks(1, (links) => {
|
||||
for (const l of links ?? []) {
|
||||
found.push({ text: l.text, startX: l.range.start.x, endX: l.range.end.x });
|
||||
}
|
||||
resolve();
|
||||
}),
|
||||
);
|
||||
}
|
||||
return found.filter((l) => l.text.startsWith("http"));
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
xterm.linkProviders.length = 0;
|
||||
});
|
||||
|
||||
it.each([
|
||||
["See https://example.com/docs.", "https://example.com/docs", 5],
|
||||
["(https://example.com/foo)", "https://example.com/foo", 2],
|
||||
["[text](https://x.example/z).", "https://x.example/z", 8],
|
||||
["<https://x.example/a>", "https://x.example/a", 2],
|
||||
["https://en.wikipedia.org/wiki/Foo_(bar)", "https://en.wikipedia.org/wiki/Foo_(bar)", 1],
|
||||
])("underlines only the URL in %j", async (line, url, startX) => {
|
||||
mountSession("bash");
|
||||
await write(line);
|
||||
|
||||
expect(await linksOnFirstRow()).toEqual([
|
||||
{ text: url, startX, endX: startX + url.length - 1 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("TerminalView — copying text out", () => {
|
||||
let writeText: ReturnType<typeof vi.fn>;
|
||||
|
||||
beforeEach(() => {
|
||||
writeText = vi.fn(async () => {});
|
||||
Object.defineProperty(navigator, "clipboard", {
|
||||
value: { writeText },
|
||||
configurable: true,
|
||||
});
|
||||
});
|
||||
|
||||
async function emitBytes(bytes: Uint8Array) {
|
||||
const emit = ptyOutput.listeners.get("terminal-output-s1");
|
||||
if (!emit) throw new Error("no terminal-output listener registered");
|
||||
await act(async () => {
|
||||
emit({ payload: Array.from(bytes) });
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
await new Promise((r) => setTimeout(r, 0));
|
||||
});
|
||||
}
|
||||
const utf8 = (s: string) => new TextEncoder().encode(s);
|
||||
|
||||
function pressCopy(container: HTMLElement, altKey = false) {
|
||||
helperTextarea(container).dispatchEvent(
|
||||
new KeyboardEvent("keydown", {
|
||||
key: "C",
|
||||
ctrlKey: true,
|
||||
shiftKey: true,
|
||||
altKey,
|
||||
bubbles: true,
|
||||
cancelable: true,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
it("puts OSC 52 text on the clipboard as UTF-8, not mojibake", async () => {
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
// What the xclip shim (`printf '%s' | base64`) and Claude Code both send.
|
||||
const text = "it’s café — naïve 日本語\u00A0👍";
|
||||
const b64 = Buffer.from(text, "utf8").toString("base64");
|
||||
await emitBytes(utf8(`\x1b]52;c;${b64}\x07`));
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
// NBSP normalized; everything else exactly as sent.
|
||||
expect(writeText).toHaveBeenCalledWith("it’s café — naïve 日本語 👍");
|
||||
});
|
||||
|
||||
it("does not write anything for an OSC 52 read request", async () => {
|
||||
mountSession("claude");
|
||||
await act(async () => {});
|
||||
await emitBytes(utf8("\x1b]52;c;?\x07"));
|
||||
expect(writeText).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
// Claude Code's own hard wrap: it moves to the next row itself (CR + cursor
|
||||
// moves in the real stream), so the rows are not `isWrapped` and the
|
||||
// selection carries a newline at every break.
|
||||
const CLAUDE_ROWS = [
|
||||
"● The café’s naïve résumé handling means the parser — which was",
|
||||
" written years ago by someone who has since left the team — silently",
|
||||
" drops every accented character it cannot map.",
|
||||
];
|
||||
|
||||
it("Ctrl+Shift+C rejoins Claude's wrapped rows and drops its gutter", async () => {
|
||||
const { container } = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const term = xterm.instances[0] as Terminal;
|
||||
act(() => term.resize(70, 24));
|
||||
await emitBytes(utf8(CLAUDE_ROWS.join("\r\n")));
|
||||
term.selectAll();
|
||||
|
||||
pressCopy(container);
|
||||
|
||||
expect(writeText).toHaveBeenCalledWith(
|
||||
"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
);
|
||||
});
|
||||
|
||||
it("Ctrl+Shift+Alt+C copies the selection raw", async () => {
|
||||
const { container } = mountSession("claude");
|
||||
await act(async () => {});
|
||||
const term = xterm.instances[0] as Terminal;
|
||||
act(() => term.resize(70, 24));
|
||||
await emitBytes(utf8(CLAUDE_ROWS.join("\r\n")));
|
||||
term.selectAll();
|
||||
|
||||
pressCopy(container, true);
|
||||
|
||||
expect(writeText).toHaveBeenCalledTimes(1);
|
||||
expect(writeText.mock.calls[0][0]).toBe(term.getSelection());
|
||||
expect(writeText.mock.calls[0][0]).toContain("● The café’s");
|
||||
});
|
||||
});
|
||||
@@ -16,6 +16,7 @@ import {
|
||||
} from "../../lib/tauri-commands";
|
||||
import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
import { UrlDetector, type UrlSource } from "../../lib/urlDetector";
|
||||
import { TERMINAL_URL_REGEX } from "../../lib/urlMatch";
|
||||
import {
|
||||
RelayRateLimiter,
|
||||
URL_RELAY_OSC,
|
||||
@@ -31,7 +32,8 @@ import UrlToast, {
|
||||
URL_TOAST_SELECTOR,
|
||||
URL_TOAST_SHORTCUT,
|
||||
} from "./UrlToast";
|
||||
import { trimSelection } from "./trimSelection";
|
||||
import { cleanSelection, sanitizeInvisibles, type CopyGeometry } from "./copyCleanup";
|
||||
import { decodeOsc52 } from "./osc52";
|
||||
import { resolveTerminalGpuRendering } from "../../lib/terminalRenderer";
|
||||
import TerminalContextMenu from "./TerminalContextMenu";
|
||||
import { createFilePathLinkProvider } from "./filePathLinkProvider";
|
||||
@@ -122,6 +124,16 @@ export function supersedes(
|
||||
*/
|
||||
export const OSC8_HOVER_CLASS = "xterm-hover";
|
||||
|
||||
/**
|
||||
* What `cleanSelection` needs to tell Claude's hard wraps from real line
|
||||
* breaks: the width it wrapped to, and where the selection's first row began.
|
||||
* `getSelectionPosition().start.x` is documented as 1-based but xterm 5.5
|
||||
* returns the selection model's 0-based column — which is what is wanted.
|
||||
*/
|
||||
function selectionGeometry(term: Terminal): CopyGeometry {
|
||||
return { cols: term.cols, startCol: term.getSelectionPosition()?.start.x ?? 0 };
|
||||
}
|
||||
|
||||
/**
|
||||
* Report a failed handoff to the host's browser.
|
||||
*
|
||||
@@ -1069,9 +1081,10 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
// Web links addon — opens URLs in host browser via Tauri, with a permissive regex
|
||||
// that matches URLs even if they lack trailing path segments (the default regex
|
||||
// misses OAuth URLs that end mid-line).
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const urlRegex = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/;
|
||||
// misses OAuth URLs that end mid-line). `TERMINAL_URL_REGEX` also leaves
|
||||
// trailing prose punctuation and wrapping brackets out of the match, which
|
||||
// is what the addon underlines *and* what it hands the handler below.
|
||||
const urlRegex = TERMINAL_URL_REGEX;
|
||||
const webLinksAddon = new WebLinksAddon((event, uri) => {
|
||||
// Same gate and same sink as `createOsc8LinkHandler`, because this
|
||||
// reaches the same `openUrlExternal` through the same
|
||||
@@ -1126,15 +1139,17 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
|
||||
term.open(containerRef.current);
|
||||
|
||||
// Ctrl+Shift+C copies the selection with whitespace trimmed (UI padding
|
||||
// stripped, internal indentation preserved). Ctrl+Shift+Alt+C copies raw.
|
||||
// Ctrl+Shift+C copies the selection cleaned up for pasting (UI padding,
|
||||
// Claude's gutter markers and hard wraps, and invisible characters removed;
|
||||
// internal indentation preserved — see `copyCleanup.ts`). Ctrl+Shift+Alt+C
|
||||
// copies raw.
|
||||
// Both prevent the keystroke from reaching the container (where Ctrl+C
|
||||
// would send SIGINT and cancel running work).
|
||||
term.attachCustomKeyEventHandler((event) => {
|
||||
if (event.type === "keydown" && event.ctrlKey && event.shiftKey && event.key === "C") {
|
||||
const sel = term.getSelection();
|
||||
if (sel) {
|
||||
const out = event.altKey ? sel : trimSelection(sel);
|
||||
const out = event.altKey ? sel : cleanSelection(sel, selectionGeometry(term));
|
||||
navigator.clipboard.writeText(out).catch((e) =>
|
||||
console.error("Ctrl+Shift+C clipboard write failed:", e),
|
||||
);
|
||||
@@ -1214,16 +1229,18 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
const osc52Disposable = term.parser.registerOscHandler(52, (data) => {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return false;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return false; // clipboard read request, not supported
|
||||
try {
|
||||
const decoded = atob(payload);
|
||||
navigator.clipboard.writeText(decoded).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
} catch (e) {
|
||||
console.error("OSC 52 decode failed:", e);
|
||||
if (data.substring(idx + 1) === "?") return false; // clipboard read request, not supported
|
||||
// Decoded as UTF-8 — `atob` alone mojibaked every non-ASCII character.
|
||||
// Only invisible characters are normalized: the program chose this text,
|
||||
// so it is not dedented or reflowed the way a terminal selection is.
|
||||
const decoded = decodeOsc52(data);
|
||||
if (decoded === null) {
|
||||
console.error("OSC 52 decode failed: invalid base64");
|
||||
return true;
|
||||
}
|
||||
navigator.clipboard.writeText(sanitizeInvisibles(decoded)).catch((e) =>
|
||||
console.error("OSC 52 clipboard write failed:", e),
|
||||
);
|
||||
return true;
|
||||
});
|
||||
|
||||
@@ -1625,7 +1642,7 @@ export default function TerminalView({ sessionId, active }: Props) {
|
||||
if (!term) return;
|
||||
const sel = term.getSelection();
|
||||
if (!sel) return;
|
||||
const out = mode === "raw" ? sel : trimSelection(sel);
|
||||
const out = mode === "raw" ? sel : cleanSelection(sel, selectionGeometry(term));
|
||||
navigator.clipboard.writeText(out).catch((e) =>
|
||||
console.error("Context menu clipboard write failed:", e),
|
||||
);
|
||||
|
||||
@@ -0,0 +1,287 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import {
|
||||
sanitizeInvisibles,
|
||||
stripClaudeGutter,
|
||||
rejoinHardWraps,
|
||||
cleanSelection,
|
||||
cellWidth,
|
||||
} from "./copyCleanup";
|
||||
|
||||
/**
|
||||
* Rows of a real Claude Code 2.1.316 reply, captured from a 70-column pty and
|
||||
* replayed through @xterm/headless 5.5 (what `getSelection()` would return for
|
||||
* a drag from column 0 of the first row to the end of the last). Claude
|
||||
* hard-wraps its own output — a real `\r` + cursor move between rows, never an
|
||||
* xterm soft wrap, so every row has `isWrapped === false` and the selection
|
||||
* carries a `\n` at each of Claude's breaks. Note the URL split mid-token at
|
||||
* exactly column 70, and the code line Claude also wrapped at a space.
|
||||
*/
|
||||
const CLAUDE_ROWS_70 = [
|
||||
"● The café’s naïve résumé handling means the parser — which was",
|
||||
" written years ago by someone who has since left the team — silently",
|
||||
" drops every accented character it cannot map.",
|
||||
"",
|
||||
" See https://example.com/a/very/long/path/that/keeps/going/and/going/",
|
||||
" until/it/wraps/index.html?query=1 for details.",
|
||||
"",
|
||||
" - The first item is a long sentence that describes one important",
|
||||
" consideration about the parser in considerable detail, so that it",
|
||||
" lands near twenty-five words.",
|
||||
" - The second item is another long sentence that explains the second",
|
||||
" consideration for the reader, written to contain roughly the same",
|
||||
" number of words as the first.",
|
||||
"",
|
||||
" curl -fsSL https://example.com/install.sh | bash -s --",
|
||||
" --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
];
|
||||
|
||||
describe("sanitizeInvisibles", () => {
|
||||
it("turns non-breaking and odd-width spaces into plain spaces", () => {
|
||||
expect(sanitizeInvisibles("git\u00A0status")).toBe("git status");
|
||||
expect(sanitizeInvisibles("a\u202Fb\u2007c\u2002d\u2009e\u200Af\u205Fg")).toBe("a b c d e f g");
|
||||
});
|
||||
|
||||
it("removes zero-width spaces, word joiners and BOMs", () => {
|
||||
expect(sanitizeInvisibles("\uFEFFnpm\u200B run\u2060 build")).toBe("npm run build");
|
||||
});
|
||||
|
||||
it("removes ZWJ/ZWNJ next to ASCII, where they only break commands", () => {
|
||||
expect(sanitizeInvisibles("ls\u200D -la\u200C")).toBe("ls -la");
|
||||
});
|
||||
|
||||
it("keeps ZWJ inside emoji sequences and ZWNJ inside non-Latin words", () => {
|
||||
const family = "👨\u200D👩\u200D👧";
|
||||
expect(sanitizeInvisibles(`ok ${family} ok`)).toBe(`ok ${family} ok`);
|
||||
const persian = "می\u200Cخواهم"; // ZWNJ is orthographically required here
|
||||
expect(sanitizeInvisibles(persian)).toBe(persian);
|
||||
const devanagari = "क्\u200Dष";
|
||||
expect(sanitizeInvisibles(devanagari)).toBe(devanagari);
|
||||
});
|
||||
|
||||
it("leaves legitimate non-ASCII text alone", () => {
|
||||
const text = "it’s café — naïve “quotes” 日本語\u3000テキスト 👍🏽 ● ⎿ │";
|
||||
expect(sanitizeInvisibles(text)).toBe(text); // U+3000 ideographic space kept
|
||||
});
|
||||
|
||||
it("turns Unicode line/paragraph separators into newlines", () => {
|
||||
expect(sanitizeInvisibles("a\u2028b\u2029c")).toBe("a\nb\nc");
|
||||
});
|
||||
});
|
||||
|
||||
describe("cellWidth", () => {
|
||||
it("counts wide CJK and emoji as two cells and zero-width marks as none", () => {
|
||||
expect(cellWidth("abc")).toBe(3);
|
||||
expect(cellWidth("日本")).toBe(4);
|
||||
expect(cellWidth("👍")).toBe(2);
|
||||
expect(cellWidth("é")).toBe(1);
|
||||
expect(cellWidth("—●⎿")).toBe(3);
|
||||
});
|
||||
});
|
||||
|
||||
describe("stripClaudeGutter", () => {
|
||||
it("blanks Claude's message bullet at column 0, keeping columns", () => {
|
||||
expect(stripClaudeGutter("● Hello\n world", 0)).toBe(" Hello\n world");
|
||||
});
|
||||
|
||||
it("blanks the tool-result elbow", () => {
|
||||
expect(stripClaudeGutter("● Bash(ls)\n ⎿ a.txt\n b.txt", 0)).toBe(
|
||||
" Bash(ls)\n a.txt\n b.txt",
|
||||
);
|
||||
});
|
||||
|
||||
it("does not touch a bullet that is not in the gutter", () => {
|
||||
expect(stripClaudeGutter(" ● item", 0)).toBe(" ● item");
|
||||
// Selection started mid-row: the first line's column 0 is not the gutter.
|
||||
expect(stripClaudeGutter("● x", 5)).toBe("● x");
|
||||
});
|
||||
|
||||
it("strips box borders only when every line is inside the box", () => {
|
||||
const box = [
|
||||
"╭────────────────╮",
|
||||
"│ Do you want to │",
|
||||
"│ proceed? │",
|
||||
"╰────────────────╯",
|
||||
].join("\n");
|
||||
expect(stripClaudeGutter(box, 0)).toBe(" Do you want to\n proceed?");
|
||||
});
|
||||
|
||||
it("leaves tables and partial boxes alone", () => {
|
||||
const table = "│ a │ b │\n├───┼───┤\n│ 1 │ 2 │";
|
||||
expect(stripClaudeGutter(table, 0)).toBe(table);
|
||||
const mixed = "│ inside │\noutside";
|
||||
expect(stripClaudeGutter(mixed, 0)).toBe(mixed);
|
||||
});
|
||||
|
||||
it("leaves a selection of only a table's body rows alone", () => {
|
||||
const body = "│ a │ b │\n│ 1 │ 2 │";
|
||||
expect(stripClaudeGutter(body, 0)).toBe(body);
|
||||
expect(stripClaudeGutter("│ only │ row │", 0)).toBe("│ only │ row │");
|
||||
});
|
||||
});
|
||||
|
||||
describe("rejoinHardWraps", () => {
|
||||
it("rejoins a real Claude Code reply captured at 70 columns", () => {
|
||||
const sel = stripClaudeGutter(CLAUDE_ROWS_70.join("\n"), 0);
|
||||
expect(rejoinHardWraps(sel, { cols: 70 })).toBe(
|
||||
[
|
||||
" The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
"",
|
||||
" See https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.",
|
||||
"",
|
||||
" - The first item is a long sentence that describes one important consideration about the parser in considerable detail, so that it lands near twenty-five words.",
|
||||
" - The second item is another long sentence that explains the second consideration for the reader, written to contain roughly the same number of words as the first.",
|
||||
"",
|
||||
" curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
].join("\n"),
|
||||
);
|
||||
});
|
||||
|
||||
it("rejoins Claude's echo of a submitted prompt, which wraps one column short", () => {
|
||||
// Same capture: the transcript's echo of the prompt that was typed.
|
||||
const echo = [
|
||||
"❯ Reply with exactly the following markdown and nothing else (no",
|
||||
" tools). First a paragraph: \"The café’s naïve résumé handling means",
|
||||
" the parser — which was written years ago by someone who has since",
|
||||
" left the team — silently drops every accented character it cannot",
|
||||
" map.\" Then a line: \"See https://example.com/a/very/long/path/that/k",
|
||||
" eeps/going/and/going/until/it/wraps/index.html?query=1 for",
|
||||
" details.\" Then a bullet list with two items, each a long sentence",
|
||||
" of about 25 words. Then a bash code block containing: curl -fsSL",
|
||||
" https://example.com/install.sh | bash -s --",
|
||||
" --prefix=/opt/some/really/long/prefix/path --yes",
|
||||
].join("\n");
|
||||
const typed =
|
||||
"Reply with exactly the following markdown and nothing else (no tools). First a paragraph: \"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.\" Then a line: \"See https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\" Then a bullet list with two items, each a long sentence of about 25 words. Then a bash code block containing: curl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes";
|
||||
expect(rejoinHardWraps(echo, { cols: 70 })).toBe(`❯ ${typed}`);
|
||||
});
|
||||
|
||||
it("joins a word that exactly filled the row with a space", () => {
|
||||
// 70 cells: the last token ("wordabc") and the next ("next") are short,
|
||||
// so this was a break at a space that happened to land on the edge.
|
||||
const full = " " + "x".repeat(60) + " wordabc";
|
||||
expect(cellWidth(full)).toBe(70);
|
||||
expect(rejoinHardWraps(`${full}\n next words`, { cols: 70 })).toBe(`${full} next words`);
|
||||
});
|
||||
|
||||
it("does not join short lines (code, prose that ended early)", () => {
|
||||
const code = " def foo():\n return 1\n print(foo())";
|
||||
expect(rejoinHardWraps(code, { cols: 70 })).toBe(code);
|
||||
const prose = " This sentence ends well before the edge.\n So does this one.";
|
||||
expect(rejoinHardWraps(prose, { cols: 70 })).toBe(prose);
|
||||
});
|
||||
|
||||
it("does not join a line whose next word would have fitted", () => {
|
||||
const line = " " + "a ".repeat(30).trimEnd(); // 61 cells
|
||||
expect(rejoinHardWraps(`${line}\n bb`, { cols: 70 })).toBe(`${line}\n bb`);
|
||||
});
|
||||
|
||||
it("does not join across a change of indentation", () => {
|
||||
const a = " " + "word ".repeat(13).trimEnd(); // 66 cells
|
||||
expect(rejoinHardWraps(`${a}\n indented-continuation`, { cols: 70 })).toBe(
|
||||
`${a}\n indented-continuation`,
|
||||
);
|
||||
});
|
||||
|
||||
it("does not join into the next list item or a border", () => {
|
||||
const a = " - " + "word ".repeat(12).trimEnd(); // 63 cells
|
||||
const next = " - another item with a longword";
|
||||
expect(rejoinHardWraps(`${a}\n${next}`, { cols: 70 })).toBe(`${a}\n${next}`);
|
||||
expect(rejoinHardWraps(`${a}\n │ table`, { cols: 70 })).toBe(`${a}\n │ table`);
|
||||
});
|
||||
|
||||
it("leaves column-0 shell output alone (ls-style columns, plain output)", () => {
|
||||
const ls =
|
||||
"Cargo.lock Cargo.toml README.md build.rs capabilities gen icons s\nsrc target tauri.conf.json";
|
||||
expect(rejoinHardWraps(ls, { cols: 70 })).toBe(ls);
|
||||
const plain = "x".repeat(65) + "\nyyyyyyyyyy";
|
||||
expect(rejoinHardWraps(plain, { cols: 70 })).toBe(plain);
|
||||
});
|
||||
|
||||
it("leaves tabular rows alone even when indented", () => {
|
||||
const t = " NAME STATUS PORTS CREATED AT SIZE\n triple-c-abc running 8080/tcp";
|
||||
expect(rejoinHardWraps(t, { cols: 70 })).toBe(t);
|
||||
});
|
||||
|
||||
it("joins wide-character text without inserting a space", () => {
|
||||
const a = " " + "日本語".repeat(11); // 2 + 66 = 68 cells; the next wide char needs 2 more
|
||||
expect(cellWidth(a)).toBe(68);
|
||||
expect(rejoinHardWraps(`${a}\n の続き`, { cols: 69 })).toBe(`${a}の続き`);
|
||||
});
|
||||
|
||||
it("joins astral wide characters (CJK Ext-B) without inserting a space", () => {
|
||||
const a = " " + "\u{20000}".repeat(33); // 2 + 66 = 68 cells
|
||||
expect(cellWidth(a)).toBe(68);
|
||||
expect(rejoinHardWraps(`${a}\n \u{20001}\u{20002}`, { cols: 69 })).toBe(`${a}\u{20001}\u{20002}`);
|
||||
const e = " " + "\u{1F600}".repeat(33); // emoji, also two cells each
|
||||
expect(rejoinHardWraps(`${e}\n \u{1F601}`, { cols: 69 })).toBe(`${e}\u{1F601}`);
|
||||
});
|
||||
|
||||
it("keeps the space when a short word ended at the edge and a URL that fits was moved down whole", () => {
|
||||
const prev = " " + "x".repeat(64) + " the";
|
||||
expect(cellWidth(prev)).toBe(70);
|
||||
const url = "https://example.com/" + "p".repeat(35) + "/index.html";
|
||||
expect(url.length).toBe(66); // fits in the 68 cells after the gutter
|
||||
expect(rejoinHardWraps(`${prev}\n ${url}`, { cols: 70 })).toBe(`${prev} ${url}`);
|
||||
});
|
||||
|
||||
it("still joins without a space when a row-filling piece of a long token is followed by a URL start", () => {
|
||||
// A redirect URL whose split happened to land just before its inner `https://`.
|
||||
const url = "https://r.example/" + "r".repeat(47) + "?u=https://inner.example/x";
|
||||
const rows = [" " + url.slice(0, 68), " " + url.slice(68) + " ok"];
|
||||
expect(cellWidth(rows[0])).toBe(70);
|
||||
expect(rows[1].startsWith(" https://")).toBe(true);
|
||||
expect(rejoinHardWraps(rows.join("\n"), { cols: 70 })).toBe(` ${url} ok`);
|
||||
});
|
||||
|
||||
it("rejoins a URL split over three rows without spaces", () => {
|
||||
const url = "https://example.com/" + "q".repeat(130) + "/end.html";
|
||||
const rows = [" See " + url.slice(0, 64), " " + url.slice(64, 132), " " + url.slice(132) + " ok"];
|
||||
expect(rows.map(cellWidth).slice(0, 2)).toEqual([70, 70]);
|
||||
expect(rejoinHardWraps(rows.join("\n"), { cols: 70 })).toBe(` See ${url} ok`);
|
||||
});
|
||||
|
||||
it("uses the selection's start column for a first line that began mid-row", () => {
|
||||
// Selection started at column 18, so the first line is only 51
|
||||
// characters but ended at column 69 of its row.
|
||||
const first = "written years ago by someone who has since left the";
|
||||
expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70, startCol: 18 })).toBe(
|
||||
`${first} team — silently`,
|
||||
);
|
||||
// Without the start column it looks short and is left alone.
|
||||
expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70 })).toBe(
|
||||
`${first}\n team — silently`,
|
||||
);
|
||||
});
|
||||
|
||||
it("leaves text unchanged when cols is unknown", () => {
|
||||
const sel = CLAUDE_ROWS_70.join("\n");
|
||||
expect(rejoinHardWraps(sel, { cols: 0 })).toBe(sel);
|
||||
});
|
||||
});
|
||||
|
||||
describe("cleanSelection", () => {
|
||||
it("produces paste-ready text from a Claude reply (gutter, wraps, dedent)", () => {
|
||||
const out = cleanSelection(CLAUDE_ROWS_70.join("\n"), { cols: 70 });
|
||||
expect(out.split("\n")[0]).toBe(
|
||||
"The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.",
|
||||
);
|
||||
expect(out).toContain(
|
||||
"\nSee https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\n",
|
||||
);
|
||||
expect(out.endsWith("\ncurl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes")).toBe(true);
|
||||
});
|
||||
|
||||
it("normalizes Windows line endings from xterm's getSelection", () => {
|
||||
expect(cleanSelection(" a\r\n b\r\n", { cols: 70 })).toBe("a\nb");
|
||||
});
|
||||
|
||||
it("strips invisible characters", () => {
|
||||
expect(cleanSelection(" git\u202Fcommit\u200B -m x", { cols: 70 })).toBe("git commit -m x");
|
||||
});
|
||||
|
||||
it("keeps the old trimSelection behaviour for ordinary text", () => {
|
||||
expect(cleanSelection(" def foo():\n return 1\n", { cols: 80 })).toBe(
|
||||
"def foo():\n return 1",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,278 @@
|
||||
import { trimSelection } from "./trimSelection";
|
||||
|
||||
/**
|
||||
* Clean-up applied to text copied out of a terminal, so that pasting it into
|
||||
* an editor, a shell or a chat works.
|
||||
*
|
||||
* Three separate problems, fixed by separate pure functions:
|
||||
*
|
||||
* - **Invisible characters** (`sanitizeInvisibles`). Claude Code prints
|
||||
* `❯` + U+00A0 for its prompt, and model output can carry NBSP / narrow
|
||||
* NBSP / zero-width spaces; pasted into a shell they make `git status` an
|
||||
* unknown command. xterm's `getSelection()` already maps U+00A0 (only that
|
||||
* one) to a space; everything else survived until now.
|
||||
* - **Claude's TUI chrome** (`stripClaudeGutter`): the `●` message bullet and
|
||||
* `⎿` tool-result elbow in the left gutter, and `│ … │` dialog borders.
|
||||
* - **Claude's hard wraps** (`rejoinHardWraps`). Claude Code wraps its own
|
||||
* output to the terminal width and moves the cursor to the next row itself,
|
||||
* so xterm never marks those rows `isWrapped` and a selection carries a real
|
||||
* newline (plus the 2-column gutter indent) at every break — mid-sentence,
|
||||
* and mid-URL when a long token was split at the edge.
|
||||
*
|
||||
* `cleanSelection` composes them with `trimSelection` for the trimmed copy
|
||||
* (Ctrl+Shift+C, context-menu "Copy"). OSC 52 content gets only
|
||||
* `sanitizeInvisibles`: the program chose that text, so it is not reflowed or
|
||||
* dedented — only made safe to paste. Raw copy (Ctrl+Shift+Alt+C) gets none.
|
||||
*/
|
||||
|
||||
// ── invisible characters ────────────────────────────────────────────────────
|
||||
|
||||
/** NBSP, figure space, narrow NBSP, the U+2000–U+200A width spaces, medium
|
||||
* math space. U+3000 (ideographic space) is deliberately absent: it is real
|
||||
* CJK typography and occupies two cells. */
|
||||
const ODD_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F]/g;
|
||||
/** Zero-width space, word joiner, BOM / zero-width no-break space. */
|
||||
const ZERO_WIDTH = /[\u200B\u2060\uFEFF]/g;
|
||||
/** ZWNJ / ZWJ touching ASCII (or a string edge). Between two non-ASCII
|
||||
* characters they are meaningful — emoji ZWJ sequences, Persian ZWNJ,
|
||||
* Indic conjunct control — so only this case is removed. */
|
||||
const JOINER_NEAR_ASCII = /(^|[\x00-\x7F])[\u200C\u200D]+|[\u200C\u200D]+(?=[\x00-\x7F]|$)/g;
|
||||
|
||||
export function sanitizeInvisibles(text: string): string {
|
||||
if (!text) return text;
|
||||
return text
|
||||
.replace(/[\u2028\u2029]/g, "\n")
|
||||
.replace(ODD_SPACES, " ")
|
||||
.replace(ZERO_WIDTH, "")
|
||||
.replace(JOINER_NEAR_ASCII, "$1");
|
||||
}
|
||||
|
||||
// ── cell width ──────────────────────────────────────────────────────────────
|
||||
|
||||
const WIDE_RANGES: ReadonlyArray<readonly [number, number]> = [
|
||||
[0x1100, 0x115f],
|
||||
[0x2e80, 0x303e],
|
||||
[0x3041, 0x33ff],
|
||||
[0x3400, 0x4dbf],
|
||||
[0x4e00, 0x9fff],
|
||||
[0xa000, 0xa4cf],
|
||||
[0xac00, 0xd7a3],
|
||||
[0xf900, 0xfaff],
|
||||
[0xfe30, 0xfe4f],
|
||||
[0xff00, 0xff60],
|
||||
[0xffe0, 0xffe6],
|
||||
[0x1f300, 0x1f64f],
|
||||
[0x1f900, 0x1f9ff],
|
||||
[0x20000, 0x3fffd],
|
||||
];
|
||||
const ZERO_CELL = /^[\p{M}\u200B-\u200D\u2060\uFE00-\uFE0F\uFEFF]$/u;
|
||||
|
||||
function charWidth(ch: string): number {
|
||||
if (ZERO_CELL.test(ch)) return 0;
|
||||
const cp = ch.codePointAt(0) ?? 0;
|
||||
for (const [lo, hi] of WIDE_RANGES) if (cp >= lo && cp <= hi) return 2;
|
||||
return 1;
|
||||
}
|
||||
|
||||
/** Terminal cells a string occupies — an approximation of xterm's Unicode 6
|
||||
* width table, good enough to tell whether a row reached the right edge. */
|
||||
export function cellWidth(text: string): number {
|
||||
let w = 0;
|
||||
for (const ch of text) w += charWidth(ch);
|
||||
return w;
|
||||
}
|
||||
|
||||
// ── Claude Code's gutter and borders ────────────────────────────────────────
|
||||
|
||||
const BOX_TOP_BOTTOM = /^\s*[╭╰┌└][─━]*[╮╯┐┘]\s*$/;
|
||||
const BOX_ROW = /^(\s*)│(.*)│\s*$/;
|
||||
/** A vertical rule between the borders: a table cell separator, not a box. */
|
||||
const INTERIOR_VERTICAL = /[│┃║]/;
|
||||
const isBoxRow = (l: string) => {
|
||||
const m = BOX_ROW.exec(l);
|
||||
return !!m && !INTERIOR_VERTICAL.test(m[2]);
|
||||
};
|
||||
|
||||
/**
|
||||
* Blanks Claude Code's TUI markers without moving any text: `●` at column 0
|
||||
* and `⎿` at column 2 become spaces (so `rejoinHardWraps` still sees true
|
||||
* columns, and `trimSelection` dedents them away). A selection that is wholly
|
||||
* inside a `╭─╮ │ … │ ╰─╯` box loses the box. Anything less certain — a table
|
||||
* (`├─┼─┤` rows, or body rows alone: `│ a │ b │` has a `│` inside its borders),
|
||||
* a bullet elsewhere, a box mixed with outside text — is left as it is.
|
||||
*
|
||||
* `startCol` is the column the selection began at; when it is not 0 the first
|
||||
* line's column 0 is not the gutter.
|
||||
*/
|
||||
export function stripClaudeGutter(text: string, startCol = 0): string {
|
||||
if (!text) return text;
|
||||
let lines = text.split("\n");
|
||||
|
||||
const content = lines.filter((l) => l.trim() !== "" && !BOX_TOP_BOTTOM.test(l));
|
||||
if (content.length > 0 && content.every(isBoxRow)) {
|
||||
lines = lines
|
||||
.filter((l) => !BOX_TOP_BOTTOM.test(l))
|
||||
.map((l) => l.replace(BOX_ROW, (_m, lead: string, inner: string) => `${lead} ${inner}`.trimEnd()));
|
||||
}
|
||||
|
||||
return lines
|
||||
.map((line, i) => {
|
||||
if (i === 0 && startCol !== 0) return line;
|
||||
if (line.startsWith("● ")) return " " + line.slice(1);
|
||||
if (line.startsWith(" ⎿ ")) return " " + line.slice(3);
|
||||
return line;
|
||||
})
|
||||
.join("\n");
|
||||
}
|
||||
|
||||
// ── Claude Code's hard wraps ────────────────────────────────────────────────
|
||||
|
||||
export interface CopyGeometry {
|
||||
/** Terminal width in columns (`term.cols`). 0 / unknown disables rejoining. */
|
||||
cols: number;
|
||||
/** Column (0-based) where the selection's first row began. */
|
||||
startCol?: number;
|
||||
}
|
||||
|
||||
/** A list marker or Claude prompt glyph, and the spaces after it. */
|
||||
const MARKER = /^([-*+•●❯⎿]|\d{1,3}[.)]) +/;
|
||||
/** A row that starts a border, rule or table line. */
|
||||
const BORDER_START = /^[│┃├┤┌┐└┘╭╮╰╯─━┼]/;
|
||||
/** Two or more spaces between words: column-aligned output, not prose. */
|
||||
const TABULAR = /\S {2,}\S/;
|
||||
/** Claude Code always renders its transcript at least 2 columns in. */
|
||||
const MIN_GUTTER = 2;
|
||||
|
||||
interface Row {
|
||||
text: string;
|
||||
/** Column of the first non-space character. */
|
||||
lead: number;
|
||||
/** Column just past the last character. */
|
||||
end: number;
|
||||
/** Text after the indent. */
|
||||
body: string;
|
||||
/** Column where text after a list marker starts (or `lead` if none). */
|
||||
contentCol: number;
|
||||
hasMarker: boolean;
|
||||
}
|
||||
|
||||
function toRow(text: string, offset: number): Row {
|
||||
const body = text.trimStart();
|
||||
const lead = offset + cellWidth(text.slice(0, text.length - body.length));
|
||||
const marker = body.match(MARKER);
|
||||
return {
|
||||
text,
|
||||
lead,
|
||||
end: offset + cellWidth(text),
|
||||
body,
|
||||
contentCol: lead + (marker ? cellWidth(marker[0]) : 0),
|
||||
hasMarker: !!marker,
|
||||
};
|
||||
}
|
||||
|
||||
const lastToken = (s: string) => s.slice(s.lastIndexOf(" ") + 1);
|
||||
const firstToken = (s: string) => s.split(" ", 1)[0];
|
||||
/** First / last *code point* — an astral character (CJK Ext-B, most emoji) is two UTF-16 units. */
|
||||
const firstChar = (s: string) => (s ? String.fromCodePoint(s.codePointAt(0)!) : "");
|
||||
const lastChar = (s: string) => Array.from(s.slice(-2)).pop() ?? "";
|
||||
const isWide = (ch: string) => !!ch && charWidth(ch) === 2;
|
||||
|
||||
/**
|
||||
* Re-joins rows that Claude Code broke to fit the terminal width.
|
||||
*
|
||||
* A break is undone only when all of these hold — each is a property every
|
||||
* Claude wrap has, so together they are conservative:
|
||||
* - both rows have text, and the next row does not start a list item or a
|
||||
* border/table line;
|
||||
* - the next row is indented exactly to where the logical line's text starts
|
||||
* (the gutter, or the hanging indent after `- ` / `1. `), and at least 2
|
||||
* columns — so column-0 shell output is never touched;
|
||||
* - the previous row is not column-aligned (no double spaces between words);
|
||||
* - the next row's first word would *not* have fitted on the previous row,
|
||||
* i.e. the wrapper had no choice but to break there.
|
||||
*
|
||||
* Joined with a space (Claude drops the space it broke at), except when a long
|
||||
* token such as a URL was split mid-token (see `splitMidToken`) or the break
|
||||
* is between two wide (CJK) characters.
|
||||
*/
|
||||
export function rejoinHardWraps(text: string, { cols, startCol = 0 }: CopyGeometry): string {
|
||||
if (!text || !(cols > 0)) return text;
|
||||
const rows = text.split("\n").map((t, i) => toRow(t, i === 0 ? startCol : 0));
|
||||
if (rows.length < 2) return text;
|
||||
|
||||
const out: string[] = [rows[0].text];
|
||||
let head = rows[0];
|
||||
// A first row that began mid-row: what was left of it is unknown.
|
||||
let headPartial = startCol > 0 && !rows[0].hasMarker;
|
||||
|
||||
for (let i = 1; i < rows.length; i++) {
|
||||
const prev = rows[i - 1];
|
||||
const cur = rows[i];
|
||||
const joiner = joinerFor(prev, cur, head, headPartial, cols);
|
||||
if (joiner === null) {
|
||||
out.push(cur.text);
|
||||
head = cur;
|
||||
headPartial = false;
|
||||
} else {
|
||||
out[out.length - 1] += joiner + cur.body;
|
||||
}
|
||||
}
|
||||
return out.join("\n");
|
||||
}
|
||||
|
||||
function joinerFor(prev: Row, cur: Row, head: Row, headPartial: boolean, cols: number): string | null {
|
||||
if (!prev.body || !cur.body) return null;
|
||||
if (cur.hasMarker || BORDER_START.test(cur.body)) return null;
|
||||
if (cur.lead < MIN_GUTTER) return null;
|
||||
if (headPartial ? cur.lead > head.lead : cur.lead !== head.contentCol) return null;
|
||||
|
||||
const prevText = prev === head && prev.hasMarker ? prev.body.replace(MARKER, "") : prev.body;
|
||||
if (TABULAR.test(prevText)) return null;
|
||||
|
||||
// Claude's echo of a submitted prompt (`❯ …`) wraps one column short of
|
||||
// its replies — measured on 2.1.316 at 70 columns: rows end at 69, and a
|
||||
// long token is split at column 69.
|
||||
const width = head.body.startsWith("❯") ? cols - 1 : cols;
|
||||
if (prev.end > width) return null;
|
||||
const next = firstToken(cur.body);
|
||||
if (prev.end + 1 + cellWidth(next) <= width) return null;
|
||||
|
||||
if (isWide(lastChar(prev.body)) && isWide(firstChar(cur.body))) return "";
|
||||
return prev.end === width && splitMidToken(prev, cur, next, width) ? "" : " ";
|
||||
}
|
||||
|
||||
/** The start of a URL: a token that begins here was not cut off mid-token. */
|
||||
const URL_START = /^[a-z][a-z0-9+.-]*:\/\//i;
|
||||
|
||||
/**
|
||||
* Did Claude break a single long token at the edge of `prev` (so the rows
|
||||
* join with no space), rather than moving the next word down whole?
|
||||
*
|
||||
* Ink's wrap-ansi splits a word only when it is wider than the line, so the
|
||||
* combined token must not fit in the line's available width. That alone
|
||||
* cannot tell `the` + a 66-cell URL (two words; the URL fits on a row and was
|
||||
* moved down) from one 69-cell token split after `the` — the lengths are the
|
||||
* same. So: a next-row token that *starts a URL* (`scheme://`) was moved down
|
||||
* whole, unless one of the two pieces fills its entire row, which only the
|
||||
* middle piece of a token longer than a line does.
|
||||
*/
|
||||
function splitMidToken(prev: Row, cur: Row, next: string, width: number): boolean {
|
||||
const tail = cellWidth(lastToken(prev.body));
|
||||
const head = cellWidth(next);
|
||||
if (tail + head <= width - cur.lead) return false; // would have been moved down whole
|
||||
const prevIsPiece = tail >= width - prev.lead;
|
||||
const curIsPiece = cur.end === width && head === cellWidth(cur.body);
|
||||
if (prevIsPiece || curIsPiece) return true;
|
||||
return !URL_START.test(next);
|
||||
}
|
||||
|
||||
// ── the trimmed copy ────────────────────────────────────────────────────────
|
||||
|
||||
/** The full clean-up for a trimmed copy of an xterm selection. */
|
||||
export function cleanSelection(text: string, geometry: CopyGeometry): string {
|
||||
if (!text) return text;
|
||||
const lf = text.replace(/\r\n?/g, "\n"); // getSelection() joins with CRLF on Windows
|
||||
const visible = sanitizeInvisibles(lf);
|
||||
const unframed = stripClaudeGutter(visible, geometry.startCol ?? 0);
|
||||
return trimSelection(rejoinHardWraps(unframed, geometry));
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { describe, expect, it, vi } from "vitest";
|
||||
import { createFilePathLinkProvider } from "./filePathLinkProvider";
|
||||
|
||||
const fakeTerm = (rows: Array<[string, boolean]>) => ({
|
||||
const fakeTerm = (rows: Array<[string, boolean]>, cols?: number) => ({
|
||||
cols,
|
||||
buffer: {
|
||||
active: {
|
||||
getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] },
|
||||
@@ -40,6 +41,29 @@ describe("createFilePathLinkProvider", () => {
|
||||
expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } });
|
||||
});
|
||||
|
||||
it("links a long /tmp path that Claude Code hard-wrapped, from either row", () => {
|
||||
// Captured from Claude Code at 70 columns: the path is broken mid-word and
|
||||
// the rest drawn two cells in on the next row, with no `isWrapped`.
|
||||
const rows: Array<[string, boolean]> = [
|
||||
["\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e", false],
|
||||
[" 039e2a07562/scratchpad/notes.md ok", false],
|
||||
];
|
||||
const onOpen = vi.fn();
|
||||
const provider = createFilePathLinkProvider(fakeTerm(rows, 70), onOpen, () => true);
|
||||
for (const row of [1, 2]) {
|
||||
const links = vi.fn();
|
||||
provider.provideLinks(row, links);
|
||||
const [list] = links.mock.calls[0];
|
||||
expect(list).toHaveLength(1);
|
||||
expect(list[0].range).toEqual({ start: { x: 18, y: 1 }, end: { x: 33, y: 2 } });
|
||||
list[0].activate(new MouseEvent("click"), list[0].text);
|
||||
}
|
||||
const scratch = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
|
||||
expect(onOpen).toHaveBeenCalledTimes(2);
|
||||
expect(onOpen).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: scratch }));
|
||||
expect(onOpen).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: scratch }));
|
||||
});
|
||||
|
||||
it("answers undefined for a row with nothing", () => {
|
||||
const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true);
|
||||
const links = vi.fn();
|
||||
|
||||
@@ -17,7 +17,7 @@ export interface FilePathHover {
|
||||
}
|
||||
|
||||
export function createFilePathLinkProvider(
|
||||
term: Pick<Terminal, "buffer">,
|
||||
term: Pick<Terminal, "buffer" | "cols">,
|
||||
onOpen: (match: FilePathMatch) => void,
|
||||
gate: (event: MouseEvent) => boolean,
|
||||
hover?: FilePathHover,
|
||||
@@ -27,7 +27,9 @@ export function createFilePathLinkProvider(
|
||||
const row = bufferLineNumber - 1;
|
||||
const line = term.buffer.active.getLine(row);
|
||||
if (!line) return callback(undefined);
|
||||
const joined = joinWrappedRows(term.buffer.active, row);
|
||||
// `cols` lets the join bridge Claude Code's own mid-word wraps, which is
|
||||
// how a long `/tmp/claude-<uid>/...` path reaches the screen.
|
||||
const joined = joinWrappedRows(term.buffer.active, row, { cols: term.cols });
|
||||
// `offsetToCell` has no row to map onto when nothing was joined.
|
||||
if (joined.rowStarts.length === 0) return callback(undefined);
|
||||
const matches = findFilePathLinks(joined.text);
|
||||
|
||||
@@ -1,59 +1,76 @@
|
||||
import { describe, it, expect, vi, beforeEach } from "vitest";
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { decodeOsc52 } from "./osc52";
|
||||
|
||||
/**
|
||||
* Tests the OSC 52 clipboard parsing logic used in TerminalView.
|
||||
* Extracted here to validate the decode/write path independently.
|
||||
*/
|
||||
|
||||
// Mirrors the handler registered in TerminalView.tsx
|
||||
function handleOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null;
|
||||
try {
|
||||
return atob(payload);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
/** Base64 of the UTF-8 bytes of `text` — what the container shim
|
||||
* (`printf '%s' "$data" | base64`) and Claude Code's own OSC 52 both send. */
|
||||
function utf8Base64(text: string): string {
|
||||
return Buffer.from(text, "utf8").toString("base64");
|
||||
}
|
||||
|
||||
describe("OSC 52 clipboard handler", () => {
|
||||
describe("decodeOsc52", () => {
|
||||
it("decodes a valid clipboard write sequence", () => {
|
||||
// "c;BASE64" where BASE64 encodes "https://example.com"
|
||||
const encoded = btoa("https://example.com");
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe("https://example.com");
|
||||
expect(decodeOsc52(`c;${utf8Base64("https://example.com")}`)).toBe("https://example.com");
|
||||
});
|
||||
|
||||
it("decodes multi-line content", () => {
|
||||
const text = "line1\nline2\nline3";
|
||||
const encoded = btoa(text);
|
||||
const result = handleOsc52(`c;${encoded}`);
|
||||
expect(result).toBe(text);
|
||||
expect(decodeOsc52(`c;${utf8Base64(text)}`)).toBe(text);
|
||||
});
|
||||
|
||||
it("handles primary selection target (p)", () => {
|
||||
const encoded = btoa("selected text");
|
||||
const result = handleOsc52(`p;${encoded}`);
|
||||
expect(result).toBe("selected text");
|
||||
it("handles primary selection target (p) and an empty target", () => {
|
||||
expect(decodeOsc52(`p;${utf8Base64("selected text")}`)).toBe("selected text");
|
||||
expect(decodeOsc52(`;${utf8Base64("selected text")}`)).toBe("selected text");
|
||||
});
|
||||
|
||||
// The user-visible bug: `atob` returns one char per *byte*, so every
|
||||
// multi-byte UTF-8 sequence came out as Latin-1 mojibake (’ → "â\x80\x99",
|
||||
// é → "é"), which Windows apps render as `’`.
|
||||
it("decodes UTF-8 multi-byte text instead of mojibaking it", () => {
|
||||
const text = "it’s café — naïve 日本語 👍🏽 👨\u200D👩\u200D👧";
|
||||
const decoded = decodeOsc52(`c;${utf8Base64(text)}`);
|
||||
expect(decoded).toBe(text);
|
||||
expect(decoded).not.toContain("Ã");
|
||||
expect(decoded).not.toContain("â");
|
||||
});
|
||||
|
||||
// One bad or truncated byte must not re-mojibake the whole payload: only
|
||||
// the bad bytes become U+FFFD, the valid UTF-8 around them is kept.
|
||||
it("replaces only invalid bytes with U+FFFD, keeping the valid UTF-8", () => {
|
||||
const bytes = Buffer.concat([Buffer.from("it’s ", "utf8"), Buffer.from([0xff]), Buffer.from(" café", "utf8")]);
|
||||
expect(decodeOsc52(`c;${bytes.toString("base64")}`)).toBe("it’s \uFFFD café");
|
||||
});
|
||||
|
||||
it("keeps the text before a multi-byte sequence truncated at the end", () => {
|
||||
const full = Buffer.from("naïve ’", "utf8");
|
||||
const truncated = full.subarray(0, full.length - 1); // drop the last byte of ’
|
||||
expect(decodeOsc52(`c;${truncated.toString("base64")}`)).toBe("naïve \uFFFD");
|
||||
});
|
||||
|
||||
it("does not guess Latin-1 for a payload that is not UTF-8", () => {
|
||||
// Every sender this app sees sends UTF-8; a Latin-1 é is just a bad byte.
|
||||
const latin1 = Buffer.from([0x63, 0x61, 0x66, 0xe9]).toString("base64");
|
||||
expect(decodeOsc52(`c;${latin1}`)).toBe("caf\uFFFD");
|
||||
});
|
||||
|
||||
it("tolerates whitespace/newlines inside the base64 (base64 without -w0)", () => {
|
||||
const b64 = utf8Base64("x".repeat(100));
|
||||
const wrapped = b64.slice(0, 76) + "\n" + b64.slice(76);
|
||||
expect(decodeOsc52(`c;${wrapped}`)).toBe("x".repeat(100));
|
||||
});
|
||||
|
||||
it("returns null for clipboard read request (?)", () => {
|
||||
expect(handleOsc52("c;?")).toBe(null);
|
||||
expect(decodeOsc52("c;?")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for missing semicolon", () => {
|
||||
expect(handleOsc52("invalid")).toBe(null);
|
||||
expect(decodeOsc52("invalid")).toBe(null);
|
||||
});
|
||||
|
||||
it("returns null for invalid base64", () => {
|
||||
expect(handleOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
expect(decodeOsc52("c;!!!not-base64!!!")).toBe(null);
|
||||
});
|
||||
|
||||
it("handles empty payload after selection target", () => {
|
||||
// btoa("") = ""
|
||||
const result = handleOsc52("c;");
|
||||
expect(result).toBe("");
|
||||
expect(decodeOsc52("c;")).toBe("");
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,36 @@
|
||||
/**
|
||||
* Decodes the payload of an OSC 52 clipboard *write* (`Pc ; Pd`, where `Pd` is
|
||||
* base64) into the text to put on the clipboard.
|
||||
*
|
||||
* The base64 carries **bytes**, and every sender this app sees — the
|
||||
* container's xclip/xsel/pbcopy shim (`printf '%s' | base64`) and Claude Code's
|
||||
* own OSC 52 copy — sends UTF-8. `atob` alone returns one UTF-16 code unit per
|
||||
* byte, so it turned `’` into `â\x80\x99` and `é` into `é` (the `’` mojibake
|
||||
* users saw after pasting). The bytes are therefore decoded as UTF-8,
|
||||
* non-fatally: a bad or truncated byte becomes U+FFFD and only that byte is
|
||||
* lost. There is deliberately no Latin-1 fallback — re-reading the *whole*
|
||||
* payload as Latin-1 because of one bad byte brought the mojibake back for
|
||||
* everything else, and no sender here emits Latin-1.
|
||||
*
|
||||
* Returns `null` for a read request (`?`), a malformed sequence, or invalid
|
||||
* base64 — the caller writes nothing in those cases. Pure function.
|
||||
*/
|
||||
const UTF8 = new TextDecoder("utf-8");
|
||||
|
||||
export function decodeOsc52(data: string): string | null {
|
||||
const idx = data.indexOf(";");
|
||||
if (idx === -1) return null;
|
||||
const payload = data.substring(idx + 1);
|
||||
if (payload === "?") return null; // clipboard read request, not supported
|
||||
|
||||
let binary: string;
|
||||
try {
|
||||
binary = atob(payload); // tolerates ASCII whitespace, as base64 without -w0 emits
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i);
|
||||
return UTF8.decode(bytes);
|
||||
}
|
||||
@@ -96,9 +96,27 @@ Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
|
||||
}, [openTerminal, project.id, project.name, fail]);
|
||||
|
||||
/**
|
||||
* Open a shell tab and type `command` into it. Used for [Resume] and for the
|
||||
* capability drawer's "Manage in terminal" — the backend has no
|
||||
* run-a-command entry point, so we drive the shell we just opened.
|
||||
* Open a Claude tab on a saved conversation. A real Claude session, not a
|
||||
* shell with `claude --resume` typed into it: the tab's session type is
|
||||
* what Shift+Enter, the status-bar hint and Send to agent key off, so a
|
||||
* resumed conversation in a "bash" tab submitted on Shift+Enter.
|
||||
*/
|
||||
const resumeClaudeSession = useCallback(
|
||||
async (claudeSessionId: string, sessionLabel?: string) => {
|
||||
try {
|
||||
return await openTerminal(project.id, project.name, "claude", sessionLabel, claudeSessionId);
|
||||
} catch (e) {
|
||||
fail("Could not resume that session", e);
|
||||
return null;
|
||||
}
|
||||
},
|
||||
[openTerminal, project.id, project.name, fail],
|
||||
);
|
||||
|
||||
/**
|
||||
* Open a shell tab and type `command` into it. Used for the capability
|
||||
* drawer's "Manage in terminal" — the backend has no run-a-command entry
|
||||
* point, so we drive the shell we just opened.
|
||||
*/
|
||||
const openTerminalWithCommand = useCallback(
|
||||
async (command: string, sessionLabel?: string) => {
|
||||
@@ -170,6 +188,7 @@ Reset again, or remove ${resetLeftoverPronoun(outcome)} manually once stopped.`,
|
||||
handleBackup,
|
||||
openClaudeTerminal,
|
||||
openShell,
|
||||
resumeClaudeSession,
|
||||
openTerminalWithCommand,
|
||||
};
|
||||
}
|
||||
@@ -81,3 +81,26 @@ describe("useTerminal input ordering", () => {
|
||||
expect(bySession).toContainEqual(["b", "2"]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("useTerminal open", () => {
|
||||
it("opens a resumed conversation as a Claude session", async () => {
|
||||
// Resume used to open a "bash" tab and type `claude --resume` into it, and
|
||||
// Shift+Enter keys off the tab's type — so it submitted instead of adding
|
||||
// a newline. The id has to travel to the backend *and* the tab has to be
|
||||
// registered as "claude".
|
||||
const commands = await import("../lib/tauri-commands");
|
||||
const { useAppState } = await import("../store/appState");
|
||||
const { result } = renderHook(() => useTerminal());
|
||||
|
||||
const id = await result.current.open("p1", "proj", "claude", "my chat", "e13d312d-2f38");
|
||||
|
||||
expect(commands.openTerminalSession).toHaveBeenCalledWith(
|
||||
"p1",
|
||||
id,
|
||||
"claude",
|
||||
"my chat",
|
||||
"e13d312d-2f38",
|
||||
);
|
||||
expect(useAppState.getState().sessions.find((s) => s.id === id)?.sessionType).toBe("claude");
|
||||
});
|
||||
});
|
||||
@@ -97,9 +97,15 @@ export function useTerminal() {
|
||||
);
|
||||
|
||||
const open = useCallback(
|
||||
async (projectId: string, projectName: string, sessionType: "claude" | "bash" = "claude", sessionName?: string) => {
|
||||
async (
|
||||
projectId: string,
|
||||
projectName: string,
|
||||
sessionType: "claude" | "bash" = "claude",
|
||||
sessionName?: string,
|
||||
resumeSessionId?: string,
|
||||
) => {
|
||||
const sessionId = crypto.randomUUID();
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName);
|
||||
await commands.openTerminalSession(projectId, sessionId, sessionType, sessionName, resumeSessionId);
|
||||
addSession({ id: sessionId, projectId, projectName, sessionType, sessionName: sessionName ?? null });
|
||||
return sessionId;
|
||||
},
|
||||
|
||||
@@ -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+)+$/;
|
||||
|
||||
@@ -65,8 +65,14 @@ export const awsSsoRefresh = (projectId: string) =>
|
||||
invoke<void>("aws_sso_refresh", { projectId });
|
||||
|
||||
// Terminal
|
||||
export const openTerminalSession = (projectId: string, sessionId: string, sessionType?: string, sessionName?: string) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName });
|
||||
export const openTerminalSession = (
|
||||
projectId: string,
|
||||
sessionId: string,
|
||||
sessionType?: string,
|
||||
sessionName?: string,
|
||||
resumeSessionId?: string,
|
||||
) =>
|
||||
invoke<void>("open_terminal_session", { projectId, sessionId, sessionType, sessionName, resumeSessionId });
|
||||
export const terminalInput = (sessionId: string, data: number[]) =>
|
||||
invoke<void>("terminal_input", { sessionId, data });
|
||||
export const terminalResize = (sessionId: string, cols: number, rows: number) =>
|
||||
@@ -168,8 +174,6 @@ export const runDockerInstall = () => invoke<void>("run_docker_install");
|
||||
// Container introspection — sessions
|
||||
export const listClaudeSessions = (projectId: string) =>
|
||||
invoke<ClaudeSession[]>("list_claude_sessions", { projectId });
|
||||
export const resumeSessionCommand = (projectId: string, sessionId: string) =>
|
||||
invoke<string>("resume_session_command", { projectId, sessionId });
|
||||
|
||||
// Container introspection — capabilities
|
||||
export const listContainerCapabilities = (projectId: string) =>
|
||||
|
||||
@@ -146,6 +146,62 @@ describe("UrlDetector", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("UrlDetector — trailing punctuation", () => {
|
||||
it("leaves a full stop after the link out of the candidate", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `Open ${SIGN_IN_URL}. Then paste the code.\r\n`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("leaves wrapping brackets out of the candidate", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `[Sign in](${SIGN_IN_URL}).\r\nmore\r\n`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("trims a link that ends the buffer once it is confirmed", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
|
||||
feed(d, `See ${SIGN_IN_URL}.`);
|
||||
|
||||
expect(seen).toEqual([SIGN_IN_URL]);
|
||||
});
|
||||
|
||||
it("still waits for more when the buffer ends on a full stop", () => {
|
||||
// A `.` at the end of a chunk may be the middle of the URL (`file.tar.gz`)
|
||||
// with the rest still in flight. Whether to wait is decided by where the
|
||||
// raw text ran to, not by where the trimmed URL ends.
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => 1000);
|
||||
const head = "https://example.com/" + "a".repeat(90) + ".";
|
||||
const url = head + "tar.gz";
|
||||
|
||||
d.feed(enc.encode(head));
|
||||
vi.advanceTimersByTime(400); // scanned, but inside the confirmation wait
|
||||
d.feed(enc.encode("tar.gz\r\nnext\r\n"));
|
||||
vi.advanceTimersByTime(2000);
|
||||
|
||||
expect(seen).toEqual([url]);
|
||||
});
|
||||
|
||||
it("measures the length threshold on the trimmed URL", () => {
|
||||
const seen: string[] = [];
|
||||
const d = new UrlDetector((u) => seen.push(u), () => COLS);
|
||||
const url = "https://example.com/" + "a".repeat(79); // 99 characters
|
||||
|
||||
feed(d, `(${url}).\r\nmore\r\n`);
|
||||
|
||||
expect(seen).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("usableLink", () => {
|
||||
// Ports `usable_sign_in_link` from `commands/auth_token_commands.rs` — a junk
|
||||
// filter, not the security decision. `sanitizeRelayUrl` is still what stands
|
||||
|
||||
@@ -65,6 +65,7 @@
|
||||
*/
|
||||
|
||||
import { extendsUrl } from "./urlRelay";
|
||||
import { findUrls } from "./urlMatch";
|
||||
|
||||
const ANSI_RE =
|
||||
/\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g;
|
||||
@@ -297,19 +298,19 @@ export class UrlDetector {
|
||||
// still re-validated by `sanitizeRelayUrl` before it can reach the opener;
|
||||
// stopping the match early only means the legitimate prefix survives
|
||||
// instead of the whole candidate being thrown away.
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const urlRe = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g;
|
||||
let m: RegExpExecArray | null;
|
||||
|
||||
while ((m = urlRe.exec(flat)) !== null) {
|
||||
const url = m[0];
|
||||
|
||||
//
|
||||
// `findUrls` also trims trailing prose punctuation and wrapping brackets
|
||||
// (`…/authorize?….` → no `.`), the same rule the terminal underline uses
|
||||
// — see `urlMatch.ts`. It reports the raw run's end separately, because
|
||||
// that, not the trimmed URL's end, is what says whether the text may
|
||||
// still be growing: a chunk ending `…/file.` may be followed by `tar.gz`.
|
||||
for (const { url, rawEnd } of findUrls(flat)) {
|
||||
// 4. Filter by length
|
||||
if (url.length < MIN_URL_LENGTH) continue;
|
||||
|
||||
// 5. If the match extends to the very end of the flattened string,
|
||||
// more chunks may still be arriving — defer emission.
|
||||
if (m.index + url.length >= flat.length) {
|
||||
if (rawEnd >= flat.length) {
|
||||
this.pendingUrl = url;
|
||||
this.confirmTimer = setTimeout(() => {
|
||||
this.confirmTimer = null;
|
||||
|
||||
@@ -0,0 +1,127 @@
|
||||
import { describe, it, expect } from "vitest";
|
||||
import { TERMINAL_URL_REGEX, findUrls, trimUrlCandidate } from "./urlMatch";
|
||||
|
||||
/**
|
||||
* Every URL `WebLinksAddon` would underline in `line`.
|
||||
*
|
||||
* Mirrors `LinkComputer.computeLink` in `@xterm/addon-web-links`: it rebuilds
|
||||
* the regex with the `g` flag added to whatever flags it was given and
|
||||
* `exec`s in a loop. Run the same way here so the test exercises the regex as
|
||||
* the addon will, not as a one-off `match` would.
|
||||
*/
|
||||
function underlined(line: string): string[] {
|
||||
const rex = new RegExp(TERMINAL_URL_REGEX.source, (TERMINAL_URL_REGEX.flags || "") + "g");
|
||||
const out: string[] = [];
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = rex.exec(line))) out.push(m[0]);
|
||||
return out;
|
||||
}
|
||||
|
||||
describe("TERMINAL_URL_REGEX", () => {
|
||||
it("carries no g or y flag, which the addon adds itself", () => {
|
||||
expect(TERMINAL_URL_REGEX.flags).not.toMatch(/[gy]/);
|
||||
});
|
||||
|
||||
it.each([
|
||||
["See https://example.com/docs.", "https://example.com/docs"],
|
||||
["at https://example.com/docs, then", "https://example.com/docs"],
|
||||
["is it https://example.com/docs?", "https://example.com/docs"],
|
||||
["wow https://example.com/docs!", "https://example.com/docs"],
|
||||
["go to https://example.com/docs: it", "https://example.com/docs"],
|
||||
["https://example.com/docs; and", "https://example.com/docs"],
|
||||
["end https://example.com/docs...", "https://example.com/docs"],
|
||||
["(https://example.com/foo)", "https://example.com/foo"],
|
||||
["(see https://example.com/foo).", "https://example.com/foo"],
|
||||
["[text](https://x.y/z)", "https://x.y/z"],
|
||||
["[text](https://x.y/z).", "https://x.y/z"],
|
||||
["<https://x.example>", "https://x.example"],
|
||||
["[https://x.example/a]", "https://x.example/a"],
|
||||
["{https://x.example/a}", "https://x.example/a"],
|
||||
["\"https://x.example/a\"", "https://x.example/a"],
|
||||
["'https://x.example/a'", "https://x.example/a"],
|
||||
["`https://x.example/a`", "https://x.example/a"],
|
||||
["**https://x.example/a**", "https://x.example/a"],
|
||||
["https://x.example/a/", "https://x.example/a/"],
|
||||
])("leaves the trailing punctuation off %j", (line, url) => {
|
||||
expect(underlined(line)).toEqual([url]);
|
||||
});
|
||||
|
||||
it.each([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
"https://example.com/search?q=a.b&x=1",
|
||||
"https://example.com/a,b/c",
|
||||
"https://example.com/path/file.tar.gz",
|
||||
"https://example.com/#/route",
|
||||
"https://example.com/a?arr[]=1&arr[]=2",
|
||||
"http://[::1]:8080/x",
|
||||
"https://example.com/a)b",
|
||||
"https://example.com/a(b",
|
||||
"https://example.com/api/{id}/x",
|
||||
// OAuth state and code_challenge are base64url, which can end in `_`, `-`
|
||||
// or `=` padding. None of those may be trimmed.
|
||||
"https://claude.ai/oauth/authorize?code=true&state=abc-_",
|
||||
"https://claude.ai/oauth/authorize?code_challenge=abc-",
|
||||
"https://example.com/x?t=YWJj==",
|
||||
"https://example.com/~user",
|
||||
])("keeps %j whole", (url) => {
|
||||
expect(underlined(url)).toEqual([url]);
|
||||
expect(underlined(`see ${url} now`)).toEqual([url]);
|
||||
});
|
||||
|
||||
it("keeps a balanced paren and drops the one that wraps the link", () => {
|
||||
expect(underlined("(https://en.wikipedia.org/wiki/Foo_(bar))")).toEqual([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
]);
|
||||
expect(underlined("[Foo](https://en.wikipedia.org/wiki/Foo_(bar)).")).toEqual([
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
]);
|
||||
});
|
||||
|
||||
it("finds several links on one line", () => {
|
||||
expect(underlined("a https://a.example/1, b (https://b.example/2).")).toEqual([
|
||||
"https://a.example/1",
|
||||
"https://b.example/2",
|
||||
]);
|
||||
});
|
||||
|
||||
it("matches nothing after a bare scheme", () => {
|
||||
expect(underlined("https://.")).toEqual([]);
|
||||
expect(underlined("https:// x")).toEqual([]);
|
||||
});
|
||||
|
||||
it("still stops at control characters", () => {
|
||||
expect(underlined("https://example.com/a\x1bb")).toEqual(["https://example.com/a"]);
|
||||
expect(underlined("https://example.com/a\x00b")).toEqual(["https://example.com/a"]);
|
||||
expect(underlined("https://example.com/a\x7fb")).toEqual(["https://example.com/a"]);
|
||||
});
|
||||
|
||||
it("does not hang on a long run of punctuation", () => {
|
||||
const line = "https://example.com/" + "(.".repeat(5000) + ")".repeat(5000);
|
||||
const t0 = Date.now();
|
||||
underlined(line);
|
||||
expect(Date.now() - t0).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
describe("findUrls", () => {
|
||||
it("returns the trimmed URL and the extent of the raw run", () => {
|
||||
expect(findUrls("See https://example.com/docs. next")).toEqual([
|
||||
{ url: "https://example.com/docs", index: 4, rawEnd: 29 },
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
||||
describe("trimUrlCandidate", () => {
|
||||
it("trims trailing punctuation and unbalanced closers", () => {
|
||||
expect(trimUrlCandidate("https://example.com/docs.")).toBe("https://example.com/docs");
|
||||
expect(trimUrlCandidate("https://example.com/foo).")).toBe("https://example.com/foo");
|
||||
expect(trimUrlCandidate("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe(
|
||||
"https://en.wikipedia.org/wiki/Foo_(bar)",
|
||||
);
|
||||
});
|
||||
|
||||
it("returns null for something that is not a URL at all", () => {
|
||||
expect(trimUrlCandidate("https://.")).toBeNull();
|
||||
expect(trimUrlCandidate("not a url")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,93 @@
|
||||
/**
|
||||
* Where a plain-text URL in terminal output starts and ends.
|
||||
*
|
||||
* The old pattern, `https?:\/\/[^\s'"`<>\x00-\x20\x7f]+`, ran to the next
|
||||
* whitespace, so the prose around a link came with it: `See https://x/docs.`
|
||||
* underlined and opened `https://x/docs.`, `(https://x/a)` opened
|
||||
* `https://x/a)`, and a markdown link `[t](https://x/a)` the same. This uses
|
||||
* the rules GitHub's autolinker and linkify apply:
|
||||
*
|
||||
* - A URL never *ends* in `.` `,` `;` `:` `!` `?` `*` — those are sentence
|
||||
* punctuation (and markdown emphasis) when they come last. In the middle
|
||||
* (`a.b`, `a,b`, `?q=`) they are kept.
|
||||
* - A closing `)` `]` `}` is kept only when it closes a bracket opened inside
|
||||
* the URL — `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren,
|
||||
* `(https://x/a)` loses the one that wraps it. A closer followed by more URL
|
||||
* (`/a)b`) is kept either way, since it cannot be wrapping anything.
|
||||
* - `-` `_` `~` `=` `/` are never trimmed: OAuth `state` and
|
||||
* `code_challenge` are base64url, which can end in any of the first two,
|
||||
* and `=` is base64 padding.
|
||||
*
|
||||
* It is a single regex rather than a match-then-trim because `WebLinksAddon`
|
||||
* underlines exactly what its regex matches: trimming only the URL handed to
|
||||
* the click handler would still paint the `.` as part of the link. The
|
||||
* bracket rule is one level deep, which covers every real case (Wikipedia
|
||||
* disambiguations, IPv6 hosts, `a[]=` query keys) without needing a parser.
|
||||
*
|
||||
* The set of characters a URL may contain at all is unchanged — whitespace,
|
||||
* quotes, backtick, `<` `>`, the whole C0 range and DEL still end a match —
|
||||
* and nothing here is a security decision: `sanitizeRelayUrl` still decides
|
||||
* what may be opened.
|
||||
*/
|
||||
|
||||
/** Any character a URL may contain, minus brackets (handled separately). */
|
||||
const C = "[^\\s'\"`<>()\\[\\]{}\\x00-\\x20\\x7f]";
|
||||
/** A character a URL may end with: `C` minus trailing punctuation. */
|
||||
const END_CHAR = "[^\\s'\"`<>()\\[\\]{}\\x00-\\x20\\x7f.,;:!?*]";
|
||||
/** A one-level balanced bracket group: `(bar)`, `[::1]`, `[]`, `{id}`. */
|
||||
const GROUP = `\\(${C}*\\)|\\[${C}*\\]|\\{${C}*\\}`;
|
||||
/** An opener that starts no balanced group (`/a(b`), so it is plain text. */
|
||||
const LONE_OPEN = `\\((?!${C}*\\))|\\[(?!${C}*\\])|\\{(?!${C}*\\})`;
|
||||
/** A closer, allowed mid-URL; the final position excludes it unless balanced. */
|
||||
const CLOSE = "[)\\]}]";
|
||||
|
||||
const URL_SOURCE = `https?:\\/\\/(?:${GROUP}|${C}|${LONE_OPEN}|${CLOSE})*(?:${GROUP}|${END_CHAR})`;
|
||||
|
||||
/**
|
||||
* The regex handed to `WebLinksAddon` as `urlRegex`.
|
||||
*
|
||||
* No flags: `LinkComputer.computeLink` rebuilds it with `g` appended to
|
||||
* whatever flags it has, so giving it `g` here would be redundant and `y`
|
||||
* would break the scan.
|
||||
*/
|
||||
export const TERMINAL_URL_REGEX = new RegExp(URL_SOURCE);
|
||||
|
||||
const ANCHORED = new RegExp(`^(?:${URL_SOURCE})`);
|
||||
|
||||
/**
|
||||
* The maximal run of URL characters starting at a scheme — the old pattern.
|
||||
* Still useful for knowing how far the raw text ran, e.g. whether it reached
|
||||
* the end of a buffer that may still be growing.
|
||||
*/
|
||||
// eslint-disable-next-line no-control-regex
|
||||
const RAW_RUN = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g;
|
||||
|
||||
/**
|
||||
* The URL at the start of `candidate`, with trailing punctuation and
|
||||
* unbalanced closers removed; `null` if nothing URL-shaped is left.
|
||||
*/
|
||||
export function trimUrlCandidate(candidate: string): string | null {
|
||||
const m = ANCHORED.exec(candidate);
|
||||
return m ? m[0] : null;
|
||||
}
|
||||
|
||||
export interface UrlMatch {
|
||||
/** The URL as it should be shown and opened. */
|
||||
url: string;
|
||||
/** Where it starts in the input. */
|
||||
index: number;
|
||||
/** Where the raw run of URL characters ended, trailing punctuation included. */
|
||||
rawEnd: number;
|
||||
}
|
||||
|
||||
/** Every URL in `text`, trimmed, with the extent of the raw run it came from. */
|
||||
export function findUrls(text: string): UrlMatch[] {
|
||||
const out: UrlMatch[] = [];
|
||||
RAW_RUN.lastIndex = 0;
|
||||
let m: RegExpExecArray | null;
|
||||
while ((m = RAW_RUN.exec(text)) !== null) {
|
||||
const url = trimUrlCandidate(m[0]);
|
||||
if (url) out.push({ url, index: m.index, rawEnd: m.index + m[0].length });
|
||||
}
|
||||
return out;
|
||||
}
|
||||
@@ -10,7 +10,7 @@ const buffer = (rows: Array<[string, boolean]>): RowSource => ({
|
||||
describe("joinWrappedRows", () => {
|
||||
it("returns a single unwrapped row as-is", () => {
|
||||
const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0);
|
||||
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] });
|
||||
expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0], rowIndents: [0] });
|
||||
});
|
||||
|
||||
it("walks up to the row that started the wrap and down through continuations", () => {
|
||||
@@ -38,6 +38,120 @@ describe("joinWrappedRows", () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe("joinWrappedRows on a long chain", () => {
|
||||
const cols = 40;
|
||||
/** 1000 hard-wrapped rows: row 0 at column 0, every later row indented 2. */
|
||||
const chainRows = (): Array<[string, boolean]> => {
|
||||
const rows: Array<[string, boolean]> = [["/" + "a".repeat(cols - 1), false]];
|
||||
for (let i = 1; i < 1000; i++) rows.push([" " + String(i).padStart(4, "0") + "b".repeat(cols - 6), false]);
|
||||
return rows;
|
||||
};
|
||||
const counting = (rows: Array<[string, boolean]>) => {
|
||||
const b = buffer(rows);
|
||||
let reads = 0;
|
||||
return {
|
||||
reads: () => reads,
|
||||
source: { getLine: (y: number) => (reads++, b.getLine(y)) } as RowSource,
|
||||
};
|
||||
};
|
||||
|
||||
it("always includes the hovered row, deep in a chain longer than the budget", () => {
|
||||
const rows = chainRows();
|
||||
for (const row of [45, 300, 999]) {
|
||||
const j = joinWrappedRows(buffer(rows), row, { cols });
|
||||
expect(j.firstRow).toBeLessThanOrEqual(row);
|
||||
expect(j.firstRow + j.rowStarts.length).toBeGreaterThan(row);
|
||||
expect(j.text).toContain(String(row).padStart(4, "0"));
|
||||
expect(j.text.length).toBeLessThanOrEqual(MAX_JOINED_LENGTH);
|
||||
}
|
||||
});
|
||||
|
||||
it("drops a continuation's indent when the window starts mid-chain", () => {
|
||||
const j = joinWrappedRows(buffer(chainRows()), 500, { cols });
|
||||
expect(j.firstRow).toBeGreaterThan(0);
|
||||
expect(j.rowIndents[0]).toBe(2);
|
||||
expect(j.text.startsWith(" ")).toBe(false);
|
||||
});
|
||||
|
||||
it("reads a bounded number of rows, not the whole chain above", () => {
|
||||
const c = counting(chainRows());
|
||||
joinWrappedRows(c.source, 999, { cols });
|
||||
// The window holds at most MAX_JOINED_LENGTH / (cols - 2) rows; each is
|
||||
// read a few times by the walk. Reading all 1000 rows would be far more.
|
||||
expect(c.reads()).toBeLessThan(1000);
|
||||
});
|
||||
});
|
||||
|
||||
/**
|
||||
* Claude Code wraps its own output: a word longer than the line is broken
|
||||
* mid-word and the rest is drawn on the next row with cursor moves, so xterm
|
||||
* never marks that row `isWrapped`. Captured from Claude Code at 70 columns
|
||||
* (bytes: `...9329-e\r\x1b[2C\x1b[1B039e2a...`).
|
||||
*/
|
||||
const CLAUDE_ROW_1 = "\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e";
|
||||
const CLAUDE_ROW_2 = " 039e2a07562/scratchpad/notes.md ok";
|
||||
const SCRATCH = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md";
|
||||
|
||||
describe("joinWrappedRows across Claude Code's hard wraps", () => {
|
||||
it("the captured row really does end at the right edge", () => {
|
||||
expect(CLAUDE_ROW_1.length).toBe(70);
|
||||
});
|
||||
|
||||
it("joins a path broken mid-word onto the next row, dropping the indent", () => {
|
||||
const b = buffer([["before", false], [CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false], ["after", false]]);
|
||||
const j = joinWrappedRows(b, 1, { cols: 70 });
|
||||
expect(j.text).toBe(`\u25cf The file is at ${SCRATCH} ok`);
|
||||
expect(j.firstRow).toBe(1);
|
||||
expect(j.rowStarts).toEqual([0, 70]);
|
||||
expect(j.rowIndents).toEqual([0, 2]);
|
||||
// Asked from the continuation row, the same line comes back.
|
||||
expect(joinWrappedRows(b, 2, { cols: 70 })).toEqual(j);
|
||||
// The first character of the continuation is drawn at column 3.
|
||||
expect(offsetToCell(j, 70)).toEqual({ x: 3, y: 3 });
|
||||
});
|
||||
|
||||
it("joins a path broken over three rows", () => {
|
||||
const cols = 30;
|
||||
const path = "/tmp/" + "a".repeat(60) + "/notes.md"; // 74 chars
|
||||
const rows: Array<[string, boolean]> = [
|
||||
[path.slice(0, 30), false],
|
||||
[" " + path.slice(30, 58), false],
|
||||
[" " + path.slice(58), false],
|
||||
];
|
||||
const j = joinWrappedRows(buffer(rows), 2, { cols });
|
||||
expect(j.text).toBe(path);
|
||||
expect(j.rowIndents).toEqual([0, 2, 2]);
|
||||
});
|
||||
|
||||
it("does not glue an ordinary word at the edge to a short word below it", () => {
|
||||
const cols = 40;
|
||||
const row1 = "Run the tests and then go and look at it"; // ends at the edge
|
||||
expect(row1.length).toBe(cols);
|
||||
const b = buffer([[row1, false], [" src/main.rs for details", false]]);
|
||||
expect(joinWrappedRows(b, 0, { cols }).text).toBe(row1);
|
||||
expect(joinWrappedRows(b, 1, { cols }).text).toBe(" src/main.rs for details");
|
||||
});
|
||||
|
||||
it("does not glue two column-0 lines together (continuations are indented)", () => {
|
||||
const cols = 40;
|
||||
const row0 = "/var/log/some/very/long/dir/file-aaa.txt";
|
||||
expect(row0.length).toBe(cols);
|
||||
const b = buffer([[row0, false], ["src/main.rs", false]]);
|
||||
expect(joinWrappedRows(b, 0, { cols }).text).toBe(row0);
|
||||
expect(joinWrappedRows(b, 1, { cols }).text).toBe("src/main.rs");
|
||||
});
|
||||
|
||||
it("does not bridge a row that stops short of the edge", () => {
|
||||
const b = buffer([["/tmp/claude-1000/-workspace/abc", false], ["def/notes.md", false]]);
|
||||
expect(joinWrappedRows(b, 1, { cols: 70 }).text).toBe("def/notes.md");
|
||||
});
|
||||
|
||||
it("does not bridge without knowing the width", () => {
|
||||
const b = buffer([[CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false]]);
|
||||
expect(joinWrappedRows(b, 0).text).toBe(CLAUDE_ROW_1);
|
||||
});
|
||||
});
|
||||
|
||||
describe("offsetToCell", () => {
|
||||
it("maps offsets to 1-based cells on the right row", () => {
|
||||
const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] };
|
||||
|
||||
@@ -1,6 +1,10 @@
|
||||
/**
|
||||
* Joins an xterm buffer row with its wrapped continuations.
|
||||
*
|
||||
* Two kinds: xterm's own soft wraps (`isWrapped`), and — when the caller
|
||||
* passes the terminal width — the mid-word breaks Claude Code makes itself,
|
||||
* which xterm cannot see (see `hardWrapIndent`).
|
||||
*
|
||||
* `WebLinksAddon` does the same in its private `LinkComputer`, which the
|
||||
* built package does not export — so the walk is repeated here, with the same
|
||||
* 2048-character budget. Rows are read with `translateToString(true)`, which
|
||||
@@ -13,6 +17,8 @@
|
||||
* lands a cell early; the click still resolves the same link).
|
||||
*/
|
||||
|
||||
import { PATH_SEGMENT_CLASS } from "./filePathLinks";
|
||||
|
||||
export const MAX_JOINED_LENGTH = 2048;
|
||||
|
||||
/** Minimal slice of xterm's IBuffer this needs. */
|
||||
@@ -20,28 +26,118 @@ export interface RowSource {
|
||||
getLine(y: number): { isWrapped: boolean; translateToString(trimRight?: boolean): string } | undefined;
|
||||
}
|
||||
|
||||
export interface JoinOptions {
|
||||
/**
|
||||
* The terminal's width. Given, rows that Claude Code broke mid-word are
|
||||
* joined too (see `hardWrapIndent`); omitted, only xterm's soft wraps are.
|
||||
*/
|
||||
cols?: number;
|
||||
}
|
||||
|
||||
export interface JoinedLine {
|
||||
text: string;
|
||||
/** 0-based index of the first buffer row that contributed. */
|
||||
firstRow: number;
|
||||
/** For each contributed row (in order), the string offset at which it starts. */
|
||||
rowStarts: number[];
|
||||
/**
|
||||
* For each contributed row, how many leading cells were left out of `text`:
|
||||
* the indent of a hard-wrapped continuation, 0 for every other row.
|
||||
*/
|
||||
rowIndents: number[];
|
||||
}
|
||||
|
||||
export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
|
||||
/** The characters `findFilePathLinks`' token is made of: segment characters and `/`. */
|
||||
const PATH_CLASS = `[${PATH_SEGMENT_CLASS}/]`;
|
||||
const PATH_CHAR = new RegExp(PATH_CLASS);
|
||||
const TRAILING_PATH_RUN = new RegExp(`${PATH_CLASS}+$`);
|
||||
const LEADING_PATH_RUN = new RegExp(`^${PATH_CLASS}+`);
|
||||
/** Cells a full row may stop short of the right edge (Ink boxes pad by one or two). */
|
||||
const EDGE_SLACK = 2;
|
||||
/** Claude Code draws its transcript (and so every continuation row) at least this far in. */
|
||||
const MIN_CONTINUATION_INDENT = 2;
|
||||
|
||||
/**
|
||||
* Is `next` the rest of a word that Claude Code broke at the end of `prev`?
|
||||
* Returns the continuation's indent (cells to skip), or `null`.
|
||||
*
|
||||
* Claude Code wraps its own output (Ink / wrap-ansi) and draws continuation
|
||||
* rows with cursor moves, so xterm never marks them `isWrapped` — captured at
|
||||
* 70 columns: `…/5e4ab145-915e-4902-9329-e\r\x1b[2C\x1b[1B039e2a…/notes.md`.
|
||||
* It breaks *inside* a word only when the word is longer than the line, which
|
||||
* is exactly what a long `/tmp/claude-<uid>/…` path is. So bridge only when
|
||||
* `prev` runs to (within {@link EDGE_SLACK} of) the right edge and ends in a
|
||||
* path character, `next` starts (after an indent of at least
|
||||
* {@link MIN_CONTINUATION_INDENT} cells — so two column-0 lines of shell
|
||||
* output are never glued) with one, and the two runs
|
||||
* together would not have fit on one line — i.e. word wrap could not have
|
||||
* moved the word down whole. That last test is what keeps ordinary prose (a
|
||||
* word that happens to end at the edge above `src/main.rs`) from being glued.
|
||||
*/
|
||||
function hardWrapIndent(prev: string, next: string, cols: number): number | null {
|
||||
if (prev.length < cols - EDGE_SLACK || prev.length > cols) return null;
|
||||
if (!PATH_CHAR.test(prev[prev.length - 1])) return null;
|
||||
const indent = next.search(/\S/);
|
||||
if (indent < MIN_CONTINUATION_INDENT || !PATH_CHAR.test(next[indent])) return null;
|
||||
const tail = TRAILING_PATH_RUN.exec(prev)?.[0].length ?? 0;
|
||||
const head = LEADING_PATH_RUN.exec(next.slice(indent))?.[0].length ?? 0;
|
||||
if (tail + head <= cols - indent - EDGE_SLACK) return null;
|
||||
return indent;
|
||||
}
|
||||
|
||||
/**
|
||||
* Cells to skip if row `y` continues row `y - 1`: 0 for an xterm soft wrap,
|
||||
* the indent for a Claude Code hard wrap, `null` if `y` starts a new line.
|
||||
*/
|
||||
function continuationIndent(buffer: RowSource, y: number, cols: number | undefined): number | null {
|
||||
if (y <= 0) return null;
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) return null;
|
||||
if (line.isWrapped) return 0;
|
||||
if (!cols) return null;
|
||||
const prev = buffer.getLine(y - 1);
|
||||
if (!prev) return null;
|
||||
return hardWrapIndent(prev.translateToString(true), line.translateToString(true), cols);
|
||||
}
|
||||
|
||||
/**
|
||||
* Joins `row` with the rows it wraps from and into. A chain longer than
|
||||
* {@link MAX_JOINED_LENGTH} is windowed around `row`: the walk up stops after
|
||||
* half the budget, the walk down after the whole of it, and `row` itself is
|
||||
* always included — so a hover deep in a huge wrapped blob reads a bounded
|
||||
* number of rows and still finds the text under the pointer.
|
||||
*/
|
||||
export function joinWrappedRows(buffer: RowSource, row: number, options: JoinOptions = {}): JoinedLine {
|
||||
const { cols } = options;
|
||||
let top = row;
|
||||
while (top > 0 && buffer.getLine(top)?.isWrapped) top--;
|
||||
let above = 0;
|
||||
while (top > 0 && continuationIndent(buffer, top, cols) !== null) {
|
||||
// Every row counts at least 1, so a run of blank soft-wrapped rows is bounded too.
|
||||
const len = Math.max(1, buffer.getLine(top - 1)?.translateToString(true).length ?? 0);
|
||||
if (above + len > MAX_JOINED_LENGTH / 2) break;
|
||||
above += len;
|
||||
top--;
|
||||
}
|
||||
|
||||
const parts: string[] = [];
|
||||
const rowIndents: number[] = [];
|
||||
let length = 0;
|
||||
let y = top;
|
||||
for (;;) {
|
||||
const line = buffer.getLine(y);
|
||||
if (!line) break;
|
||||
if (y !== top && !line.isWrapped) break;
|
||||
const text = line.translateToString(true);
|
||||
if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break;
|
||||
let indent = 0;
|
||||
const c = continuationIndent(buffer, y, cols);
|
||||
if (y !== top) {
|
||||
if (c === null) break;
|
||||
indent = c;
|
||||
} else if (c !== null) {
|
||||
indent = c; // the window starts mid-chain: drop this row's indent as well
|
||||
}
|
||||
const text = line.translateToString(true).slice(indent);
|
||||
if (length + text.length > MAX_JOINED_LENGTH && y > row) break;
|
||||
parts.push(text);
|
||||
rowIndents.push(indent);
|
||||
length += text.length;
|
||||
y++;
|
||||
}
|
||||
@@ -52,14 +148,18 @@ export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine {
|
||||
rowStarts.push(offset);
|
||||
offset += p.length;
|
||||
}
|
||||
return { text: parts.join(""), firstRow: top, rowStarts };
|
||||
return { text: parts.join(""), firstRow: top, rowStarts, rowIndents };
|
||||
}
|
||||
|
||||
/** String offset → 1-based {x, y} cell (y is the buffer row + 1). */
|
||||
export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } {
|
||||
export function offsetToCell(
|
||||
joined: Pick<JoinedLine, "firstRow" | "rowStarts"> & Partial<Pick<JoinedLine, "rowIndents">>,
|
||||
offset: number,
|
||||
): { x: number; y: number } {
|
||||
let rowIdx = 0;
|
||||
for (let i = 0; i < joined.rowStarts.length; i++) {
|
||||
if (joined.rowStarts[i] <= offset) rowIdx = i;
|
||||
}
|
||||
return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 };
|
||||
const indent = joined.rowIndents?.[rowIdx] ?? 0;
|
||||
return { x: offset - joined.rowStarts[rowIdx] + indent + 1, y: joined.firstRow + rowIdx + 1 };
|
||||
}
|
||||
+25
-2
@@ -28,12 +28,33 @@ ENV DEBIAN_FRONTEND=noninteractive
|
||||
# archive.ubuntu.com returns stale Packages.gz files with mismatched hashes
|
||||
# during hourly resyncs. Clearing /var/lib/apt/lists/* between attempts
|
||||
# forces a fresh fetch.
|
||||
RUN for i in 1 2 3 4 5; do \
|
||||
#
|
||||
# `apt-get upgrade` is what picks up security fixes for packages the
|
||||
# `ubuntu:24.04` image already ships. `install` below never touches those, so
|
||||
# without it a fix that lands in noble-security between two refreshes of the
|
||||
# base tag stays out of this image until the next refresh. Packages the later
|
||||
# layers install come straight from the archive, so this one upgrade is enough.
|
||||
#
|
||||
# CI builds with `cache-from: type=gha`, so this layer (and everything after
|
||||
# it) only re-runs when its cache key changes: the base digest, this layer's
|
||||
# text, or `APT_REFRESH`. CI sets that to the ISO week (`2026-W45`) and also
|
||||
# rebuilds weekly on a schedule, so a security fix published after the last
|
||||
# commit is in the image within a week. An ARG only changes the key of a RUN
|
||||
# that uses it, hence the `echo`. Local builds leave it empty.
|
||||
#
|
||||
# Scanned on 2026-11-02, the published image of 2026-10-16 carried
|
||||
# libbrotli1 1.1.0-2build2 (CVE-2026-111497, HIGH) and zlib1g 1.3.dfsg-3.1ubuntu2.2
|
||||
# (CVE-2026-85091), both with fixed versions in the archive, and pnpm's bundled
|
||||
# undici 6.29.0 (three HIGH CVEs, fixed in the pnpm 12.16.1 a rebuild installs).
|
||||
ARG APT_REFRESH=
|
||||
RUN echo "apt refresh: ${APT_REFRESH:-unset}" \
|
||||
&& for i in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=3 update && break; \
|
||||
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
sleep 10; \
|
||||
done \
|
||||
&& apt-get -o Acquire::Retries=3 upgrade -y \
|
||||
&& apt-get install -y --no-install-recommends \
|
||||
git \
|
||||
curl \
|
||||
@@ -274,7 +295,9 @@ RUN npx --yes playwright@latest --version \
|
||||
&& rm -rf /var/lib/apt/lists/* /root/.npm
|
||||
|
||||
# ── Python 3 + pip + uv + ruff ──────────────────────────────────────────────
|
||||
RUN for i in 1 2 3 4 5; do \
|
||||
ARG APT_REFRESH=
|
||||
RUN echo "apt refresh: ${APT_REFRESH:-unset}" \
|
||||
&& for i in 1 2 3 4 5; do \
|
||||
apt-get -o Acquire::Retries=3 update && break; \
|
||||
echo "apt-get update failed (attempt $i), retrying in 10s..."; \
|
||||
rm -rf /var/lib/apt/lists/*; \
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -1,6 +1,14 @@
|
||||
FROM python:3.11-slim
|
||||
|
||||
RUN pip install --no-cache-dir \
|
||||
# The python:3.11 images freeze the pip and setuptools that shipped with that
|
||||
# CPython branch (pip 24.0, setuptools 79), and both have since collected
|
||||
# advisories: pip path traversal / tar-symlink / index-URL handling
|
||||
# (CVE-2025-8869, CVE-2026-1703, CVE-2026-3219, CVE-2026-6357, CVE-2026-8643,
|
||||
# CVE-2026-13346) and setuptools sdist/legacy-wheel path issues
|
||||
# (CVE-2026-59890, GHSA-xmj3-9gf7-h52w). Upgrade them first, so the installs
|
||||
# below also run under a patched pip.
|
||||
RUN pip install --no-cache-dir --upgrade "pip>=26.2" "setuptools>=84.1" \
|
||||
&& pip install --no-cache-dir \
|
||||
faster-whisper \
|
||||
fastapi \
|
||||
uvicorn[standard] \
|
||||
|
||||
Reference in new issue
Block a user