From 0d5858c89cc818a15b7d6d04e4930a5d2861df35 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:16:18 -0800 Subject: [PATCH 01/22] chore(deps): npm security updates (vite, vitest, postcss, undici, ...) Lockfile-only, all within existing package.json ranges. Clears all 11 npm audit advisories (1 critical, 7 high, 2 moderate, 1 low): vite 6.4.1->6.4.5, vitest/@vitest/mocker 4.0.18->4.1.12, postcss 8.5.6->8.5.32, undici 7.22.0->7.30.1, nanoid 3.3.11->3.3.20, picomatch 4.0.3->4.0.7, browserslist 4.28.1->4.29.5, source-map-js 1.2.1->1.2.2, baseline-browser-mapping 2.10.0->2.11.34, @babel/core 7.29.0->7.29.7. Generated with npm 11 because npm 10.9's `audit fix`/`update` crash with "Cannot read properties of null (reading 'edgesOut')". lockfileVersion is unchanged (3) and `npm ci` with npm 10 installs it cleanly. Co-Authored-By: Claude Opus 5.5 --- app/package-lock.json | 397 ++++++++++++++++++++++-------------------- 1 file changed, 207 insertions(+), 190 deletions(-) diff --git a/app/package-lock.json b/app/package-lock.json index 5d3ca19..5ab65a9 100644 --- a/app/package-lock.json +++ b/app/package-lock.json @@ -124,13 +124,13 @@ "license": "MIT" }, "node_modules/@babel/code-frame": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.0.tgz", - "integrity": "sha512-9NhCeYjq9+3uxgdtp20LSiJXJvN0FeCtNGpJxuMFZ1Kv3cWUNb6DOhJwUvcVCzKGR66cw4njwM6hrJLqgOwbcw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/code-frame/-/code-frame-7.29.7.tgz", + "integrity": "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-validator-identifier": "^7.28.5", + "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" }, @@ -139,9 +139,9 @@ } }, "node_modules/@babel/compat-data": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.0.tgz", - "integrity": "sha512-T1NCJqT/j9+cn8fvkt7jtwbLBfLC/1y1c7NtCeXFRgzGTsafi68MRv8yzkYSapBnFA6L3U2VSc02ciDzoAJhJg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/compat-data/-/compat-data-7.29.7.tgz", + "integrity": "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg==", "dev": true, "license": "MIT", "engines": { @@ -149,21 +149,21 @@ } }, "node_modules/@babel/core": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.0.tgz", - "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/core/-/core-7.29.7.tgz", + "integrity": "sha512-RgHBCvtjbOK2gXSNBNIkNoEc9qoVEtau3hj8gEqKQuL3HZAibKarWFEI3Lfm6EYKkLalOh8eSrj9b+ch9H/VBA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-compilation-targets": "^7.28.6", - "@babel/helper-module-transforms": "^7.28.6", - "@babel/helpers": "^7.28.6", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/traverse": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.7", + "@babel/helper-compilation-targets": "^7.29.7", + "@babel/helper-module-transforms": "^7.29.7", + "@babel/helpers": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/template": "^7.29.7", + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7", "@jridgewell/remapping": "^2.3.5", "convert-source-map": "^2.0.0", "debug": "^4.1.0", @@ -180,14 +180,14 @@ } }, "node_modules/@babel/generator": { - "version": "7.29.1", - "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.1.tgz", - "integrity": "sha512-qsaF+9Qcm2Qv8SRIMMscAvG4O3lJ0F1GuMo5HR/Bp02LopNgnZBC/EkbevHFeGs4ls/oPz9v+Bsmzbkbe+0dUw==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/generator/-/generator-7.29.12.tgz", + "integrity": "sha512-2W76mhjrpQFO1lK5e8IvGYB2D0YZ7S2R1Z2BjO2ZUYuW+amcpK/z60U6ZZmzskwoa7sJ6XohP/Wg8H5KxXI/IA==", "dev": true, "license": "MIT", "dependencies": { - "@babel/parser": "^7.29.0", - "@babel/types": "^7.29.0", + "@babel/parser": "^7.29.12", + "@babel/types": "^7.29.11", "@jridgewell/gen-mapping": "^0.3.12", "@jridgewell/trace-mapping": "^0.3.28", "jsesc": "^3.0.2" @@ -197,14 +197,14 @@ } }, "node_modules/@babel/helper-compilation-targets": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.28.6.tgz", - "integrity": "sha512-JYtls3hqi15fcx5GaSNL7SCTJ2MNmjrkHXg4FSpOA/grxK8KwyZ5bubHsCq8FXCkua6xhuaaBit+3b7+VZRfcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-compilation-targets/-/helper-compilation-targets-7.29.7.tgz", + "integrity": "sha512-wem6WaBj4NaVYVdNhLPPVacES6ZJ+KBBfSkTMD3YZxbP3rm3Di85tJU5ljaUNhaOynt+Aj0xruhYuzQBt8n71g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/compat-data": "^7.28.6", - "@babel/helper-validator-option": "^7.27.1", + "@babel/compat-data": "^7.29.7", + "@babel/helper-validator-option": "^7.29.7", "browserslist": "^4.24.0", "lru-cache": "^5.1.1", "semver": "^6.3.1" @@ -214,9 +214,9 @@ } }, "node_modules/@babel/helper-globals": { - "version": "7.28.0", - "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.28.0.tgz", - "integrity": "sha512-+W6cISkXFa1jXsDEdYA8HeevQT/FULhxzR99pxphltZcVaugps53THCeiWA8SguxxpSp3gKPiuYfSWopkLQ4hw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-globals/-/helper-globals-7.29.7.tgz", + "integrity": "sha512-3nQVUAtvkKH9zahfWgw96Jc/uFOmjACE1kQz82E2lqWmHBgjzbNlsC22nuQTfahmWeQtTq5nQ/4Nnd2A1wj4zA==", "dev": true, "license": "MIT", "engines": { @@ -224,29 +224,29 @@ } }, "node_modules/@babel/helper-module-imports": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.28.6.tgz", - "integrity": "sha512-l5XkZK7r7wa9LucGw9LwZyyCUscb4x37JWTPz7swwFE/0FMQAGpiWUZn8u9DzkSBWEcK25jmvubfpw2dnAMdbw==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-imports/-/helper-module-imports-7.29.7.tgz", + "integrity": "sha512-ejHwrQQYcm9xnTivShn2IDOlIzInN34AXskvq9QicvCtEzq1Vzclu/tKF8Jq1Cg8JG2GL6/EmjgsCT7lXepE3g==", "dev": true, "license": "MIT", "dependencies": { - "@babel/traverse": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/traverse": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/helper-module-transforms": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.28.6.tgz", - "integrity": "sha512-67oXFAYr2cDLDVGLXTEABjdBJZ6drElUSI7WKp70NrpyISso3plG9SAGEF6y7zbha/wOzUByWWTJvEDVNIUGcA==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-module-transforms/-/helper-module-transforms-7.29.7.tgz", + "integrity": "sha512-UPUVSyXbOh627KiCIGQSgwWzGeBKLkaJ9PJEdrngIwMSzxLR4jS4+f1f1jb7VzBbg8nFLaYotvVPFCTqdrmTAg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-module-imports": "^7.28.6", - "@babel/helper-validator-identifier": "^7.28.5", - "@babel/traverse": "^7.28.6" + "@babel/helper-module-imports": "^7.29.7", + "@babel/helper-validator-identifier": "^7.29.7", + "@babel/traverse": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -266,9 +266,9 @@ } }, "node_modules/@babel/helper-string-parser": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.27.1.tgz", - "integrity": "sha512-qMlSxKbpRlAridDExk92nSobyDdpPijUq2DW6oDnUqd0iOGxmQjyqhMIihI9+zv4LPyZdRje2cavWPbCbWm3eA==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/helper-string-parser/-/helper-string-parser-7.29.11.tgz", + "integrity": "sha512-snZbO7MEDOof+hvNa97EjEY7qD7h2kwvQr5AJC3ZhQudKW5dnej6k3kP9NQV88hJwaS8vDqRLFo9rivLEhle2g==", "dev": true, "license": "MIT", "engines": { @@ -276,9 +276,9 @@ } }, "node_modules/@babel/helper-validator-identifier": { - "version": "7.28.5", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.28.5.tgz", - "integrity": "sha512-qSs4ifwzKJSV39ucNjsvc6WVHs6b7S03sOh2OcHF9UHfVPqWWALUsNUVzhSBiItjRZoLHx7nIarVjqKVusUZ1Q==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-identifier/-/helper-validator-identifier-7.29.7.tgz", + "integrity": "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg==", "dev": true, "license": "MIT", "engines": { @@ -286,9 +286,9 @@ } }, "node_modules/@babel/helper-validator-option": { - "version": "7.27.1", - "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.27.1.tgz", - "integrity": "sha512-YvjJow9FxbhFFKDSuFnVCe2WxXk1zWc22fFePVNEaWJEu8IrZVlda6N0uHwzZrUM1il7NC9Mlp4MaJYbYd9JSg==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/helper-validator-option/-/helper-validator-option-7.29.7.tgz", + "integrity": "sha512-N9ZErrD+yW5geCDtBqnOoxmR8+tNKiGuxKlDpuJxfsqpa2dFcexaziGAE/qoHLiDDreVNMupxGmSoNlyvsA3gw==", "dev": true, "license": "MIT", "engines": { @@ -296,27 +296,27 @@ } }, "node_modules/@babel/helpers": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.28.6.tgz", - "integrity": "sha512-xOBvwq86HHdB7WUDTfKfT/Vuxh7gElQ+Sfti2Cy6yIWNW05P8iUslOVcZ4/sKbE+/jQaukQAdz/gf3724kYdqw==", + "version": "7.29.10", + "resolved": "https://registry.npmjs.org/@babel/helpers/-/helpers-7.29.10.tgz", + "integrity": "sha512-EMCx3AGZ3WfAl6ht7AyQoPm2Lh+BGYEMdc4Q6Hf7uSCDOZFuLq44qNwL+iaim8VlyYhRAo4X43+weadK8ZB4Yg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/template": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.8" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/parser": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.0.tgz", - "integrity": "sha512-IyDgFV5GeDUVX4YdF/3CPULtVGSXXMLh1xVIgdCgxApktqnQV0r7/8Nqthg+8YLGaAtdyIlo2qIdZrbCv4+7ww==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/parser/-/parser-7.29.12.tgz", + "integrity": "sha512-WJ7JBp/Q4+z/ImydtI7jS6NVco5++632ExBemSX+Ysb+G1lCwQ5BVo3SY+jGGOcZXI4ZccbZSUSJLNBW6eEpJg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/types": "^7.29.0" + "@babel/types": "^7.29.11" }, "bin": { "parser": "bin/babel-parser.js" @@ -368,33 +368,33 @@ } }, "node_modules/@babel/template": { - "version": "7.28.6", - "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.28.6.tgz", - "integrity": "sha512-YA6Ma2KsCdGb+WC6UpBVFJGXL58MDA6oyONbjyF/+5sBgxY/dwkhLogbMT2GXXyU84/IhRw/2D1Os1B/giz+BQ==", + "version": "7.29.7", + "resolved": "https://registry.npmjs.org/@babel/template/-/template-7.29.7.tgz", + "integrity": "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.28.6", - "@babel/parser": "^7.28.6", - "@babel/types": "^7.28.6" + "@babel/code-frame": "^7.29.7", + "@babel/parser": "^7.29.7", + "@babel/types": "^7.29.7" }, "engines": { "node": ">=6.9.0" } }, "node_modules/@babel/traverse": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.0.tgz", - "integrity": "sha512-4HPiQr0X7+waHfyXPZpWPfWL/J7dcN1mx9gL6WdQVMbPnF3+ZhSMs8tCxN7oHddJE9fhNE7+lxdnlyemKfJRuA==", + "version": "7.29.12", + "resolved": "https://registry.npmjs.org/@babel/traverse/-/traverse-7.29.12.tgz", + "integrity": "sha512-MscXe/NAZcOkYEsRajHZFt3ynsb22PUi6CTrp5U7+RYycR9By84mn9mFN54YgfITiqK4hFx3Ju3tZSyzfpS5ew==", "dev": true, "license": "MIT", "dependencies": { - "@babel/code-frame": "^7.29.0", - "@babel/generator": "^7.29.0", - "@babel/helper-globals": "^7.28.0", - "@babel/parser": "^7.29.0", - "@babel/template": "^7.28.6", - "@babel/types": "^7.29.0", + "@babel/code-frame": "^7.29.7", + "@babel/generator": "^7.29.12", + "@babel/helper-globals": "^7.29.7", + "@babel/parser": "^7.29.12", + "@babel/template": "^7.29.7", + "@babel/types": "^7.29.11", "debug": "^4.3.1" }, "engines": { @@ -402,14 +402,14 @@ } }, "node_modules/@babel/types": { - "version": "7.29.0", - "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.0.tgz", - "integrity": "sha512-LwdZHpScM4Qz8Xw2iKSzS+cfglZzJGvofQICy7W7v4caru4EaAmyUuO6BGrbyQ2mYV11W0U8j5mBhd14dd3B0A==", + "version": "7.29.11", + "resolved": "https://registry.npmjs.org/@babel/types/-/types-7.29.11.tgz", + "integrity": "sha512-WCsemW40Cpc6gouC1SrY6PBaZ+oLRlEVY9XBvZBI7il/HyUrb2nJ505wE1MHEFyDZNnrWDEuy4jL50WVHfTTSQ==", "dev": true, "license": "MIT", "dependencies": { - "@babel/helper-string-parser": "^7.27.1", - "@babel/helper-validator-identifier": "^7.28.5" + "@babel/helper-string-parser": "^7.29.11", + "@babel/helper-validator-identifier": "^7.29.7" }, "engines": { "node": ">=6.9.0" @@ -2526,31 +2526,31 @@ } }, "node_modules/@vitest/expect": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.0.18.tgz", - "integrity": "sha512-8sCWUyckXXYvx4opfzVY03EOiYVxyNrHS5QxX3DAIi5dpJAAkyJezHCP77VMX4HKA2LDT/Jpfo8i2r5BE3GnQQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/expect/-/expect-4.1.12.tgz", + "integrity": "sha512-vFlTt+YEu9WBpNjdGayNmlMVzrWPi1+sv853/7t2FU7hpO5gAFBKHzQmkCq5Xjpr+evegF2994LQT3hF3mRlqQ==", "dev": true, "license": "MIT", "dependencies": { - "@standard-schema/spec": "^1.0.0", + "@standard-schema/spec": "^1.1.0", "@types/chai": "^5.2.2", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "chai": "^6.2.1", - "tinyrainbow": "^3.0.3" + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "chai": "^6.2.2", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/mocker": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.0.18.tgz", - "integrity": "sha512-HhVd0MDnzzsgevnOWCBj5Otnzobjy5wLBe4EdeeFGv8luMsGcYqDuFRMcttKWZA5vVO8RFjexVovXvAM4JoJDQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/mocker/-/mocker-4.1.12.tgz", + "integrity": "sha512-fKShNpKZzupWZuY0z7egydvL0sqW4INEZBEExMBMAa6izlENEVHwoXgdaTCaBZ5fwlr4xf2o1wXC83n9RpO+NA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/spy": "4.0.18", + "@vitest/spy": "4.1.12", "estree-walker": "^3.0.3", "magic-string": "^0.30.21" }, @@ -2559,7 +2559,7 @@ }, "peerDependencies": { "msw": "^2.4.9", - "vite": "^6.0.0 || ^7.0.0-0" + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "msw": { @@ -2571,26 +2571,26 @@ } }, "node_modules/@vitest/pretty-format": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.0.18.tgz", - "integrity": "sha512-P24GK3GulZWC5tz87ux0m8OADrQIUVDPIjjj65vBXYG17ZeU3qD7r+MNZ1RNv4l8CGU2vtTRqixrOi9fYk/yKw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/pretty-format/-/pretty-format-4.1.12.tgz", + "integrity": "sha512-XW9O8+Mxx4VpCtkYdMJLZQQwsVryP9wk7cPKDxtXFfwSak6yRh5+2fep/Y0XF/ElBvJHq8bcYdi6aeuai0opZA==", "dev": true, "license": "MIT", "dependencies": { - "tinyrainbow": "^3.0.3" + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" } }, "node_modules/@vitest/runner": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.0.18.tgz", - "integrity": "sha512-rpk9y12PGa22Jg6g5M3UVVnTS7+zycIGk9ZNGN+m6tZHKQb7jrP7/77WfZy13Y/EUDd52NDsLRQhYKtv7XfPQw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/runner/-/runner-4.1.12.tgz", + "integrity": "sha512-NKlVKCr5QnCe8jijdMnmkpHHU+WTqiLFR6G0pHji9uRjXK9xJl1k1nTw6F3pOOj2PDcsDt2GKQMI7wIsaT491w==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/utils": "4.0.18", + "@vitest/utils": "4.1.12", "pathe": "^2.0.3" }, "funding": { @@ -2598,13 +2598,14 @@ } }, "node_modules/@vitest/snapshot": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.0.18.tgz", - "integrity": "sha512-PCiV0rcl7jKQjbgYqjtakly6T1uwv/5BQ9SwBLekVg/EaYeQFPiXcgrC2Y7vDMA8dM1SUEAEV82kgSQIlXNMvA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/snapshot/-/snapshot-4.1.12.tgz", + "integrity": "sha512-ko1y1Qgb/slzQ9mr+b89Cuqud8WG1TKtsPD8r7ZUBdvVOGt85Af2HwCqHujrhwJq95hs/9RM7fcAPg57o1vlzw==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", + "@vitest/pretty-format": "4.1.12", + "@vitest/utils": "4.1.12", "magic-string": "^0.30.21", "pathe": "^2.0.3" }, @@ -2613,9 +2614,9 @@ } }, "node_modules/@vitest/spy": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.0.18.tgz", - "integrity": "sha512-cbQt3PTSD7P2OARdVW3qWER5EGq7PHlvE+QfzSC0lbwO+xnt7+XH06ZzFjFRgzUX//JmpxrCu92VdwvEPlWSNw==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/spy/-/spy-4.1.12.tgz", + "integrity": "sha512-RGq9x4JNGNTZHEJxvN0oSiJjeSCExm0n2gZKKJZHY9cX5AVA6M7VJILBT3ZJ/tJ/0J9uWT7fETV9/btoi//vSg==", "dev": true, "license": "MIT", "funding": { @@ -2623,14 +2624,15 @@ } }, "node_modules/@vitest/utils": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.0.18.tgz", - "integrity": "sha512-msMRKLMVLWygpK3u2Hybgi4MNjcYJvwTb0Ru09+fOyCXIgT5raYP041DRRdiJiI3k/2U6SEbAETB3YtBrUkCFA==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/@vitest/utils/-/utils-4.1.12.tgz", + "integrity": "sha512-Q3awXrwOfEV+qc3WmO5ONyE0yO7Q1gQ9/oyHxGLZ/pl7N3NO/ZztqQwKLuwMkV+CA1JYSIeO3gkHnRDIo4AVaA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/pretty-format": "4.0.18", - "tinyrainbow": "^3.0.3" + "@vitest/pretty-format": "4.1.12", + "convert-source-map": "^2.0.0", + "tinyrainbow": "^3.1.0" }, "funding": { "url": "https://opencollective.com/vitest" @@ -2759,9 +2761,9 @@ } }, "node_modules/baseline-browser-mapping": { - "version": "2.10.0", - "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.0.tgz", - "integrity": "sha512-lIyg0szRfYbiy67j9KN8IyeD7q7hcmqnJ1ddWmNt19ItGpNN64mnllmxUNFIOdOm6by97jlL6wfpTTJrmnjWAA==", + "version": "2.11.34", + "resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.11.34.tgz", + "integrity": "sha512-Aqr8mA1kIvyJ3UY5YAY7ib8LSo1DmDMbMnVqGPtsKW1LxaFQFwn9Eb3yvq2fUKJsRvaXjnAV7R5osqETapzNiA==", "dev": true, "license": "Apache-2.0", "bin": { @@ -2782,9 +2784,9 @@ } }, "node_modules/browserslist": { - "version": "4.28.1", - "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.28.1.tgz", - "integrity": "sha512-ZC5Bd0LgJXgwGqUknZY/vkUQ04r8NXnJZ3yYi4vDmSiZmC/pdSN0NbNRPxZpbtO4uAfDUAFffO8IZoM3Gj8IkA==", + "version": "4.29.5", + "resolved": "https://registry.npmjs.org/browserslist/-/browserslist-4.29.5.tgz", + "integrity": "sha512-khTmKu5NGYTotqcYo8Bew2k4RAE9PuXRSFRXG2lQBrCEQHyeQ/vX6gKMxfokwywBsLiXZMnHsmWMG1pnO8J7uQ==", "dev": true, "funding": [ { @@ -2802,11 +2804,11 @@ ], "license": "MIT", "dependencies": { - "baseline-browser-mapping": "^2.9.0", - "caniuse-lite": "^1.0.30001759", - "electron-to-chromium": "^1.5.263", - "node-releases": "^2.0.27", - "update-browserslist-db": "^1.2.0" + "baseline-browser-mapping": "^2.11.33", + "caniuse-lite": "^1.0.30001819", + "electron-to-chromium": "^1.5.464", + "node-releases": "^2.0.60", + "update-browserslist-db": "^1.3.4" }, "bin": { "browserslist": "cli.js" @@ -2816,9 +2818,9 @@ } }, "node_modules/caniuse-lite": { - "version": "1.0.30001774", - "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001774.tgz", - "integrity": "sha512-DDdwPGz99nmIEv216hKSgLD+D4ikHQHjBC/seF98N9CPqRX4M5mSxT9eTV6oyisnJcuzxtZy4n17yKKQYmYQOA==", + "version": "1.0.30001820", + "resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001820.tgz", + "integrity": "sha512-gfWD6MxqVCqAOwmODfZf7DchWvlrA+SRdNX7ceLfg3xRqe5ssjjpF7EgslC/0tiLiNTajdaNN/q8c/5u/7Gz8w==", "dev": true, "funding": [ { @@ -2837,9 +2839,9 @@ "license": "CC-BY-4.0" }, "node_modules/chai": { - "version": "6.2.2", - "resolved": "https://registry.npmjs.org/chai/-/chai-6.2.2.tgz", - "integrity": "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg==", + "version": "6.3.0", + "resolved": "https://registry.npmjs.org/chai/-/chai-6.3.0.tgz", + "integrity": "sha512-XWAtwJ6OHO+tj0EKCs0Y2UamnyOxseZWltU4x2U2wh8g4AigdjwvtUjvLP2tqkA/avxHEtzxNaqGq/YGNwckKg==", "dev": true, "license": "MIT", "engines": { @@ -2981,9 +2983,9 @@ "peer": true }, "node_modules/electron-to-chromium": { - "version": "1.5.302", - "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.302.tgz", - "integrity": "sha512-sM6HAN2LyK82IyPBpznDRqlTQAtuSaO+ShzFiWTvoMJLHyZ+Y39r8VMfHzwbU8MVBzQ4Wdn85+wlZl2TLGIlwg==", + "version": "1.5.472", + "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.472.tgz", + "integrity": "sha512-s4+nfT29b0FKpVKoaIxl2pWsoNcn7A/nMPSrxayw4T3BsmPeV2lF9Y06BsAQeMGhCTQ4nYJ5XST7jkqe4ZEqFw==", "dev": true, "license": "ISC" }, @@ -3015,9 +3017,9 @@ } }, "node_modules/es-module-lexer": { - "version": "1.7.0", - "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-1.7.0.tgz", - "integrity": "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA==", + "version": "2.3.2", + "resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.2.tgz", + "integrity": "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw==", "dev": true, "license": "MIT" }, @@ -3616,9 +3618,9 @@ "license": "MIT" }, "node_modules/nanoid": { - "version": "3.3.11", - "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.11.tgz", - "integrity": "sha512-N8SpfPUnUp1bK+PMYW8qSWdl9U+wwNWI4QKxOYDy9JAro3WMX7p2OeVRF9v+347pnakNevPmiHhNmZ2HbFA76w==", + "version": "3.3.20", + "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.20.tgz", + "integrity": "sha512-uKdg2G3GNCKQn9byYOpxbGqrT2fGO5KRt5J/8b3pok8rT6qxGWF6hxMyJiEYtAf+FVyYuD9hRaDqX5uPFYJ4ZQ==", "dev": true, "funding": [ { @@ -3635,11 +3637,14 @@ } }, "node_modules/node-releases": { - "version": "2.0.27", - "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.27.tgz", - "integrity": "sha512-nmh3lCkYZ3grZvqcCH+fjmQ7X+H0OeZgP40OierEaAptX4XofMh5kwNbWh7lBduUzCcV/8kZ+NDLCwm2iorIlA==", + "version": "2.0.63", + "resolved": "https://registry.npmjs.org/node-releases/-/node-releases-2.0.63.tgz", + "integrity": "sha512-6li2QanLsAXpIH2R6j77MzRyvOyrvdTqlKtO4CgBdHGNvN99ZZfyxszHc5o5+IZBWD5u6Hzwg83JoGXmqZNG9Q==", "dev": true, - "license": "MIT" + "license": "MIT", + "engines": { + "node": ">=18" + } }, "node_modules/obug": { "version": "2.1.1", @@ -3680,9 +3685,9 @@ "license": "ISC" }, "node_modules/picomatch": { - "version": "4.0.3", - "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", - "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", + "version": "4.0.7", + "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.7.tgz", + "integrity": "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA==", "dev": true, "license": "MIT", "engines": { @@ -3693,9 +3698,9 @@ } }, "node_modules/postcss": { - "version": "8.5.6", - "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.6.tgz", - "integrity": "sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==", + "version": "8.5.32", + "resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.32.tgz", + "integrity": "sha512-LJlKCCAUFKOuZxipUdYw1NzIITEsPcgyOG9LkvRJvuKj/bRzbuRY0ARXvL0pWGpsZvn8jku+LMep2eU0seHp8g==", "dev": true, "funding": [ { @@ -3713,9 +3718,9 @@ ], "license": "MIT", "dependencies": { - "nanoid": "^3.3.11", + "nanoid": "^3.3.20", "picocolors": "^1.1.1", - "source-map-js": "^1.2.1" + "source-map-js": "^1.2.2" }, "engines": { "node": "^10 || ^12 || >=14" @@ -3899,9 +3904,9 @@ "license": "ISC" }, "node_modules/source-map-js": { - "version": "1.2.1", - "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.1.tgz", - "integrity": "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==", + "version": "1.2.2", + "resolved": "https://registry.npmjs.org/source-map-js/-/source-map-js-1.2.2.tgz", + "integrity": "sha512-KGj/8Y43x35aZVDtt+J4mK1hoLGHULMYfSkODJNQjNDC3oW1PqPoxMwo0pLUsWM/UEGzON/NxeHywEfNXNP3Vw==", "dev": true, "license": "BSD-3-Clause", "engines": { @@ -3916,9 +3921,9 @@ "license": "MIT" }, "node_modules/std-env": { - "version": "3.10.0", - "resolved": "https://registry.npmjs.org/std-env/-/std-env-3.10.0.tgz", - "integrity": "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg==", + "version": "4.3.0", + "resolved": "https://registry.npmjs.org/std-env/-/std-env-4.3.0.tgz", + "integrity": "sha512-OtU/EgQ1kIm5KwqQpBC6ZEMXrZRui11w8zgfTWp8cdO9B8OaPsbA8bTHO2P+HNo1VlUTGMVBwPhydu6poeXiag==", "dev": true, "license": "MIT" }, @@ -4004,9 +4009,9 @@ } }, "node_modules/tinyrainbow": { - "version": "3.0.3", - "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.0.3.tgz", - "integrity": "sha512-PSkbLUoxOFRzJYjjxHJt9xro7D+iilgMX/C9lawzVuYiIdcihh9DXmVibBe8lmcFrRi/VzlPjBxbN7rH24q8/Q==", + "version": "3.2.0", + "resolved": "https://registry.npmjs.org/tinyrainbow/-/tinyrainbow-3.2.0.tgz", + "integrity": "sha512-LgO3D9yZJjApUiuUfl9iFAwrtaX4+lok3wJIqttGoKCHlWUqHqbQpnxCf82L8FjgKsh4iGo78hqJwgL8F6To2A==", "dev": true, "license": "MIT", "engines": { @@ -4074,9 +4079,9 @@ } }, "node_modules/undici": { - "version": "7.22.0", - "resolved": "https://registry.npmjs.org/undici/-/undici-7.22.0.tgz", - "integrity": "sha512-RqslV2Us5BrllB+JeiZnK4peryVTndy9Dnqq62S3yYRRTj0tFQCwEniUy2167skdGOy3vqRzEvl1Dm4sV2ReDg==", + "version": "7.30.1", + "resolved": "https://registry.npmjs.org/undici/-/undici-7.30.1.tgz", + "integrity": "sha512-03KrMbjIFDWQR5RNPrSw3bd1r12JpyFQl9/yxjUbJtqkjmllqC5lFjZh6ujeuJCKr8EOQvRW872Oy/az+nLYxg==", "dev": true, "license": "MIT", "engines": { @@ -4084,9 +4089,9 @@ } }, "node_modules/update-browserslist-db": { - "version": "1.2.3", - "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.2.3.tgz", - "integrity": "sha512-Js0m9cx+qOgDxo0eMiFGEueWztz+d4+M3rGlmKPT+T4IS/jP4ylw3Nwpu6cpTTP8R1MAC1kF4VbdLt3ARf209w==", + "version": "1.3.4", + "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.3.4.tgz", + "integrity": "sha512-t8wonIOdQyOjtPd2VktlBu6QjTVTks6v8bSi7kd0r2Vll8TK1JYDZtRRdbmCHOAMphkZgrzK+L+vXK4pyZ8t4A==", "dev": true, "funding": [ { @@ -4115,9 +4120,9 @@ } }, "node_modules/vite": { - "version": "6.4.1", - "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.1.tgz", - "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", + "version": "6.4.5", + "resolved": "https://registry.npmjs.org/vite/-/vite-6.4.5.tgz", + "integrity": "sha512-4enwjFfi4IwtEh0w+jPzpLVOFQiPHmmzPxcHXQOyEur47o6u0jE573DmGpjA+7irXoMLYRZD5ZaFUEneS3abPg==", "dev": true, "license": "MIT", "dependencies": { @@ -4190,31 +4195,31 @@ } }, "node_modules/vitest": { - "version": "4.0.18", - "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.0.18.tgz", - "integrity": "sha512-hOQuK7h0FGKgBAas7v0mSAsnvrIgAvWmRFjmzpJ7SwFHH3g1k2u37JtYwOwmEKhK6ZO3v9ggDBBm0La1LCK4uQ==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/vitest/-/vitest-4.1.12.tgz", + "integrity": "sha512-5eWdz2yb3ajxWF3C8C4TjkXl7FGm3n2E/VlazALrQHzK7nYMSGFnCZto0bbPNl77AwkOZVLk0YnBAejUu0NiWA==", "dev": true, "license": "MIT", "dependencies": { - "@vitest/expect": "4.0.18", - "@vitest/mocker": "4.0.18", - "@vitest/pretty-format": "4.0.18", - "@vitest/runner": "4.0.18", - "@vitest/snapshot": "4.0.18", - "@vitest/spy": "4.0.18", - "@vitest/utils": "4.0.18", - "es-module-lexer": "^1.7.0", - "expect-type": "^1.2.2", + "@vitest/expect": "4.1.12", + "@vitest/mocker": "4.1.12", + "@vitest/pretty-format": "4.1.12", + "@vitest/runner": "4.1.12", + "@vitest/snapshot": "4.1.12", + "@vitest/spy": "4.1.12", + "@vitest/utils": "4.1.12", + "es-module-lexer": "^2.0.0", + "expect-type": "^1.3.0", "magic-string": "^0.30.21", "obug": "^2.1.1", "pathe": "^2.0.3", "picomatch": "^4.0.3", - "std-env": "^3.10.0", + "std-env": "^4.0.0-rc.1", "tinybench": "^2.9.0", "tinyexec": "^1.0.2", "tinyglobby": "^0.2.15", - "tinyrainbow": "^3.0.3", - "vite": "^6.0.0 || ^7.0.0", + "tinyrainbow": "^3.1.0", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0", "why-is-node-running": "^2.3.0" }, "bin": { @@ -4230,12 +4235,15 @@ "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^20.0.0 || ^22.0.0 || >=24.0.0", - "@vitest/browser-playwright": "4.0.18", - "@vitest/browser-preview": "4.0.18", - "@vitest/browser-webdriverio": "4.0.18", - "@vitest/ui": "4.0.18", + "@vitest/browser-playwright": "4.1.12", + "@vitest/browser-preview": "4.1.12", + "@vitest/browser-webdriverio": "^4.1.11", + "@vitest/coverage-istanbul": "4.1.12", + "@vitest/coverage-v8": "4.1.12", + "@vitest/ui": "4.1.12", "happy-dom": "*", - "jsdom": "*" + "jsdom": "*", + "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "peerDependenciesMeta": { "@edge-runtime/vm": { @@ -4256,6 +4264,12 @@ "@vitest/browser-webdriverio": { "optional": true }, + "@vitest/coverage-istanbul": { + "optional": true + }, + "@vitest/coverage-v8": { + "optional": true + }, "@vitest/ui": { "optional": true }, @@ -4264,6 +4278,9 @@ }, "jsdom": { "optional": true + }, + "vite": { + "optional": false } } }, -- 2.52.0 From 8ddc8460082945da7de8a16cb4022a006edc21fc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:16:21 -0800 Subject: [PATCH 02/22] chore(deps): cargo security updates (rustls, rustls-webpki, quinn-proto, tar, ...) Cargo.lock only, all semver-compatible. Clears every `cargo audit` vulnerability: quinn-proto 0.11.13 -> 0.11.19 rustls 0.23.37 -> 0.23.46 rustls-webpki 0.103.9 -> 0.103.16 tar 0.4.44 -> 0.4.46 webpki-roots 1.0.6 -> 1.0.11 webpki-root-certs 1.0.9 -> 1.0.11 plist 1.8.0 -> 1.10.0 (quick-xml 0.38.4 -> 0.41.0) Also clears these "unsound" warnings: tokio 1.49.0 -> 1.54.1, anyhow -> 1.0.104, event-listener -> 5.4.2, rand 0.8.5 -> 0.8.8 and 0.9.2 -> 0.9.5. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/Cargo.lock | 170 ++++++++++++++++++++++++++------------- 1 file changed, 114 insertions(+), 56 deletions(-) diff --git a/app/src-tauri/Cargo.lock b/app/src-tauri/Cargo.lock index ed81dbc..22b1543 100644 --- a/app/src-tauri/Cargo.lock +++ b/app/src-tauri/Cargo.lock @@ -26,7 +26,7 @@ checksum = "b169f7a6d4742236a0a00c541b845991d0ac43e546831af1249753ab4c3aa3a0" dependencies = [ "cfg-if", "cipher", - "cpufeatures", + "cpufeatures 0.2.17", ] [[package]] @@ -84,9 +84,9 @@ dependencies = [ [[package]] name = "anyhow" -version = "1.0.102" +version = "1.0.104" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "7f202df86484c868dbad7eaa557ef785d5c66295e41b460ef922eca0723b842c" +checksum = "330a5ed07fa54e4702c9d6c4174f74427fc0ef6e214bbd677ae50a5099946470" [[package]] name = "arc-swap" @@ -105,7 +105,7 @@ checksum = "3c3610892ee6e0cbce8ae2700349fcf8f98adb0dbfbee85aec3c9179d29cc072" dependencies = [ "base64ct", "blake2", - "cpufeatures", + "cpufeatures 0.2.17", "password-hash", ] @@ -674,6 +674,17 @@ version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "613afe47fcd5fac7ccf1db93babcb082c5994d996f20b8b159f2ad1658eb5724" +[[package]] +name = "chacha20" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c35e4b699c7e15ccbe7ee35c005e4fc0a278d22238a2857e6ce2dadeda1b06" +dependencies = [ + "cfg-if", + "cpufeatures 0.3.1", + "rand_core 0.10.1", +] + [[package]] name = "chrono" version = "0.4.44" @@ -816,6 +827,15 @@ dependencies = [ "libc", ] +[[package]] +name = "cpufeatures" +version = "0.3.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "5ca28b0ae3115b884660db4118d803791fd6756b6e88f39c0f3f7859060d7566" +dependencies = [ + "libc", +] + [[package]] name = "crc32fast" version = "1.5.0" @@ -1367,16 +1387,15 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "39cab71617ae0d63f51a36d69f866391735b51691dbda63cf6f96d042b63efeb" dependencies = [ "libc", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "event-listener" -version = "5.4.1" +version = "5.4.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "e13b66accf52311f30a0db42147dadea9850cb48cd070028831ae5f5d4b856ab" +checksum = "5a23add41df1562121a9393cb065eab5146a1242410f23a644851e90cfd669d2" dependencies = [ - "concurrent-queue", "parking", "pin-project-lite", ] @@ -1768,11 +1787,9 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "899def5c37c4fd7b2664648c28120ecec138e4d395b459e5ca34f9cce2dd77fd" dependencies = [ "cfg-if", - "js-sys", "libc", "r-efi", "wasip2", - "wasm-bindgen", ] [[package]] @@ -1782,10 +1799,13 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "139ef39800118c7683f2fd3c98c1b23c09ae076556b435f8e9064ae108aaeeec" dependencies = [ "cfg-if", + "js-sys", "libc", "r-efi", + "rand_core 0.10.1", "wasip2", "wasip3", + "wasm-bindgen", ] [[package]] @@ -3742,9 +3762,9 @@ dependencies = [ [[package]] name = "mio" -version = "1.1.1" +version = "1.2.4" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "a69bcab0ad47271a0234d9422b131806bf3968021e5dc9328caf2d4cd58557fc" +checksum = "1788edb87fdc09c7e26304471e2f5be8cdefb1b6930d6e3985fc02ff53bf86ee" dependencies = [ "libc", "wasi 0.11.1+wasi-snapshot-preview1", @@ -4303,7 +4323,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "5d5285893bb5eb82e6aaf5d59ee909a06a16737a8970984dd7746ba9283498d6" dependencies = [ "phf_shared 0.10.0", - "rand 0.8.5", + "rand 0.8.8", ] [[package]] @@ -4313,7 +4333,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "3c80231409c20246a13fddb31776fb942c38553c51e871f8cbd687a4cfb5843d" dependencies = [ "phf_shared 0.11.3", - "rand 0.8.5", + "rand 0.8.8", ] [[package]] @@ -4433,9 +4453,9 @@ checksum = "7edddbd0b52d732b21ad9a5fab5c704c14cd949e5e9a1ec5929a24fded1b904c" [[package]] name = "plist" -version = "1.8.0" +version = "1.10.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "740ebea15c5d1428f910cd1a5f52cebf8d25006245ed8ade92702f4943d91e07" +checksum = "7da1d65da6dd5d1e44199ac0f58712d241c0f439f80adea8924d832384087f85" dependencies = [ "base64 0.22.1", "indexmap 2.13.0", @@ -4491,7 +4511,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9d1fe60d06143b2430aa532c94cfe9e29783047f06c0d7fd359a9a51b729fa25" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "opaque-debug", "universal-hash", ] @@ -4661,9 +4681,9 @@ dependencies = [ [[package]] name = "quick-xml" -version = "0.38.4" +version = "0.41.0" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b66c2058c55a409d601666cffe35f04333cf1013010882cec174a7467cd4e21c" +checksum = "e660451e55124f798a69a5af3f49ccfbefbd41910eefd25caf2393e1f3473ec1" dependencies = [ "memchr", ] @@ -4690,15 +4710,16 @@ dependencies = [ [[package]] name = "quinn-proto" -version = "0.11.13" +version = "0.11.19" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "f1906b49b0c3bc04b5fe5d86a77925ae6524a19b816ae38ce1e426255f1d8a31" +checksum = "0e750cca55fe4f0439a15d0bb529da9651e79993e8e72c61a899a36d462befbe" dependencies = [ "aws-lc-rs", "bytes", - "getrandom 0.3.4", + "getrandom 0.4.1", "lru-slab", - "rand 0.9.2", + "rand 0.10.3", + "rand_pcg 0.10.2", "ring", "rustc-hash", "rustls", @@ -4721,7 +4742,7 @@ dependencies = [ "once_cell", "socket2", "tracing", - "windows-sys 0.60.2", + "windows-sys 0.52.0", ] [[package]] @@ -4750,14 +4771,14 @@ dependencies = [ "rand_chacha 0.2.2", "rand_core 0.5.1", "rand_hc", - "rand_pcg", + "rand_pcg 0.2.1", ] [[package]] name = "rand" -version = "0.8.5" +version = "0.8.8" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "34af8d1a0e25924bc5b7c43c079c942339d8f0a8b57c39049bef581b46327404" +checksum = "e058c7de0b26af77780c769414d6257830bb240f3c38477dbc2c16e5f54d6d4c" dependencies = [ "libc", "rand_chacha 0.3.1", @@ -4766,14 +4787,25 @@ dependencies = [ [[package]] name = "rand" -version = "0.9.2" +version = "0.9.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "6db2770f06117d490610c7488547d543617b21bfa07796d7a12f6f1bd53850d1" +checksum = "b9ef1d0d795eb7d84685bca4f72f3649f064e6641543d3a8c415898726a57b41" dependencies = [ "rand_chacha 0.9.0", "rand_core 0.9.5", ] +[[package]] +name = "rand" +version = "0.10.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "65c9fb96cbc91e3478eaae79a69fcd3f1ae4ad052e471fe6732fff548984b4af" +dependencies = [ + "chacha20", + "getrandom 0.4.1", + "rand_core 0.10.1", +] + [[package]] name = "rand_chacha" version = "0.2.2" @@ -4831,6 +4863,12 @@ dependencies = [ "getrandom 0.3.4", ] +[[package]] +name = "rand_core" +version = "0.10.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "63b8176103e19a2643978565ca18b50549f6101881c443590420e4dc998a3c69" + [[package]] name = "rand_hc" version = "0.2.0" @@ -4849,6 +4887,15 @@ dependencies = [ "rand_core 0.5.1", ] +[[package]] +name = "rand_pcg" +version = "0.10.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "caa0f4137e1c0a72f4c651489402276c8e8e1cf081f3b0ba156d2cbeef09e86a" +dependencies = [ + "rand_core 0.10.1", +] + [[package]] name = "raw-window-handle" version = "0.6.2" @@ -5071,14 +5118,14 @@ dependencies = [ "errno", "libc", "linux-raw-sys", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] name = "rustls" -version = "0.23.37" +version = "0.23.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "758025cb5fccfd3bc2fd74708fd4682be41d99e5dff73c377c0646c6012c73a4" +checksum = "48e13bd8c0e9365c43cfa5c9e8f9ad49d3c8444926c9aac819e0e4dc503c8fdf" dependencies = [ "aws-lc-rs", "once_cell", @@ -5129,7 +5176,7 @@ dependencies = [ "security-framework 3.7.0", "security-framework-sys", "webpki-root-certs", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -5140,9 +5187,9 @@ checksum = "eec689c0bc40ff2458a5977b6619cb718087084a18e02a131c599b62d05e1a5f" [[package]] name = "rustls-webpki" -version = "0.103.9" +version = "0.103.16" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "d7df23109aa6c1567d1c575b9952556388da57401e4ace1d15f79eedad0d8f53" +checksum = "4443b1b6d39d7c663139942cc4558262562d2c84123bb6282ef177009ca8b281" dependencies = [ "aws-lc-rs", "ring", @@ -5517,7 +5564,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "e3bf829a2d51ab4a5ddf1352d8470c140cadc8301b2ae1789db023f01cedd6ba" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5534,7 +5581,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a7507d819769d01a365ab707794a4084392c824f54a7a6a7862f8c3d0892b283" dependencies = [ "cfg-if", - "cpufeatures", + "cpufeatures 0.2.17", "digest", ] @@ -5608,12 +5655,12 @@ checksum = "67b1b7a3b5fe4f1376887184045fcf45c69e92af734b7aaddc05fb777b6fbd03" [[package]] name = "socket2" -version = "0.6.2" +version = "0.6.5" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "86f4aa3ad99f2088c990dfa82d367e19cb29268ed67c574d10d0a4bfe71f07e0" +checksum = "c3d1e2c7f27f8d4cb10542a02c49005dbd6e93095799d6f3be745fae9f8fedd4" dependencies = [ "libc", - "windows-sys 0.60.2", + "windows-sys 0.61.2", ] [[package]] @@ -5764,6 +5811,17 @@ dependencies = [ "unicode-ident", ] +[[package]] +name = "syn" +version = "3.0.9" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d78c8dee4c7bf0e14673097256fed6142ce9d3b85a408189d07482442145823b" +dependencies = [ + "proc-macro2", + "quote", + "unicode-ident", +] + [[package]] name = "sync_wrapper" version = "1.0.2" @@ -5850,9 +5908,9 @@ dependencies = [ [[package]] name = "tar" -version = "0.4.44" +version = "0.4.46" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "1d863878d212c87a19c1a610eb53bb01fe12951c0501cf5a0d65f724914a667a" +checksum = "3f6221d9a6003c78398e3b239969f352578258df48c8eb051caadae0015bc840" dependencies = [ "filetime", "libc", @@ -6169,10 +6227,10 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "82a72c767771b47409d2345987fda8628641887d5466101319899796367354a0" dependencies = [ "fastrand", - "getrandom 0.4.1", + "getrandom 0.3.4", "once_cell", "rustix", - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] @@ -6294,9 +6352,9 @@ checksum = "1f3ccbac311fea05f86f61904b462b55fb3df8837a366dfc601a0161d0532f20" [[package]] name = "tokio" -version = "1.49.0" +version = "1.54.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "72a2903cd7736441aac9df9d7688bd0ce48edccaadf181c3b90be801e81d3d86" +checksum = "b52fd3255258d26fac7264ab5a76b53c13367136dc011d3d6ad4f8ed070396a3" dependencies = [ "bytes", "libc", @@ -6311,13 +6369,13 @@ dependencies = [ [[package]] name = "tokio-macros" -version = "2.6.0" +version = "2.7.2" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "af407857209536a95c8e56f8231ef2c2e2aff839b22e07a1ffcbc617e9db9fa5" +checksum = "78773a2a397f451582ce068015985c33193cf6dea8b74d2a639fe457b2f07b0e" dependencies = [ "proc-macro2", "quote", - "syn 2.0.117", + "syn 3.0.9", ] [[package]] @@ -6570,7 +6628,7 @@ dependencies = [ "keyring", "local-ip-address", "log", - "rand 0.9.2", + "rand 0.9.5", "reqwest 0.12.28", "serde", "serde_json", @@ -6606,7 +6664,7 @@ dependencies = [ "http", "httparse", "log", - "rand 0.9.2", + "rand 0.9.5", "sha1", "thiserror 2.0.18", "utf-8", @@ -7045,18 +7103,18 @@ dependencies = [ [[package]] name = "webpki-root-certs" -version = "1.0.9" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b96554aa2acc8ccdb7e1c9a58a7a68dd5d13bccc69cd124cb09406db612a1c9b" +checksum = "e49d0e65abc00c142e8f9428da991da9f1a250c39be97459901eb12b9b49f62a" dependencies = [ "rustls-pki-types", ] [[package]] name = "webpki-roots" -version = "1.0.6" +version = "1.0.11" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "22cfaf3c063993ff62e73cb4311efde4db1efb31ab78a3e5c457939ad5cc0bed" +checksum = "f5f280bbb2b287bc8e7de7e3852945a0ff50d1bbcb5ed42b6c7a568c546df171" dependencies = [ "rustls-pki-types", ] @@ -7119,7 +7177,7 @@ version = "0.1.11" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "c2a7b1c03c876122aa43f3020e6c3c3ee5c05081c9a00739faf7503aeba10d22" dependencies = [ - "windows-sys 0.61.2", + "windows-sys 0.52.0", ] [[package]] -- 2.52.0 From b0cea1240a3b05e6915f80746e1cac98a58b7026 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:23:37 -0800 Subject: [PATCH 03/22] fix(gateway): bump LiteLLM v1.96.0 -> v1.106.6 for SSRF and admin-escalation CVEs v1.96.0 is affected by two advisories published since it was pinned: - CVE-2026-84377 / GHSA-3cv6-jpf6-8222: authenticated SSRF and provider-credential exfiltration via unvalidated request-body URLs (fixed 1.96.2 on that line). This container holds a billed provider key. - CVE-2026-114612 / GHSA-7hp6-4w63-5g45: privilege escalation to proxy admin via cross-domain reuse of the salt key (fixed 1.103.1; no 1.96.x backport). v1.106.6 (2026-10-28) is the newest stable release with no OSV advisories. Tag and digest updated together; the index digest was read with `docker buildx imagetools inspect` and cosign-verified against LiteLLM's pinned public key. GATEWAY_REGISTRY_IMAGE in gateway.rs is kept in lockstep. Smoke-tested with a config shaped like render_config()'s output: the proxy starts, /v1/messages without a key is a 401, and with the master key the Anthropic-format request is translated and routed to the configured OpenAI base. Note this LiteLLM refuses to start with no master_key at all; Triple-C always renders one, so that is not a regression for us. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/docker/gateway.rs | 9 +++++---- gateway-container/Dockerfile | 23 +++++++++++++++-------- 2 files changed, 20 insertions(+), 12 deletions(-) diff --git a/app/src-tauri/src/docker/gateway.rs b/app/src-tauri/src/docker/gateway.rs index c32d60a..f9d69ab 100644 --- a/app/src-tauri/src/docker/gateway.rs +++ b/app/src-tauri/src/docker/gateway.rs @@ -45,10 +45,11 @@ const GATEWAY_CONFIG_VOLUME: &str = "triple-c-gateway-config"; /// Upstream LiteLLM, pinned to an exact release. /// /// LiteLLM 1.82.7 and 1.82.8 shipped credential-harvesting malware on PyPI, so -/// nothing here may float a tag or resolve `litellm` at build time. v1.96.0 is -/// also above the 1.84.0 floor set by the proxy auth-bypass CVEs — see the long -/// comment in `gateway-container/Dockerfile`, and keep the two in lockstep. -const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.96.0"; +/// nothing here may float a tag or resolve `litellm` at build time. v1.106.6 is +/// also above the floors set by the proxy auth-bypass, SSRF and salt-key CVEs — +/// see the long comment in `gateway-container/Dockerfile`, and keep the two in +/// lockstep. +const GATEWAY_REGISTRY_IMAGE: &str = "ghcr.io/berriai/litellm:v1.106.6"; const GATEWAY_LOCAL_IMAGE: &str = "triple-c-gateway:latest"; const GATEWAY_DOCKERFILE: &str = include_str!("../../../../gateway-container/Dockerfile"); diff --git a/gateway-container/Dockerfile b/gateway-container/Dockerfile index 362e344..f654126 100644 --- a/gateway-container/Dockerfile +++ b/gateway-container/Dockerfile @@ -28,19 +28,26 @@ # to keep it that way: a floating tag re-resolves, and this container ends up # holding an OpenAI API key. # -# v1.96.0 is chosen rather than the nearest clean post-incident release (1.83.0) +# v1.106.6 is chosen rather than the nearest clean post-incident release (1.83.0) # because the intervening releases fix a run of *ordinary* proxy CVEs that matter # for a gateway published on a host port: SQL injection in API-key verification # (CVE-2026-42208, fixed 1.83.7), auth bypass via Host header injection -# (CVE-2026-49468, fixed 1.84.0) and MCP auth bypass (CVE-2026-59822, fixed -# 1.84.0). 1.84.0 is the floor; v1.96.0 is the newest release with no open OSV -# advisories at the time of writing. Note LiteLLM has retired the `main-*` tag -# family (`main-latest` is no longer updated, `main-stable` is deprecated) in -# favour of plain semver tags, which is why this is `v1.96.0` and not -# `main-v1.96.0-stable`. +# (CVE-2026-49468, fixed 1.84.0), MCP auth bypass (CVE-2026-59822, fixed +# 1.84.0), authenticated SSRF and provider-credential exfiltration via +# unvalidated request-body URLs (CVE-2026-84377 / GHSA-3cv6-jpf6-8222, fixed +# 1.96.2 on the line this file previously pinned) and privilege escalation to +# proxy admin via cross-domain reuse of the salt key (CVE-2026-114612 / +# GHSA-7hp6-4w63-5g45, fixed 1.103.1; no 1.96.x fix). The SSRF one is the +# sharpest for Triple-C: this container holds a billed provider key. v1.106.6 is +# the newest stable release with no open OSV advisories at the time of writing +# (2026-11-02), and its digest was checked with +# `cosign verify --key https://raw.githubusercontent.com/BerriAI/litellm/e82a3c646908ba0a6ee0beddc4310d7a6cac4065/cosign.pub`. +# Note LiteLLM has retired the `main-*` tag family (`main-latest` is no longer +# updated, `main-stable` is deprecated) in favour of plain semver tags, which is +# why this is `v1.106.6` and not `main-v1.106.6-stable`. # # Bump deliberately, never automatically, and re-check the digest when you do. -FROM ghcr.io/berriai/litellm:v1.96.0@sha256:90d8de0ea6fbb3cad145d1019d00a0149ae400b1e18e2011a60f1988f143f672 +FROM ghcr.io/berriai/litellm:v1.106.6@sha256:37e2dafb088a7723beb998590682957386dfc83ffea1848486f8e0e5cfac0b06 # A placeholder config so the image is runnable on its own. Triple-C overwrites # /etc/litellm/config.yaml (a named volume) with the generated one before the -- 2.52.0 From 1ab386f24be717d3569e65ec4fe502011cf16abe Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:25:58 -0800 Subject: [PATCH 04/22] fix(stt): upgrade the base image's pip and setuptools before installing python:3.11-slim ships CPython 3.11's frozen pip 24.0 and setuptools 79.0.1. OSV lists six pip advisories (CVE-2025-8869, CVE-2026-1703, CVE-2026-3219, CVE-2026-6357, CVE-2026-8643, CVE-2026-13346; fixed by 26.2) and two setuptools ones (CVE-2026-59890, GHSA-xmj3-9gf7-h52w; fixed by 84.1.0). Upgrading them first also means the app installs run under a patched pip. After the change the image resolves pip 26.2.2 and setuptools 84.2.1, and an OSV batch query over all 41 installed packages comes back clean (fastapi 0.162.0, starlette 1.10.0, python-multipart 0.0.33, uvicorn 0.54.0, faster-whisper 1.2.1, ...). Trivy finds no fixable Debian 13.7 OS vulnerabilities in the current python:3.11-slim, so no apt upgrade layer is added. Smoke-tested: /health 200 and /transcribe 200 on a generated WAV. Co-Authored-By: Claude Opus 5.5 --- stt-container/Dockerfile | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/stt-container/Dockerfile b/stt-container/Dockerfile index 3d1a912..aa91330 100644 --- a/stt-container/Dockerfile +++ b/stt-container/Dockerfile @@ -1,6 +1,14 @@ FROM python:3.11-slim -RUN pip install --no-cache-dir \ +# The python:3.11 images freeze the pip and setuptools that shipped with that +# CPython branch (pip 24.0, setuptools 79), and both have since collected +# advisories: pip path traversal / tar-symlink / index-URL handling +# (CVE-2025-8869, CVE-2026-1703, CVE-2026-3219, CVE-2026-6357, CVE-2026-8643, +# CVE-2026-13346) and setuptools sdist/legacy-wheel path issues +# (CVE-2026-59890, GHSA-xmj3-9gf7-h52w). Upgrade them first, so the installs +# below also run under a patched pip. +RUN pip install --no-cache-dir --upgrade "pip>=26.2" "setuptools>=84.1" \ + && pip install --no-cache-dir \ faster-whisper \ fastapi \ uvicorn[standard] \ -- 2.52.0 From a3d53287a65446ab3c81bc043607df042eae0599 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:28:03 -0800 Subject: [PATCH 05/22] ci: bump the Windows runners' fallback Node from 22.14.0 to 22.23.4 22.14.0 is from early 2025 and has missed every Node 22 security release since, the latest being 22.23.4 (2026-10-20, marked security in nodejs.org/dist/index.json). The step only runs when `node` is absent from the build VM, so this governs a fresh or rebuilt VM; an existing install on WindowsBuilder is left as it is. No advisories exist in OSV for the pinned actions (checkout@v4, build-push-action@v5, login-action@v3, setup-buildx-action@v3, setup-qemu-action@v3). Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build-app-preview.yml | 2 +- .gitea/workflows/build-app.yml | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 63c9701..6ce586d 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -655,7 +655,7 @@ jobs: where node >nul 2>&1 && ( node --version ) || ( - curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.14.0/node-v22.14.0-x64.msi" + curl -fSL -o node-install.msi "https://nodejs.org/dist/v22.23.4/node-v22.23.4-x64.msi" msiexec /i node-install.msi /quiet /norestart del node-install.msi ) diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 96be408..51719a4 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -596,7 +596,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 ) -- 2.52.0 From 77dca26a83067fda3bd2459c788c0ebeaecc83e1 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:30:59 -0800 Subject: [PATCH 06/22] fix(container): apt-get upgrade in the base layer to pick up OS security fixes Nothing in container/Dockerfile pins a version (gh, docker-ce-cli and Node come from their apt repos, pnpm/Playwright/uv/ruff/rustup/Claude Code/AWS CLI resolve latest), so a rebuild is what delivers fixes. Two things stood in the way: - `apt-get install` never upgrades packages the ubuntu:24.04 image already ships, so a noble-security fix stays out until Canonical refreshes the tag. The first apt layer now runs `apt-get upgrade -y` inside the existing retry pattern. - CI builds with `cache-from: type=gha`, so the layers only re-run when the base digest or a layer's text changes. Trivy on the published image (built 2026-10-16) found libbrotli1 1.1.0-2build2 (CVE-2026-111497, HIGH), zlib1g 1.3.dfsg-3.1ubuntu2.2 (CVE-2026-85091) and pnpm's bundled undici 6.29.0 (CVE-2026-109383, CVE-2026-109869, CVE-2026-87018, all HIGH). Changing this layer invalidates the cache once, so the next CI build rebuilds everything. A local `docker build --pull` of this tree succeeds (amd64). Trivy finds no fixable OS or Node vulnerabilities in it: Node 22.23.4, gh 2.103.3, docker CLI 29.9.2, AWS CLI 2.39.3, pnpm 12.16.1, Playwright 1.64.1, Claude Code 2.1.316. What remains is uv 0.13.6's bundled rustls 0.23.45 (GHSA-c8m5-c952-6cm4, MEDIUM). That is fixed on uv's main branch but not in a release yet. Co-Authored-By: Claude Opus 5.5 --- container/Dockerfile | 14 ++++++++++++++ 1 file changed, 14 insertions(+) diff --git a/container/Dockerfile b/container/Dockerfile index fc81ee9..ba4b9c8 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -28,12 +28,26 @@ 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. +# +# `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. +# +# Note CI builds with `cache-from: type=gha`, so this layer (and everything +# after it) only re-runs when the base digest or this layer's text changes. +# 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). RUN 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 \ -- 2.52.0 From fc531be4992f2c55dd104763125260915204786b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:16:47 -0800 Subject: [PATCH 07/22] Keep trailing punctuation out of clickable terminal URLs The plain-text URL regex handed to WebLinksAddon ran to the next whitespace, so `See https://x/docs.`, `(https://x/a)` and markdown `[t](https://x/a)` underlined and opened the URL with the trailing `.` or `)` attached. The addon underlines exactly what the regex matches and passes that text to the click handler, so the fix has to be in the match. lib/urlMatch.ts adds TERMINAL_URL_REGEX, using GitHub/linkify-style rules: a URL never ends in `.,;:!?*`, and a closing `)]}` is kept only when it closes a bracket opened inside the URL (one level), so Wikipedia's `Foo_(bar)`, IPv6 `[::1]` and `a[]=` keys survive while a wrapping paren is dropped. `-_~=/` are never trimmed (base64url OAuth state, padding). The allowed character set is unchanged. UrlDetector's toast candidates use the same trimming via findUrls, but still decide whether to wait for more output from the raw run's end, so a chunk ending `.../file.` is not emitted before `tar.gz` arrives. sanitizeRelayUrl and the opensOnClick gate are untouched. Co-Authored-By: Claude Opus 5.5 --- .../components/terminal/TerminalView.test.tsx | 59 ++++++++ app/src/components/terminal/TerminalView.tsx | 8 +- app/src/lib/urlDetector.test.ts | 56 ++++++++ app/src/lib/urlDetector.ts | 17 +-- app/src/lib/urlMatch.test.ts | 127 ++++++++++++++++++ app/src/lib/urlMatch.ts | 93 +++++++++++++ 6 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 app/src/lib/urlMatch.test.ts create mode 100644 app/src/lib/urlMatch.ts diff --git a/app/src/components/terminal/TerminalView.test.tsx b/app/src/components/terminal/TerminalView.test.tsx index 8de6e72..6c5cde4 100644 --- a/app/src/components/terminal/TerminalView.test.tsx +++ b/app/src/components/terminal/TerminalView.test.tsx @@ -60,6 +60,8 @@ const ptyOutput = vi.hoisted(() => ({ const xterm = vi.hoisted(() => ({ options: null as Record | null, instances: [] as unknown[], + /** Every link provider registered on any terminal, for asking what gets underlined. */ + linkProviders: [] as import("@xterm/xterm").ILinkProvider[], })); /** @@ -82,6 +84,10 @@ vi.mock("@xterm/xterm", async (importOriginal) => { xterm.options = (options ?? null) as Record | null; xterm.instances.push(this); } + override registerLinkProvider(provider: import("@xterm/xterm").ILinkProvider) { + xterm.linkProviders.push(provider); + return super.registerLinkProvider(provider); + } } return { ...actual, Terminal: SpyTerminal }; }); @@ -1974,3 +1980,56 @@ describe("the plain-text URL path is gated the same way", () => { expect(openUrlExternal).not.toHaveBeenCalled(); }); }); + +/** + * The underline is the link: `WebLinksAddon` paints exactly what its regex + * matched and hands that same text to the click handler. So trailing prose + * punctuation has to be kept out of the *match*, not trimmed afterwards — + * otherwise `See https://x/docs.` underlines the full stop and opens it. + */ +describe("plain-text URLs stop before trailing punctuation", () => { + function term() { + return xterm.instances.at(-1) as unknown as { + write(d: string, cb: () => void): void; + }; + } + + async function write(data: string) { + await act(() => new Promise((resolve) => term().write(data, resolve))); + } + + /** The links every registered provider reports for buffer row 1. */ + async function linksOnFirstRow() { + const found: Array<{ text: string; startX: number; endX: number }> = []; + for (const provider of xterm.linkProviders) { + await new Promise((resolve) => + provider.provideLinks(1, (links) => { + for (const l of links ?? []) { + found.push({ text: l.text, startX: l.range.start.x, endX: l.range.end.x }); + } + resolve(); + }), + ); + } + return found.filter((l) => l.text.startsWith("http")); + } + + beforeEach(() => { + xterm.linkProviders.length = 0; + }); + + it.each([ + ["See https://example.com/docs.", "https://example.com/docs", 5], + ["(https://example.com/foo)", "https://example.com/foo", 2], + ["[text](https://x.example/z).", "https://x.example/z", 8], + ["", "https://x.example/a", 2], + ["https://en.wikipedia.org/wiki/Foo_(bar)", "https://en.wikipedia.org/wiki/Foo_(bar)", 1], + ])("underlines only the URL in %j", async (line, url, startX) => { + mountSession("bash"); + await write(line); + + expect(await linksOnFirstRow()).toEqual([ + { text: url, startX, endX: startX + url.length - 1 }, + ]); + }); +}); diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 180d60e..8f2553d 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -16,6 +16,7 @@ import { } from "../../lib/tauri-commands"; import { getCurrentWebview } from "@tauri-apps/api/webview"; import { UrlDetector, type UrlSource } from "../../lib/urlDetector"; +import { TERMINAL_URL_REGEX } from "../../lib/urlMatch"; import { RelayRateLimiter, URL_RELAY_OSC, @@ -1069,9 +1070,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 diff --git a/app/src/lib/urlDetector.test.ts b/app/src/lib/urlDetector.test.ts index 875bb9d..a222b7e 100644 --- a/app/src/lib/urlDetector.test.ts +++ b/app/src/lib/urlDetector.test.ts @@ -146,6 +146,62 @@ describe("UrlDetector", () => { }); }); +describe("UrlDetector — trailing punctuation", () => { + it("leaves a full stop after the link out of the candidate", () => { + const seen: string[] = []; + const d = new UrlDetector((u) => seen.push(u), () => COLS); + + feed(d, `Open ${SIGN_IN_URL}. Then paste the code.\r\n`); + + expect(seen).toEqual([SIGN_IN_URL]); + }); + + it("leaves wrapping brackets out of the candidate", () => { + const seen: string[] = []; + const d = new UrlDetector((u) => seen.push(u), () => COLS); + + feed(d, `[Sign in](${SIGN_IN_URL}).\r\nmore\r\n`); + + expect(seen).toEqual([SIGN_IN_URL]); + }); + + it("trims a link that ends the buffer once it is confirmed", () => { + const seen: string[] = []; + const d = new UrlDetector((u) => seen.push(u), () => COLS); + + feed(d, `See ${SIGN_IN_URL}.`); + + expect(seen).toEqual([SIGN_IN_URL]); + }); + + it("still waits for more when the buffer ends on a full stop", () => { + // A `.` at the end of a chunk may be the middle of the URL (`file.tar.gz`) + // with the rest still in flight. Whether to wait is decided by where the + // raw text ran to, not by where the trimmed URL ends. + const seen: string[] = []; + const d = new UrlDetector((u) => seen.push(u), () => 1000); + const head = "https://example.com/" + "a".repeat(90) + "."; + const url = head + "tar.gz"; + + d.feed(enc.encode(head)); + vi.advanceTimersByTime(400); // scanned, but inside the confirmation wait + d.feed(enc.encode("tar.gz\r\nnext\r\n")); + vi.advanceTimersByTime(2000); + + expect(seen).toEqual([url]); + }); + + it("measures the length threshold on the trimmed URL", () => { + const seen: string[] = []; + const d = new UrlDetector((u) => seen.push(u), () => COLS); + const url = "https://example.com/" + "a".repeat(79); // 99 characters + + feed(d, `(${url}).\r\nmore\r\n`); + + expect(seen).toEqual([]); + }); +}); + describe("usableLink", () => { // Ports `usable_sign_in_link` from `commands/auth_token_commands.rs` — a junk // filter, not the security decision. `sanitizeRelayUrl` is still what stands diff --git a/app/src/lib/urlDetector.ts b/app/src/lib/urlDetector.ts index b2fbb22..f0a9215 100644 --- a/app/src/lib/urlDetector.ts +++ b/app/src/lib/urlDetector.ts @@ -65,6 +65,7 @@ */ import { extendsUrl } from "./urlRelay"; +import { findUrls } from "./urlMatch"; const ANSI_RE = /\x1b(?:\[[0-9;?]*[A-Za-z]|\][^\x07\x1b]*(?:\x07|\x1b\\)?|[()#][A-Za-z0-9]|.)/g; @@ -297,19 +298,19 @@ export class UrlDetector { // still re-validated by `sanitizeRelayUrl` before it can reach the opener; // stopping the match early only means the legitimate prefix survives // instead of the whole candidate being thrown away. - // eslint-disable-next-line no-control-regex - const urlRe = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g; - let m: RegExpExecArray | null; - - while ((m = urlRe.exec(flat)) !== null) { - const url = m[0]; - + // + // `findUrls` also trims trailing prose punctuation and wrapping brackets + // (`…/authorize?….` → no `.`), the same rule the terminal underline uses + // — see `urlMatch.ts`. It reports the raw run's end separately, because + // that, not the trimmed URL's end, is what says whether the text may + // still be growing: a chunk ending `…/file.` may be followed by `tar.gz`. + for (const { url, rawEnd } of findUrls(flat)) { // 4. Filter by length if (url.length < MIN_URL_LENGTH) continue; // 5. If the match extends to the very end of the flattened string, // more chunks may still be arriving — defer emission. - if (m.index + url.length >= flat.length) { + if (rawEnd >= flat.length) { this.pendingUrl = url; this.confirmTimer = setTimeout(() => { this.confirmTimer = null; diff --git a/app/src/lib/urlMatch.test.ts b/app/src/lib/urlMatch.test.ts new file mode 100644 index 0000000..97864bd --- /dev/null +++ b/app/src/lib/urlMatch.test.ts @@ -0,0 +1,127 @@ +import { describe, it, expect } from "vitest"; +import { TERMINAL_URL_REGEX, findUrls, trimUrlCandidate } from "./urlMatch"; + +/** + * Every URL `WebLinksAddon` would underline in `line`. + * + * Mirrors `LinkComputer.computeLink` in `@xterm/addon-web-links`: it rebuilds + * the regex with the `g` flag added to whatever flags it was given and + * `exec`s in a loop. Run the same way here so the test exercises the regex as + * the addon will, not as a one-off `match` would. + */ +function underlined(line: string): string[] { + const rex = new RegExp(TERMINAL_URL_REGEX.source, (TERMINAL_URL_REGEX.flags || "") + "g"); + const out: string[] = []; + let m: RegExpExecArray | null; + while ((m = rex.exec(line))) out.push(m[0]); + return out; +} + +describe("TERMINAL_URL_REGEX", () => { + it("carries no g or y flag, which the addon adds itself", () => { + expect(TERMINAL_URL_REGEX.flags).not.toMatch(/[gy]/); + }); + + it.each([ + ["See https://example.com/docs.", "https://example.com/docs"], + ["at https://example.com/docs, then", "https://example.com/docs"], + ["is it https://example.com/docs?", "https://example.com/docs"], + ["wow https://example.com/docs!", "https://example.com/docs"], + ["go to https://example.com/docs: it", "https://example.com/docs"], + ["https://example.com/docs; and", "https://example.com/docs"], + ["end https://example.com/docs...", "https://example.com/docs"], + ["(https://example.com/foo)", "https://example.com/foo"], + ["(see https://example.com/foo).", "https://example.com/foo"], + ["[text](https://x.y/z)", "https://x.y/z"], + ["[text](https://x.y/z).", "https://x.y/z"], + ["", "https://x.example"], + ["[https://x.example/a]", "https://x.example/a"], + ["{https://x.example/a}", "https://x.example/a"], + ["\"https://x.example/a\"", "https://x.example/a"], + ["'https://x.example/a'", "https://x.example/a"], + ["`https://x.example/a`", "https://x.example/a"], + ["**https://x.example/a**", "https://x.example/a"], + ["https://x.example/a/", "https://x.example/a/"], + ])("leaves the trailing punctuation off %j", (line, url) => { + expect(underlined(line)).toEqual([url]); + }); + + it.each([ + "https://en.wikipedia.org/wiki/Foo_(bar)", + "https://example.com/search?q=a.b&x=1", + "https://example.com/a,b/c", + "https://example.com/path/file.tar.gz", + "https://example.com/#/route", + "https://example.com/a?arr[]=1&arr[]=2", + "http://[::1]:8080/x", + "https://example.com/a)b", + "https://example.com/a(b", + "https://example.com/api/{id}/x", + // OAuth state and code_challenge are base64url, which can end in `_`, `-` + // or `=` padding. None of those may be trimmed. + "https://claude.ai/oauth/authorize?code=true&state=abc-_", + "https://claude.ai/oauth/authorize?code_challenge=abc-", + "https://example.com/x?t=YWJj==", + "https://example.com/~user", + ])("keeps %j whole", (url) => { + expect(underlined(url)).toEqual([url]); + expect(underlined(`see ${url} now`)).toEqual([url]); + }); + + it("keeps a balanced paren and drops the one that wraps the link", () => { + expect(underlined("(https://en.wikipedia.org/wiki/Foo_(bar))")).toEqual([ + "https://en.wikipedia.org/wiki/Foo_(bar)", + ]); + expect(underlined("[Foo](https://en.wikipedia.org/wiki/Foo_(bar)).")).toEqual([ + "https://en.wikipedia.org/wiki/Foo_(bar)", + ]); + }); + + it("finds several links on one line", () => { + expect(underlined("a https://a.example/1, b (https://b.example/2).")).toEqual([ + "https://a.example/1", + "https://b.example/2", + ]); + }); + + it("matches nothing after a bare scheme", () => { + expect(underlined("https://.")).toEqual([]); + expect(underlined("https:// x")).toEqual([]); + }); + + it("still stops at control characters", () => { + expect(underlined("https://example.com/a\x1bb")).toEqual(["https://example.com/a"]); + expect(underlined("https://example.com/a\x00b")).toEqual(["https://example.com/a"]); + expect(underlined("https://example.com/a\x7fb")).toEqual(["https://example.com/a"]); + }); + + it("does not hang on a long run of punctuation", () => { + const line = "https://example.com/" + "(.".repeat(5000) + ")".repeat(5000); + const t0 = Date.now(); + underlined(line); + expect(Date.now() - t0).toBeLessThan(1000); + }); +}); + +describe("findUrls", () => { + it("returns the trimmed URL and the extent of the raw run", () => { + expect(findUrls("See https://example.com/docs. next")).toEqual([ + { url: "https://example.com/docs", index: 4, rawEnd: 29 }, + ]); + }); +}); + +describe("trimUrlCandidate", () => { + it("trims trailing punctuation and unbalanced closers", () => { + expect(trimUrlCandidate("https://example.com/docs.")).toBe("https://example.com/docs"); + expect(trimUrlCandidate("https://example.com/foo).")).toBe("https://example.com/foo"); + expect(trimUrlCandidate("https://en.wikipedia.org/wiki/Foo_(bar)")).toBe( + "https://en.wikipedia.org/wiki/Foo_(bar)", + ); + }); + + it("returns null for something that is not a URL at all", () => { + expect(trimUrlCandidate("https://.")).toBeNull(); + expect(trimUrlCandidate("not a url")).toBeNull(); + }); +}); diff --git a/app/src/lib/urlMatch.ts b/app/src/lib/urlMatch.ts new file mode 100644 index 0000000..4641abc --- /dev/null +++ b/app/src/lib/urlMatch.ts @@ -0,0 +1,93 @@ +/** + * Where a plain-text URL in terminal output starts and ends. + * + * The old pattern, `https?:\/\/[^\s'"`<>\x00-\x20\x7f]+`, ran to the next + * whitespace, so the prose around a link came with it: `See https://x/docs.` + * underlined and opened `https://x/docs.`, `(https://x/a)` opened + * `https://x/a)`, and a markdown link `[t](https://x/a)` the same. This uses + * the rules GitHub's autolinker and linkify apply: + * + * - A URL never *ends* in `.` `,` `;` `:` `!` `?` `*` — those are sentence + * punctuation (and markdown emphasis) when they come last. In the middle + * (`a.b`, `a,b`, `?q=`) they are kept. + * - A closing `)` `]` `}` is kept only when it closes a bracket opened inside + * the URL — `https://en.wikipedia.org/wiki/Foo_(bar)` keeps its paren, + * `(https://x/a)` loses the one that wraps it. A closer followed by more URL + * (`/a)b`) is kept either way, since it cannot be wrapping anything. + * - `-` `_` `~` `=` `/` are never trimmed: OAuth `state` and + * `code_challenge` are base64url, which can end in any of the first two, + * and `=` is base64 padding. + * + * It is a single regex rather than a match-then-trim because `WebLinksAddon` + * underlines exactly what its regex matches: trimming only the URL handed to + * the click handler would still paint the `.` as part of the link. The + * bracket rule is one level deep, which covers every real case (Wikipedia + * disambiguations, IPv6 hosts, `a[]=` query keys) without needing a parser. + * + * The set of characters a URL may contain at all is unchanged — whitespace, + * quotes, backtick, `<` `>`, the whole C0 range and DEL still end a match — + * and nothing here is a security decision: `sanitizeRelayUrl` still decides + * what may be opened. + */ + +/** Any character a URL may contain, minus brackets (handled separately). */ +const C = "[^\\s'\"`<>()\\[\\]{}\\x00-\\x20\\x7f]"; +/** A character a URL may end with: `C` minus trailing punctuation. */ +const END_CHAR = "[^\\s'\"`<>()\\[\\]{}\\x00-\\x20\\x7f.,;:!?*]"; +/** A one-level balanced bracket group: `(bar)`, `[::1]`, `[]`, `{id}`. */ +const GROUP = `\\(${C}*\\)|\\[${C}*\\]|\\{${C}*\\}`; +/** An opener that starts no balanced group (`/a(b`), so it is plain text. */ +const LONE_OPEN = `\\((?!${C}*\\))|\\[(?!${C}*\\])|\\{(?!${C}*\\})`; +/** A closer, allowed mid-URL; the final position excludes it unless balanced. */ +const CLOSE = "[)\\]}]"; + +const URL_SOURCE = `https?:\\/\\/(?:${GROUP}|${C}|${LONE_OPEN}|${CLOSE})*(?:${GROUP}|${END_CHAR})`; + +/** + * The regex handed to `WebLinksAddon` as `urlRegex`. + * + * No flags: `LinkComputer.computeLink` rebuilds it with `g` appended to + * whatever flags it has, so giving it `g` here would be redundant and `y` + * would break the scan. + */ +export const TERMINAL_URL_REGEX = new RegExp(URL_SOURCE); + +const ANCHORED = new RegExp(`^(?:${URL_SOURCE})`); + +/** + * The maximal run of URL characters starting at a scheme — the old pattern. + * Still useful for knowing how far the raw text ran, e.g. whether it reached + * the end of a buffer that may still be growing. + */ +// eslint-disable-next-line no-control-regex +const RAW_RUN = /https?:\/\/[^\s'"`<>\x00-\x20\x7f]+/g; + +/** + * The URL at the start of `candidate`, with trailing punctuation and + * unbalanced closers removed; `null` if nothing URL-shaped is left. + */ +export function trimUrlCandidate(candidate: string): string | null { + const m = ANCHORED.exec(candidate); + return m ? m[0] : null; +} + +export interface UrlMatch { + /** The URL as it should be shown and opened. */ + url: string; + /** Where it starts in the input. */ + index: number; + /** Where the raw run of URL characters ended, trailing punctuation included. */ + rawEnd: number; +} + +/** Every URL in `text`, trimmed, with the extent of the raw run it came from. */ +export function findUrls(text: string): UrlMatch[] { + const out: UrlMatch[] = []; + RAW_RUN.lastIndex = 0; + let m: RegExpExecArray | null; + while ((m = RAW_RUN.exec(text)) !== null) { + const url = trimUrlCandidate(m[0]); + if (url) out.push({ url, index: m.index, rawEnd: m.index + m[0].length }); + } + return out; +} -- 2.52.0 From 00d682883542e1cb73933b303cc8db2aa076ed6a Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 08:21:34 -0800 Subject: [PATCH 08/22] Open long /tmp paths that Claude Code hard-wrapped MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Shift+click did nothing useful on Claude Code's scratchpad paths (/tmp/claude-/-workspace//scratchpad/x.md). Claude Code wraps its own output: a word longer than the line is broken mid-word and the rest drawn on the next row with cursor moves, so xterm never marks that row isWrapped. The link provider saw two fragments: the first has no extension (no link), the second is a relative `…/scratchpad/x.md` probed under /workspace and not found. - xtermLineJoin bridges such breaks when given cols: the row runs to the edge, the next row continues with path characters after its indent, and the two runs could not have fit on one line (keeps prose apart). - Backend: `~/p` resolves to /home/claude/p; a relative path the project roots do not have is probed again under /home/claude and /tmp, in a second exec so a project file never becomes a chooser. - CLAUDE_INSTRUCTIONS asks Claude to print full absolute paths for files outside the project (changes the fingerprint: one recreation per project). Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 8 +- .../src/commands/file_viewer_commands.rs | 15 ++- app/src-tauri/src/docker/container.rs | 28 ++++++ app/src-tauri/src/file_viewer/resolve.rs | 83 ++++++++++++++++- .../terminal/filePathLinkProvider.test.ts | 26 +++++- .../terminal/filePathLinkProvider.ts | 6 +- app/src/lib/xtermLineJoin.test.ts | 63 ++++++++++++- app/src/lib/xtermLineJoin.ts | 91 +++++++++++++++++-- 8 files changed, 300 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 6040fd9..71977d4 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -83,6 +83,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li lookup silently fall back to `index.html`, so the window just opens the *main app*, full UI and all, with no error anywhere; `file_viewer::tests::the_viewer_entry_exists_and_is_a_vite_input` in `file_viewer/mod.rs` is the only thing pinning this. + **Claude Code wraps its own output, so xterm's `isWrapped` misses most wrapped paths.** A word + longer than the line (a `/tmp/claude-/…` scratchpad path) is broken mid-word and the rest + drawn on the next row with cursor moves. `lib/xtermLineJoin.ts` bridges that when given `cols` + (row runs to the edge, next row continues with path characters, and the two runs could not + have fit on one line — the last test is what keeps prose from being glued together). - **`components/layout/`** — TopBar, MainTabs (the unified tab strip), Sidebar, StatusBar - **`components/projects/`** — `ProjectRow` (select-only list row), `ProjectList`, `AddProjectDialog`, and the editors reused by Project Home @@ -172,7 +177,8 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li container — Claude sessions, installed capabilities, scheduler tasks), `auth_bridge_commands.rs`, `auth_token_commands.rs`. - **`file_viewer/`** — one window per click (`file-viewer-`), a managed `ViewerRegistry`, - resolution by probing `/workspace/

` then `/workspace//

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

` then `/workspace//

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

`, `/tmp/

`), polling by `sha256sum`, saves staged in `/tmp` and swapped in by a `sh` script as the container user (spec §5 says why the archive API never writes to the target directory). Commands take `window: tauri::Window`, gate on the label and act on the caller's own registry entry — no diff --git a/app/src-tauri/src/commands/file_viewer_commands.rs b/app/src-tauri/src/commands/file_viewer_commands.rs index 2af9605..dc9f65f 100644 --- a/app/src-tauri/src/commands/file_viewer_commands.rs +++ b/app/src-tauri/src/commands/file_viewer_commands.rs @@ -15,7 +15,7 @@ use crate::file_viewer::poll::{poll_file, ViewerPoll}; use crate::file_viewer::registry::{ Choice, Location, Reservation, ViewerRegistry, ViewerTarget, ViewerTargetState, }; -use crate::file_viewer::resolve::{candidate_paths, probe_candidates}; +use crate::file_viewer::resolve::{candidate_paths, fallback_candidate_paths, probe_candidates}; use crate::file_viewer::window::open_viewer_window; use crate::file_viewer::write::{sha256_hex, write_file, SavedFile, MAX_WRITE_BYTES}; use crate::models::Project; @@ -159,8 +159,17 @@ pub async fn open_file_viewer( let container_id = running_container_of(&project, "opening files").await?; let mounts: Vec = project.paths.iter().map(|p| p.mount_name.clone()).collect(); - let candidates = candidate_paths(&path, &mounts)?; - let matches = probe_candidates(&container_id, &candidates).await?; + let mut candidates = candidate_paths(&path, &mounts)?; + let mut matches = probe_candidates(&container_id, &candidates).await?; + // Not in the project: try home and /tmp, as a second exec so a project file + // never becomes a chooser because a namesake sits in /tmp. + if matches.is_empty() { + let fallbacks = fallback_candidate_paths(&path); + if !fallbacks.is_empty() { + matches = probe_candidates(&container_id, &fallbacks).await?; + candidates.extend(fallbacks); + } + } let initial = Location { line, col, end_line }; let target_state = match matches.len() { diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 6bda607..81642a9 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -124,6 +124,15 @@ 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 Shift+click by looking for it +/// in the project, then `~` and `/tmp`. A relative name for a file elsewhere +/// (Claude Code's scratchpad under `/tmp/claude-/...` above all) cannot +/// be found, so ask for the whole path. Part of the instructions fingerprint, +/// so adding or changing it recreates each container once on its next start. +const FILE_LINK_INSTRUCTIONS: &str = r#"## Referring to files + +The user can Shift+click a file path in this terminal to open it in a viewer. 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 +172,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 +4151,20 @@ 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")); + } + fn aliases(model: Option<&str>, haiku: Option<&str>) -> Vec<(&'static str, String)> { compute_model_aliases(model, haiku) } diff --git a/app/src-tauri/src/file_viewer/resolve.rs b/app/src-tauri/src/file_viewer/resolve.rs index b026444..0a51004 100644 --- a/app/src-tauri/src/file_viewer/resolve.rs +++ b/app/src-tauri/src/file_viewer/resolve.rs @@ -2,7 +2,9 @@ //! //! Relative paths are the common case (Claude prints project-relative paths). The //! terminal exec's cwd is `/workspace`, and each project path is mounted at -//! `/workspace/`, so those are the roots probed, in that order. The probe +//! `/workspace/`, so those are the roots probed, in that order. +//! A relative path none of them has is then tried under `/home/claude` and `/tmp` +//! ([`fallback_candidate_paths`]); `~/p` is `/home/claude/p`. The probe //! is one exec as the container user and prints `realpath -e` of every candidate that //! is a regular file: `fetch_container_file` refuses a symlink, so the registry must //! hold the resolved path, not the one that was clicked. @@ -36,12 +38,20 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result, return Ok(vec![normalised]); } - let rel = collapse(raw.strip_prefix("./").unwrap_or(raw)); - let rel = rel.trim_start_matches("./"); - if rel.is_empty() { - return Err("The path is empty.".into()); + // Claude Code prints paths under $HOME as `~/...`. Only the bare `~/` form: + // `~user/` names some other account's home and stays an ordinary name. + if let Some(rest) = raw.strip_prefix("~/") { + let rest = collapse(rest); + if rest.is_empty() { + return Err("The path is empty.".into()); + } + let p = format!("{}/{}", CONTAINER_HOME, rest); + validate_container_path("File", &p)?; + return Ok(vec![p]); } + let rel = relative_part(raw).ok_or_else(|| "The path is empty.".to_string())?; + let mut out: Vec = Vec::new(); let mut push = |candidate: String| { if out.len() < MAX_CANDIDATES && !out.contains(&candidate) { @@ -61,6 +71,37 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result, Ok(out) } +/// Where a relative path is looked for when no project root has it, in order. +/// The container user's home, then `/tmp` (drops, pastes and scratch files). +/// Probed only after [`candidate_paths`] found nothing, so a project file never +/// turns into a "which one?" chooser because a same-named file sits in `/tmp`. +const FALLBACK_ROOTS: &[&str] = &[CONTAINER_HOME, "/tmp"]; +const CONTAINER_HOME: &str = "/home/claude"; + +/// The second tier for a relative path: [`FALLBACK_ROOTS`]`/

`. Empty for an +/// absolute or `~/` path (those name one place already) and for anything +/// [`candidate_paths`] refuses — it is always called after that succeeded, but +/// must not be a way around its checks. +pub fn fallback_candidate_paths(raw: &str) -> Vec { + if raw.starts_with('/') || raw.starts_with("~/") || candidate_paths(raw, &[]).is_err() { + return Vec::new(); + } + let Some(rel) = relative_part(raw) else { return Vec::new() }; + FALLBACK_ROOTS + .iter() + .map(|root| format!("{}/{}", root, rel)) + .filter(|c| validate_container_path("File", c).is_ok()) + .take(MAX_CANDIDATES) + .collect() +} + +/// `./a//b` → `a/b`; `None` when nothing is left. +fn relative_part(raw: &str) -> Option { + let rel = collapse(raw.strip_prefix("./").unwrap_or(raw)); + let rel = rel.trim_start_matches("./"); + (!rel.is_empty()).then(|| rel.to_string()) +} + /// `a//b/./c` → `a/b/c`. Never touches `..` (rejected before this runs). fn collapse(path: &str) -> String { let absolute = path.starts_with('/'); @@ -129,6 +170,38 @@ mod tests { assert_eq!(c, vec!["/workspace/src/main.rs", "/workspace/api/src/main.rs"]); } + #[test] + fn an_absolute_tmp_path_is_accepted_as_is() { + // Claude Code's scratchpad: long, and with a segment that starts with `-`. + let p = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md"; + assert_eq!(candidate_paths(p, &mounts(&["api"])).unwrap(), vec![p]); + } + + #[test] + fn a_home_relative_path_is_the_container_users_home() { + // Claude Code prints paths under $HOME as `~/...`. + let c = candidate_paths("~/.claude/plans/plan.md", &mounts(&["api"])).unwrap(); + assert_eq!(c, vec!["/home/claude/.claude/plans/plan.md"]); + assert!(candidate_paths("~/", &[]).is_err()); + assert!(candidate_paths("~/../etc/passwd", &[]).is_err()); + // `~user/` is not expanded; it stays an ordinary relative name. + assert_eq!(candidate_paths("~bob/x.md", &[]).unwrap(), vec!["/workspace/~bob/x.md"]); + } + + #[test] + fn a_relative_path_falls_back_to_home_and_tmp() { + assert_eq!( + fallback_candidate_paths("scratch/notes.md"), + vec!["/home/claude/scratch/notes.md", "/tmp/scratch/notes.md"] + ); + assert_eq!(fallback_candidate_paths("./notes.md"), vec!["/home/claude/notes.md", "/tmp/notes.md"]); + // Absolute, home-relative and refused paths have no fallbacks. + assert!(fallback_candidate_paths("/tmp/notes.md").is_empty()); + assert!(fallback_candidate_paths("~/notes.md").is_empty()); + assert!(fallback_candidate_paths("../notes.md").is_empty()); + assert!(fallback_candidate_paths("").is_empty()); + } + #[test] fn traversal_nul_and_oversize_are_refused() { assert!(candidate_paths("../etc/passwd", &[]).is_err()); diff --git a/app/src/components/terminal/filePathLinkProvider.test.ts b/app/src/components/terminal/filePathLinkProvider.test.ts index 43119c3..b3d5945 100644 --- a/app/src/components/terminal/filePathLinkProvider.test.ts +++ b/app/src/components/terminal/filePathLinkProvider.test.ts @@ -1,7 +1,8 @@ import { describe, expect, it, vi } from "vitest"; import { createFilePathLinkProvider } from "./filePathLinkProvider"; -const fakeTerm = (rows: Array<[string, boolean]>) => ({ +const fakeTerm = (rows: Array<[string, boolean]>, cols?: number) => ({ + cols, buffer: { active: { getLine: (y: number) => rows[y] && { isWrapped: rows[y][1], translateToString: () => rows[y][0] }, @@ -40,6 +41,29 @@ describe("createFilePathLinkProvider", () => { expect(links.mock.calls[0][0][0].range).toEqual({ start: { x: 5, y: 1 }, end: { x: 12, y: 2 } }); }); + it("links a long /tmp path that Claude Code hard-wrapped, from either row", () => { + // Captured from Claude Code at 70 columns: the path is broken mid-word and + // the rest drawn two cells in on the next row, with no `isWrapped`. + const rows: Array<[string, boolean]> = [ + ["\u25cf The file is at /tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e", false], + [" 039e2a07562/scratchpad/notes.md ok", false], + ]; + const onOpen = vi.fn(); + const provider = createFilePathLinkProvider(fakeTerm(rows, 70), onOpen, () => true); + for (const row of [1, 2]) { + const links = vi.fn(); + provider.provideLinks(row, links); + const [list] = links.mock.calls[0]; + expect(list).toHaveLength(1); + expect(list[0].range).toEqual({ start: { x: 18, y: 1 }, end: { x: 33, y: 2 } }); + list[0].activate(new MouseEvent("click"), list[0].text); + } + const scratch = "/tmp/claude-1000/-workspace/5e4ab145-915e-4902-9329-e039e2a07562/scratchpad/notes.md"; + expect(onOpen).toHaveBeenCalledTimes(2); + expect(onOpen).toHaveBeenNthCalledWith(1, expect.objectContaining({ path: scratch })); + expect(onOpen).toHaveBeenNthCalledWith(2, expect.objectContaining({ path: scratch })); + }); + it("answers undefined for a row with nothing", () => { const provider = createFilePathLinkProvider(fakeTerm([["plain words", false]]), vi.fn(), () => true); const links = vi.fn(); diff --git a/app/src/components/terminal/filePathLinkProvider.ts b/app/src/components/terminal/filePathLinkProvider.ts index e6fbda5..565635f 100644 --- a/app/src/components/terminal/filePathLinkProvider.ts +++ b/app/src/components/terminal/filePathLinkProvider.ts @@ -17,7 +17,7 @@ export interface FilePathHover { } export function createFilePathLinkProvider( - term: Pick, + term: Pick, onOpen: (match: FilePathMatch) => void, gate: (event: MouseEvent) => boolean, hover?: FilePathHover, @@ -27,7 +27,9 @@ export function createFilePathLinkProvider( const row = bufferLineNumber - 1; const line = term.buffer.active.getLine(row); if (!line) return callback(undefined); - const joined = joinWrappedRows(term.buffer.active, row); + // `cols` lets the join bridge Claude Code's own mid-word wraps, which is + // how a long `/tmp/claude-/...` path reaches the screen. + const joined = joinWrappedRows(term.buffer.active, row, { cols: term.cols }); // `offsetToCell` has no row to map onto when nothing was joined. if (joined.rowStarts.length === 0) return callback(undefined); const matches = findFilePathLinks(joined.text); diff --git a/app/src/lib/xtermLineJoin.test.ts b/app/src/lib/xtermLineJoin.test.ts index b50d08b..b034d41 100644 --- a/app/src/lib/xtermLineJoin.test.ts +++ b/app/src/lib/xtermLineJoin.test.ts @@ -10,7 +10,7 @@ const buffer = (rows: Array<[string, boolean]>): RowSource => ({ describe("joinWrappedRows", () => { it("returns a single unwrapped row as-is", () => { const j = joinWrappedRows(buffer([["hello src/a.ts", false]]), 0); - expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0] }); + expect(j).toEqual({ text: "hello src/a.ts", firstRow: 0, rowStarts: [0], rowIndents: [0] }); }); it("walks up to the row that started the wrap and down through continuations", () => { @@ -38,6 +38,67 @@ describe("joinWrappedRows", () => { }); }); +/** + * 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 bridge a row that stops short of the edge", () => { + const b = buffer([["/tmp/claude-1000/-workspace/abc", false], ["def/notes.md", false]]); + expect(joinWrappedRows(b, 1, { cols: 70 }).text).toBe("def/notes.md"); + }); + + it("does not bridge without knowing the width", () => { + const b = buffer([[CLAUDE_ROW_1, false], [CLAUDE_ROW_2, false]]); + expect(joinWrappedRows(b, 0).text).toBe(CLAUDE_ROW_1); + }); +}); + describe("offsetToCell", () => { it("maps offsets to 1-based cells on the right row", () => { const j = { text: "abcdefgh", firstRow: 4, rowStarts: [0, 3, 6] }; diff --git a/app/src/lib/xtermLineJoin.ts b/app/src/lib/xtermLineJoin.ts index 1be6501..28fc63b 100644 --- a/app/src/lib/xtermLineJoin.ts +++ b/app/src/lib/xtermLineJoin.ts @@ -1,6 +1,10 @@ /** * Joins an xterm buffer row with its wrapped continuations. * + * Two kinds: xterm's own soft wraps (`isWrapped`), and — when the caller + * passes the terminal width — the mid-word breaks Claude Code makes itself, + * which xterm cannot see (see `hardWrapIndent`). + * * `WebLinksAddon` does the same in its private `LinkComputer`, which the * built package does not export — so the walk is repeated here, with the same * 2048-character budget. Rows are read with `translateToString(true)`, which @@ -20,28 +24,97 @@ 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. */ +const PATH_CHAR = /[A-Za-z0-9_.\-~+@/]/; +const TRAILING_PATH_RUN = /[A-Za-z0-9_.\-~+@/]+$/; +const LEADING_PATH_RUN = /^[A-Za-z0-9_.\-~+@/]+/; +/** Cells a full row may stop short of the right edge (Ink boxes pad by one or two). */ +const EDGE_SLACK = 2; + +/** + * Is `next` the rest of a word that Claude Code broke at the end of `prev`? + * Returns the continuation's indent (cells to skip), or `null`. + * + * Claude Code wraps its own output (Ink / wrap-ansi) and draws continuation + * rows with cursor moves, so xterm never marks them `isWrapped` — captured at + * 70 columns: `…/5e4ab145-915e-4902-9329-e\r\x1b[2C\x1b[1B039e2a…/notes.md`. + * It breaks *inside* a word only when the word is longer than the line, which + * is exactly what a long `/tmp/claude-/…` path is. So bridge only when + * `prev` runs to (within {@link EDGE_SLACK} of) the right edge and ends in a + * path character, `next` starts (after its indent) 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 < 0 || !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); +} + +export function joinWrappedRows(buffer: RowSource, row: number, options: JoinOptions = {}): JoinedLine { + const { cols } = options; let top = row; - while (top > 0 && buffer.getLine(top)?.isWrapped) top--; + while (top > 0 && continuationIndent(buffer, top, cols) !== null) 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); + let indent = 0; + if (y !== top) { + const c = continuationIndent(buffer, y, cols); + if (c === null) break; + indent = c; + } + const text = line.translateToString(true).slice(indent); if (length + text.length > MAX_JOINED_LENGTH && parts.length > 0) break; parts.push(text); + rowIndents.push(indent); length += text.length; y++; } @@ -52,14 +125,18 @@ export function joinWrappedRows(buffer: RowSource, row: number): JoinedLine { rowStarts.push(offset); offset += p.length; } - return { text: parts.join(""), firstRow: top, rowStarts }; + return { text: parts.join(""), firstRow: top, rowStarts, rowIndents }; } /** String offset → 1-based {x, y} cell (y is the buffer row + 1). */ -export function offsetToCell(joined: JoinedLine, offset: number): { x: number; y: number } { +export function offsetToCell( + joined: Pick & Partial>, + offset: number, +): { x: number; y: number } { let rowIdx = 0; for (let i = 0; i < joined.rowStarts.length; i++) { if (joined.rowStarts[i] <= offset) rowIdx = i; } - return { x: offset - joined.rowStarts[rowIdx] + 1, y: joined.firstRow + rowIdx + 1 }; + const indent = joined.rowIndents?.[rowIdx] ?? 0; + return { x: offset - joined.rowStarts[rowIdx] + indent + 1, y: joined.firstRow + rowIdx + 1 }; } -- 2.52.0 From 5d3a47e209a2347503be315c8939d79b34468042 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 09:16:54 -0800 Subject: [PATCH 09/22] Fix unicode artifacts and broken wraps when copying from a terminal MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Three separate causes behind "copy/paste from a Triple-C session is full of unicode junk": - Mojibake (`’`, `é`): the OSC 52 handler put `atob(payload)` straight on the clipboard. `atob` yields one code unit per byte, so every UTF-8 multi-byte character arrived as Latin-1. Claude Code copies through OSC 52 (directly, and via the container's xclip/pbcopy shim, which was verified to encode UTF-8 bytes correctly and not to chunk). Decoding moves into `osc52.ts` (`decodeOsc52`: base64 -> bytes -> fatal UTF-8, Latin-1 fallback), and `osc52.test.ts` now tests that function instead of a local copy of atob. - Invisible characters: Claude prints `❯` + U+00A0 and model text can carry narrow NBSP / zero-width spaces. xterm's getSelection maps only U+00A0. `sanitizeInvisibles` maps odd spaces to a space and drops zero-width chars; ZWJ/ZWNJ are dropped only next to ASCII, so emoji sequences and Persian/Indic text survive. - Broken wraps: Claude Code hard-wraps its own output with cursor moves, so xterm never marks the rows wrapped. `rejoinHardWraps` (term.cols + the selection's start column) rejoins a row only when the next row's first word could not have fitted, the indent matches the gutter / hanging indent, the row is not column-aligned and is at least 2 columns in; a token split at the edge (URLs) is joined without a space. Grounded on a real 70-column capture of Claude Code 2.1.316, used as the test fixture. `stripClaudeGutter` blanks the `●` / `⎿` gutter markers and an enclosing `│ … │` box. Ctrl+Shift+C and "Copy trimmed" run the whole pipeline (`cleanSelection`). OSC 52 text gets only the invisible-character pass: the program chose it, so it is not reflowed. Ctrl+Shift+Alt+C / "Copy raw" stays raw. No shortcut or setting changed. xtermLineJoin.ts is untouched. Co-Authored-By: Claude Opus 5.5 --- CLAUDE.md | 5 + .../components/terminal/TerminalView.test.tsx | 95 +++++++ app/src/components/terminal/TerminalView.tsx | 43 ++- .../components/terminal/copyCleanup.test.ts | 249 ++++++++++++++++++ app/src/components/terminal/copyCleanup.ts | 249 ++++++++++++++++++ app/src/components/terminal/osc52.test.ts | 74 +++--- app/src/components/terminal/osc52.ts | 36 +++ 7 files changed, 702 insertions(+), 49 deletions(-) create mode 100644 app/src/components/terminal/copyCleanup.test.ts create mode 100644 app/src/components/terminal/copyCleanup.ts create mode 100644 app/src/components/terminal/osc52.ts diff --git a/CLAUDE.md b/CLAUDE.md index 71977d4..56f6cba 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -73,6 +73,11 @@ docker exec stdout → tokio task → emit("terminal-output-{sessionId}") → li - **`hooks/`** — All Tauri IPC calls are encapsulated in hooks (`useTerminal`, `useProjects`, `useDocker`, `useSettings`) - **`lib/tauri-commands.ts`** — Typed `invoke()` wrappers; TypeScript types in `lib/types.ts` must match Rust models - **`components/terminal/TerminalView.tsx`** — xterm.js integration with WebGL rendering, URL detection for OAuth flow + - **Copy paths** (`copyCleanup.ts`, `osc52.ts`). OSC 52 base64 is UTF-8 *bytes* — never hand + `atob()`'s output to the clipboard (that was the `’` mojibake); OSC 52 text then gets only + `sanitizeInvisibles`. Ctrl+Shift+C / "Copy trimmed" run `cleanSelection`, which also undoes + Claude Code's hard wraps (it wraps its own output and moves the cursor, so rows are never + `isWrapped`) using `term.cols` and the selection's start column. Ctrl+Shift+Alt+C stays raw. - **`viewer/`** — the terminal file viewer's window (second Vite entry `viewer.html` → `src/viewer/main.tsx`; CodeMirror 6). `lib/filePathLinks.ts` decides what a path is; `components/terminal/filePathLinkProvider.ts` registers it with xterm. The OSC 8 handler now diff --git a/app/src/components/terminal/TerminalView.test.tsx b/app/src/components/terminal/TerminalView.test.tsx index 6c5cde4..b7799bb 100644 --- a/app/src/components/terminal/TerminalView.test.tsx +++ b/app/src/components/terminal/TerminalView.test.tsx @@ -2033,3 +2033,98 @@ describe("plain-text URLs stop before trailing punctuation", () => { ]); }); }); + +describe("TerminalView — copying text out", () => { + let writeText: ReturnType; + + beforeEach(() => { + writeText = vi.fn(async () => {}); + Object.defineProperty(navigator, "clipboard", { + value: { writeText }, + configurable: true, + }); + }); + + async function emitBytes(bytes: Uint8Array) { + const emit = ptyOutput.listeners.get("terminal-output-s1"); + if (!emit) throw new Error("no terminal-output listener registered"); + await act(async () => { + emit({ payload: Array.from(bytes) }); + await new Promise((r) => setTimeout(r, 0)); + await new Promise((r) => setTimeout(r, 0)); + }); + } + const utf8 = (s: string) => new TextEncoder().encode(s); + + function pressCopy(container: HTMLElement, altKey = false) { + helperTextarea(container).dispatchEvent( + new KeyboardEvent("keydown", { + key: "C", + ctrlKey: true, + shiftKey: true, + altKey, + bubbles: true, + cancelable: true, + }), + ); + } + + it("puts OSC 52 text on the clipboard as UTF-8, not mojibake", async () => { + mountSession("claude"); + await act(async () => {}); + // What the xclip shim (`printf '%s' | base64`) and Claude Code both send. + const text = "it’s café — naïve 日本語\u00A0👍"; + const b64 = Buffer.from(text, "utf8").toString("base64"); + await emitBytes(utf8(`\x1b]52;c;${b64}\x07`)); + + expect(writeText).toHaveBeenCalledTimes(1); + // NBSP normalized; everything else exactly as sent. + expect(writeText).toHaveBeenCalledWith("it’s café — naïve 日本語 👍"); + }); + + it("does not write anything for an OSC 52 read request", async () => { + mountSession("claude"); + await act(async () => {}); + await emitBytes(utf8("\x1b]52;c;?\x07")); + expect(writeText).not.toHaveBeenCalled(); + }); + + // Claude Code's own hard wrap: it moves to the next row itself (CR + cursor + // moves in the real stream), so the rows are not `isWrapped` and the + // selection carries a newline at every break. + const CLAUDE_ROWS = [ + "● The café’s naïve résumé handling means the parser — which was", + " written years ago by someone who has since left the team — silently", + " drops every accented character it cannot map.", + ]; + + it("Ctrl+Shift+C rejoins Claude's wrapped rows and drops its gutter", async () => { + const { container } = mountSession("claude"); + await act(async () => {}); + const term = xterm.instances[0] as Terminal; + act(() => term.resize(70, 24)); + await emitBytes(utf8(CLAUDE_ROWS.join("\r\n"))); + term.selectAll(); + + pressCopy(container); + + expect(writeText).toHaveBeenCalledWith( + "The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.", + ); + }); + + it("Ctrl+Shift+Alt+C copies the selection raw", async () => { + const { container } = mountSession("claude"); + await act(async () => {}); + const term = xterm.instances[0] as Terminal; + act(() => term.resize(70, 24)); + await emitBytes(utf8(CLAUDE_ROWS.join("\r\n"))); + term.selectAll(); + + pressCopy(container, true); + + expect(writeText).toHaveBeenCalledTimes(1); + expect(writeText.mock.calls[0][0]).toBe(term.getSelection()); + expect(writeText.mock.calls[0][0]).toContain("● The café’s"); + }); +}); diff --git a/app/src/components/terminal/TerminalView.tsx b/app/src/components/terminal/TerminalView.tsx index 8f2553d..1db5253 100644 --- a/app/src/components/terminal/TerminalView.tsx +++ b/app/src/components/terminal/TerminalView.tsx @@ -32,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"; @@ -123,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. * @@ -1128,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), ); @@ -1216,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; }); @@ -1627,7 +1642,7 @@ export default function TerminalView({ sessionId, active }: Props) { if (!term) return; const sel = term.getSelection(); if (!sel) return; - const out = mode === "raw" ? sel : trimSelection(sel); + const out = mode === "raw" ? sel : cleanSelection(sel, selectionGeometry(term)); navigator.clipboard.writeText(out).catch((e) => console.error("Context menu clipboard write failed:", e), ); diff --git a/app/src/components/terminal/copyCleanup.test.ts b/app/src/components/terminal/copyCleanup.test.ts new file mode 100644 index 0000000..241c7b7 --- /dev/null +++ b/app/src/components/terminal/copyCleanup.test.ts @@ -0,0 +1,249 @@ +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); + }); +}); + +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("uses the selection's start column for a first line that began mid-row", () => { + // Selection started at column 18, so the first line is only 51 + // characters but ended at column 69 of its row. + const first = "written years ago by someone who has since left the"; + expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70, startCol: 18 })).toBe( + `${first} team — silently`, + ); + // Without the start column it looks short and is left alone. + expect(rejoinHardWraps(`${first}\n team — silently`, { cols: 70 })).toBe( + `${first}\n team — silently`, + ); + }); + + it("leaves text unchanged when cols is unknown", () => { + const sel = CLAUDE_ROWS_70.join("\n"); + expect(rejoinHardWraps(sel, { cols: 0 })).toBe(sel); + }); +}); + +describe("cleanSelection", () => { + it("produces paste-ready text from a Claude reply (gutter, wraps, dedent)", () => { + const out = cleanSelection(CLAUDE_ROWS_70.join("\n"), { cols: 70 }); + expect(out.split("\n")[0]).toBe( + "The café’s naïve résumé handling means the parser — which was written years ago by someone who has since left the team — silently drops every accented character it cannot map.", + ); + expect(out).toContain( + "\nSee https://example.com/a/very/long/path/that/keeps/going/and/going/until/it/wraps/index.html?query=1 for details.\n", + ); + expect(out.endsWith("\ncurl -fsSL https://example.com/install.sh | bash -s -- --prefix=/opt/some/really/long/prefix/path --yes")).toBe(true); + }); + + it("normalizes Windows line endings from xterm's getSelection", () => { + expect(cleanSelection(" a\r\n b\r\n", { cols: 70 })).toBe("a\nb"); + }); + + it("strips invisible characters", () => { + expect(cleanSelection(" git\u202Fcommit\u200B -m x", { cols: 70 })).toBe("git commit -m x"); + }); + + it("keeps the old trimSelection behaviour for ordinary text", () => { + expect(cleanSelection(" def foo():\n return 1\n", { cols: 80 })).toBe( + "def foo():\n return 1", + ); + }); +}); diff --git a/app/src/components/terminal/copyCleanup.ts b/app/src/components/terminal/copyCleanup.ts new file mode 100644 index 0000000..aeeccc4 --- /dev/null +++ b/app/src/components/terminal/copyCleanup.ts @@ -0,0 +1,249 @@ +import { trimSelection } from "./trimSelection"; + +/** + * Clean-up applied to text copied out of a terminal, so that pasting it into + * an editor, a shell or a chat works. + * + * Three separate problems, fixed by separate pure functions: + * + * - **Invisible characters** (`sanitizeInvisibles`). Claude Code prints + * `❯` + U+00A0 for its prompt, and model output can carry NBSP / narrow + * NBSP / zero-width spaces; pasted into a shell they make `git status` an + * unknown command. xterm's `getSelection()` already maps U+00A0 (only that + * one) to a space; everything else survived until now. + * - **Claude's TUI chrome** (`stripClaudeGutter`): the `●` message bullet and + * `⎿` tool-result elbow in the left gutter, and `│ … │` dialog borders. + * - **Claude's hard wraps** (`rejoinHardWraps`). Claude Code wraps its own + * output to the terminal width and moves the cursor to the next row itself, + * so xterm never marks those rows `isWrapped` and a selection carries a real + * newline (plus the 2-column gutter indent) at every break — mid-sentence, + * and mid-URL when a long token was split at the edge. + * + * `cleanSelection` composes them with `trimSelection` for the trimmed copy + * (Ctrl+Shift+C, context-menu "Copy"). OSC 52 content gets only + * `sanitizeInvisibles`: the program chose that text, so it is not reflowed or + * dedented — only made safe to paste. Raw copy (Ctrl+Shift+Alt+C) gets none. + */ + +// ── invisible characters ──────────────────────────────────────────────────── + +/** NBSP, figure space, narrow NBSP, the U+2000–U+200A width spaces, medium + * math space. U+3000 (ideographic space) is deliberately absent: it is real + * CJK typography and occupies two cells. */ +const ODD_SPACES = /[\u00A0\u2000-\u200A\u202F\u205F]/g; +/** Zero-width space, word joiner, BOM / zero-width no-break space. */ +const ZERO_WIDTH = /[\u200B\u2060\uFEFF]/g; +/** ZWNJ / ZWJ touching ASCII (or a string edge). Between two non-ASCII + * characters they are meaningful — emoji ZWJ sequences, Persian ZWNJ, + * Indic conjunct control — so only this case is removed. */ +const JOINER_NEAR_ASCII = /(^|[\x00-\x7F])[\u200C\u200D]+|[\u200C\u200D]+(?=[\x00-\x7F]|$)/g; + +export function sanitizeInvisibles(text: string): string { + if (!text) return text; + return text + .replace(/[\u2028\u2029]/g, "\n") + .replace(ODD_SPACES, " ") + .replace(ZERO_WIDTH, "") + .replace(JOINER_NEAR_ASCII, "$1"); +} + +// ── cell width ────────────────────────────────────────────────────────────── + +const WIDE_RANGES: ReadonlyArray = [ + [0x1100, 0x115f], + [0x2e80, 0x303e], + [0x3041, 0x33ff], + [0x3400, 0x4dbf], + [0x4e00, 0x9fff], + [0xa000, 0xa4cf], + [0xac00, 0xd7a3], + [0xf900, 0xfaff], + [0xfe30, 0xfe4f], + [0xff00, 0xff60], + [0xffe0, 0xffe6], + [0x1f300, 0x1f64f], + [0x1f900, 0x1f9ff], + [0x20000, 0x3fffd], +]; +const ZERO_CELL = /^[\p{M}\u200B-\u200D\u2060\uFE00-\uFE0F\uFEFF]$/u; + +function charWidth(ch: string): number { + if (ZERO_CELL.test(ch)) return 0; + const cp = ch.codePointAt(0) ?? 0; + for (const [lo, hi] of WIDE_RANGES) if (cp >= lo && cp <= hi) return 2; + return 1; +} + +/** Terminal cells a string occupies — an approximation of xterm's Unicode 6 + * width table, good enough to tell whether a row reached the right edge. */ +export function cellWidth(text: string): number { + let w = 0; + for (const ch of text) w += charWidth(ch); + return w; +} + +// ── Claude Code's gutter and borders ──────────────────────────────────────── + +const BOX_TOP_BOTTOM = /^\s*[╭╰┌└][─━]*[╮╯┐┘]\s*$/; +const BOX_ROW = /^(\s*)│(.*)│\s*$/; + +/** + * 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), 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((l) => BOX_ROW.test(l))) { + 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]; +const isWide = (ch: string | undefined) => !!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 the + * previous row filled every column and its last token plus the next row's + * first token are wider than a line — a long token such as a URL split + * mid-token — or when 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; + + const prevLast = prev.body[prev.body.length - 1]; + if (isWide(prevLast) && isWide(cur.body[0])) return ""; + if (prev.end === width && cellWidth(lastToken(prev.body)) + cellWidth(next) > width - cur.lead) { + return ""; + } + return " "; +} + +// ── the trimmed copy ──────────────────────────────────────────────────────── + +/** The full clean-up for a trimmed copy of an xterm selection. */ +export function cleanSelection(text: string, geometry: CopyGeometry): string { + if (!text) return text; + const lf = text.replace(/\r\n?/g, "\n"); // getSelection() joins with CRLF on Windows + const visible = sanitizeInvisibles(lf); + const unframed = stripClaudeGutter(visible, geometry.startCol ?? 0); + return trimSelection(rejoinHardWraps(unframed, geometry)); +} diff --git a/app/src/components/terminal/osc52.test.ts b/app/src/components/terminal/osc52.test.ts index ba1f442..d1e3254 100644 --- a/app/src/components/terminal/osc52.test.ts +++ b/app/src/components/terminal/osc52.test.ts @@ -1,59 +1,63 @@ -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("â"); + }); + + it("falls back to Latin-1 when the payload is not valid UTF-8", () => { + // A legacy sender that base64'd Latin-1 bytes: "café" as 63 61 66 E9. + const latin1 = Buffer.from([0x63, 0x61, 0x66, 0xe9]).toString("base64"); + expect(decodeOsc52(`c;${latin1}`)).toBe("café"); + }); + + it("tolerates whitespace/newlines inside the base64 (base64 without -w0)", () => { + const b64 = utf8Base64("x".repeat(100)); + const wrapped = b64.slice(0, 76) + "\n" + b64.slice(76); + expect(decodeOsc52(`c;${wrapped}`)).toBe("x".repeat(100)); }); it("returns null for clipboard read request (?)", () => { - expect(handleOsc52("c;?")).toBe(null); + expect(decodeOsc52("c;?")).toBe(null); }); it("returns null for missing semicolon", () => { - expect(handleOsc52("invalid")).toBe(null); + expect(decodeOsc52("invalid")).toBe(null); }); it("returns null for invalid base64", () => { - expect(handleOsc52("c;!!!not-base64!!!")).toBe(null); + expect(decodeOsc52("c;!!!not-base64!!!")).toBe(null); }); it("handles empty payload after selection target", () => { - // btoa("") = "" - const result = handleOsc52("c;"); - expect(result).toBe(""); + expect(decodeOsc52("c;")).toBe(""); }); }); diff --git a/app/src/components/terminal/osc52.ts b/app/src/components/terminal/osc52.ts new file mode 100644 index 0000000..120689c --- /dev/null +++ b/app/src/components/terminal/osc52.ts @@ -0,0 +1,36 @@ +/** + * Decodes the payload of an OSC 52 clipboard *write* (`Pc ; Pd`, where `Pd` is + * base64) into the text to put on the clipboard. + * + * The base64 carries **bytes**, and every sender this app sees — the + * container's xclip/xsel/pbcopy shim (`printf '%s' | base64`) and Claude Code's + * own OSC 52 copy — sends UTF-8. `atob` alone returns one UTF-16 code unit per + * byte, so it turned `’` into `â\x80\x99` and `é` into `é` (the `’` mojibake + * users saw after pasting). The bytes are therefore decoded as UTF-8; a payload + * that is not valid UTF-8 falls back to Latin-1, which is exactly what the old + * `atob` path produced, so a legacy Latin-1 sender is no worse off. + * + * Returns `null` for a read request (`?`), a malformed sequence, or invalid + * base64 — the caller writes nothing in those cases. Pure function. + */ +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); + try { + return new TextDecoder("utf-8", { fatal: true }).decode(bytes); + } catch { + return binary; // not UTF-8: each byte is its Latin-1 code point + } +} -- 2.52.0 From a1cc9713a942f21dc6c6ba76af759804f370c7ef Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:36:26 -0800 Subject: [PATCH 10/22] ci: make the AppDir world-readable before repacking the AppImage appimagetool's continuous build of 2026-11-01 refuses an AppDir with restrictive modes. --appimage-extract writes it under the runner's umask, which produced a 0700 root and 0770 AppRun.wrapped, and we repacked exactly that: an image other users could not start. chmod -R a+rX before the repack, and assert the modes after it. Co-Authored-By: Claude Opus 5.5 --- scripts/finalize-appimage.sh | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/scripts/finalize-appimage.sh b/scripts/finalize-appimage.sh index c928bd7..c7c3385 100755 --- a/scripts/finalize-appimage.sh +++ b/scripts/finalize-appimage.sh @@ -235,6 +235,16 @@ done echo "Repacking." +# The AppDir must be readable (and its directories and programs executable) by +# everyone: the image is mounted with these modes and run by users other than +# the packager. `--appimage-extract` writes it under the runner's umask, which +# on some runners is 007 — the extracted root came out 0700 and AppRun.wrapped +# 0770, and we used to repack exactly that. appimagetool's continuous build of +# 2026-11-01 started refusing such an AppDir, which is right, so fix the modes +# rather than pass --no-check-permissions. `a+rX` adds x only where some x bit +# is already set (directories and programs), and grants no write to anyone. +chmod -R a+rX "$root" + tool="$work/appimagetool" curl -fsSL -o "$tool" "$APPIMAGE_TOOL_URL" chmod +x "$tool" @@ -271,6 +281,13 @@ if [ "$demoted" = true ]; then fi [ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c." +# Every entry readable by all, every directory and program executable by all — +# what the `chmod -R a+rX` before the repack promised. A 0700 root here is an +# image other users of the machine cannot start. +unreadable="$(find "$out" ! -type l \( ! -perm -a+r -o \( -type d ! -perm -a+x \) \ + -o \( -type f -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 -- 2.52.0 From c992a7959e67b1de8d5bd417e4e2f2157a115cf9 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:42:06 -0800 Subject: [PATCH 11/22] fix(links): require an indented continuation row before bridging a hard wrap Two column-0 lines of shell output where the first happens to fill the row (a 40-char path at 40 cols, then src/main.rs) were glued into one bogus path. Claude Code draws continuation rows at least 2 cells in, so require that. Co-Authored-By: Claude Opus 5.5 --- app/src/lib/xtermLineJoin.test.ts | 9 +++++++++ app/src/lib/xtermLineJoin.ts | 8 ++++++-- 2 files changed, 15 insertions(+), 2 deletions(-) diff --git a/app/src/lib/xtermLineJoin.test.ts b/app/src/lib/xtermLineJoin.test.ts index b034d41..b8cf530 100644 --- a/app/src/lib/xtermLineJoin.test.ts +++ b/app/src/lib/xtermLineJoin.test.ts @@ -88,6 +88,15 @@ describe("joinWrappedRows across Claude Code's hard wraps", () => { 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"); diff --git a/app/src/lib/xtermLineJoin.ts b/app/src/lib/xtermLineJoin.ts index 28fc63b..0e8ac1a 100644 --- a/app/src/lib/xtermLineJoin.ts +++ b/app/src/lib/xtermLineJoin.ts @@ -51,6 +51,8 @@ const TRAILING_PATH_RUN = /[A-Za-z0-9_.\-~+@/]+$/; const LEADING_PATH_RUN = /^[A-Za-z0-9_.\-~+@/]+/; /** 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`? @@ -62,7 +64,9 @@ const EDGE_SLACK = 2; * It breaks *inside* a word only when the word is longer than the line, which * is exactly what a long `/tmp/claude-/…` path is. So bridge only when * `prev` runs to (within {@link EDGE_SLACK} of) the right edge and ends in a - * path character, `next` starts (after its indent) with one, and the two runs + * 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. @@ -71,7 +75,7 @@ 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 < 0 || !PATH_CHAR.test(next[indent])) return null; + 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; -- 2.52.0 From f6f89ab6f0b9b2e17058b9fad78420fc2d39b31b Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:42:48 -0800 Subject: [PATCH 12/22] fix(links): bound the upward wrap walk and keep the hovered row in the window With hard-wrap bridging a long chain of continuation rows made the walk up to the chain's start unbounded while the walk down stopped at the 2048-char budget, so a hover deep in the chain could get back text that did not contain its own row, after reading the whole chain. Stop the walk up after half the budget, always include the hovered row, and drop the indent of a mid-chain first row. Co-Authored-By: Claude Opus 5.5 --- app/src/lib/xtermLineJoin.test.ts | 44 +++++++++++++++++++++++++++++++ app/src/lib/xtermLineJoin.ts | 22 +++++++++++++--- 2 files changed, 63 insertions(+), 3 deletions(-) diff --git a/app/src/lib/xtermLineJoin.test.ts b/app/src/lib/xtermLineJoin.test.ts index b8cf530..b777b3b 100644 --- a/app/src/lib/xtermLineJoin.test.ts +++ b/app/src/lib/xtermLineJoin.test.ts @@ -38,6 +38,50 @@ 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 diff --git a/app/src/lib/xtermLineJoin.ts b/app/src/lib/xtermLineJoin.ts index 0e8ac1a..d1de4d4 100644 --- a/app/src/lib/xtermLineJoin.ts +++ b/app/src/lib/xtermLineJoin.ts @@ -97,10 +97,24 @@ function continuationIndent(buffer: RowSource, y: number, cols: number | undefin 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 && continuationIndent(buffer, top, cols) !== null) 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[] = []; @@ -110,13 +124,15 @@ export function joinWrappedRows(buffer: RowSource, row: number, options: JoinOpt const line = buffer.getLine(y); if (!line) break; let indent = 0; + const c = continuationIndent(buffer, y, cols); if (y !== top) { - const c = continuationIndent(buffer, y, cols); 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 && parts.length > 0) break; + if (length + text.length > MAX_JOINED_LENGTH && y > row) break; parts.push(text); rowIndents.push(indent); length += text.length; -- 2.52.0 From 22480d1b0b932b4be8740f4fc57062bde36b05be Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:43:48 -0800 Subject: [PATCH 13/22] refactor: share CONTAINER_HOME and the path character class CONTAINER_HOME was declared twice (file_viewer/resolve.rs and commands/inspect_commands.rs); it now lives once in docker/mod.rs as pub(crate). xtermLineJoin's PATH_CHAR re-declared filePathLinks' TOKEN character class; filePathLinks now exports PATH_SEGMENT_CLASS and both regexes are built from it (TOKEN's source is byte-identical). Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/commands/inspect_commands.rs | 3 +-- app/src-tauri/src/docker/mod.rs | 3 +++ app/src-tauri/src/file_viewer/resolve.rs | 2 +- app/src/lib/filePathLinks.ts | 13 ++++++++++++- app/src/lib/xtermLineJoin.ts | 11 +++++++---- 5 files changed, 24 insertions(+), 8 deletions(-) diff --git a/app/src-tauri/src/commands/inspect_commands.rs b/app/src-tauri/src/commands/inspect_commands.rs index d9d85b7..0bb1059 100644 --- a/app/src-tauri/src/commands/inspect_commands.rs +++ b/app/src-tauri/src/commands/inspect_commands.rs @@ -63,6 +63,7 @@ use tauri::State; use crate::docker::client::get_docker; use crate::docker::container::is_container_running; use crate::docker::exec::{exec_oneshot_env, exec_oneshot_env_status}; +use crate::docker::CONTAINER_HOME; use crate::AppState; /// Newest N session transcripts to inspect. Caps the work done inside the @@ -72,8 +73,6 @@ const MAX_SESSIONS: usize = 50; /// Newest N scheduler notifications to return. const MAX_NOTIFICATIONS: usize = 50; -const CONTAINER_HOME: &str = "/home/claude"; - /// Caps on the free-text fields of a scheduled task. They exist to keep a /// runaway paste out of the container's task JSON and out of the `docker exec` /// payload; they are generous enough for a real prompt. diff --git a/app/src-tauri/src/docker/mod.rs b/app/src-tauri/src/docker/mod.rs index 97113fe..2bcc6a5 100644 --- a/app/src-tauri/src/docker/mod.rs +++ b/app/src-tauri/src/docker/mod.rs @@ -8,6 +8,9 @@ pub mod legacy_cleanup; pub mod migration; pub mod stt; +/// The container user `claude`'s home directory, as the image creates it. +pub(crate) const CONTAINER_HOME: &str = "/home/claude"; + #[allow(unused_imports)] pub use gateway::*; #[allow(unused_imports)] diff --git a/app/src-tauri/src/file_viewer/resolve.rs b/app/src-tauri/src/file_viewer/resolve.rs index 0a51004..f9f1730 100644 --- a/app/src-tauri/src/file_viewer/resolve.rs +++ b/app/src-tauri/src/file_viewer/resolve.rs @@ -11,6 +11,7 @@ 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; @@ -76,7 +77,6 @@ pub fn candidate_paths(raw: &str, mount_names: &[String]) -> Result, /// 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"]; -const CONTAINER_HOME: &str = "/home/claude"; /// The second tier for a relative path: [`FALLBACK_ROOTS`]`/

`. Empty for an /// absolute or `~/` path (those name one place already) and for anything diff --git a/app/src/lib/filePathLinks.ts b/app/src/lib/filePathLinks.ts index c5f64a9..2466741 100644 --- a/app/src/lib/filePathLinks.ts +++ b/app/src/lib/filePathLinks.ts @@ -34,12 +34,23 @@ const KNOWN_BASENAMES = new Set([ "README", "CHANGELOG", "PKGBUILD", ]); +/** + * The characters a path segment is made of, as a regex character-class body + * (no brackets). Shared with `xtermLineJoin`, which must agree with `TOKEN` on + * what counts as part of a path when it decides whether a row was broken + * mid-path. + */ +export const PATH_SEGMENT_CLASS = String.raw`A-Za-z0-9_.\-~+@`; + /** * A candidate token: path characters, optionally starting with `/`, `./`, `../` * or `.` (dotfile). Excludes the wrapping characters the surrounding markdown * leaves (`(`, `)`, `[`, `]`, backtick, quotes) and whitespace. */ -const TOKEN = /(?:\.{1,2}\/|\/)?[A-Za-z0-9_.\-~+@]+(?:\/[A-Za-z0-9_.\-~+@]+)*\/?/g; +const TOKEN = new RegExp( + String.raw`(?:\.{1,2}\/|\/)?[${PATH_SEGMENT_CLASS}]+(?:\/[${PATH_SEGMENT_CLASS}]+)*\/?`, + "g", +); const URL_SCHEME = /[a-z][a-z0-9+.-]*:\/\//gi; const LINE_SUFFIX = /^(?::(\d+)(?::(\d+))?(?:-(\d+))?|#L(\d+)(?:-L?(\d+))?)/; const VERSION_LIKE = /^v?\d+(\.\d+)+$/; diff --git a/app/src/lib/xtermLineJoin.ts b/app/src/lib/xtermLineJoin.ts index d1de4d4..c1f9af8 100644 --- a/app/src/lib/xtermLineJoin.ts +++ b/app/src/lib/xtermLineJoin.ts @@ -17,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. */ @@ -45,10 +47,11 @@ export interface JoinedLine { rowIndents: number[]; } -/** The characters `findFilePathLinks`' token is made of. */ -const PATH_CHAR = /[A-Za-z0-9_.\-~+@/]/; -const TRAILING_PATH_RUN = /[A-Za-z0-9_.\-~+@/]+$/; -const LEADING_PATH_RUN = /^[A-Za-z0-9_.\-~+@/]+/; +/** 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. */ -- 2.52.0 From da17bc2d0d0efb3fd029186f84141780b63916b7 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:46:31 -0800 Subject: [PATCH 14/22] fix(copy): keep a table intact when only its body rows are selected MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A selection of just `│ a │ b │` rows passed the 'wholly inside a box' test, so the outer borders were stripped and the inner │ kept. A row with a vertical rule inside its borders is a table row, not a box row. Co-Authored-By: Claude Opus 5.5 --- app/src/components/terminal/copyCleanup.test.ts | 6 ++++++ app/src/components/terminal/copyCleanup.ts | 12 +++++++++--- 2 files changed, 15 insertions(+), 3 deletions(-) diff --git a/app/src/components/terminal/copyCleanup.test.ts b/app/src/components/terminal/copyCleanup.test.ts index 241c7b7..cd4a3bf 100644 --- a/app/src/components/terminal/copyCleanup.test.ts +++ b/app/src/components/terminal/copyCleanup.test.ts @@ -111,6 +111,12 @@ describe("stripClaudeGutter", () => { 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", () => { diff --git a/app/src/components/terminal/copyCleanup.ts b/app/src/components/terminal/copyCleanup.ts index aeeccc4..15c3c0b 100644 --- a/app/src/components/terminal/copyCleanup.ts +++ b/app/src/components/terminal/copyCleanup.ts @@ -86,14 +86,20 @@ export function cellWidth(text: string): number { 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), a bullet elsewhere, a box mixed with outside text — is left - * as it is. + * (`├─┼─┤` 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. @@ -103,7 +109,7 @@ export function stripClaudeGutter(text: string, startCol = 0): string { let lines = text.split("\n"); const content = lines.filter((l) => l.trim() !== "" && !BOX_TOP_BOTTOM.test(l)); - if (content.length > 0 && content.every((l) => BOX_ROW.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())); -- 2.52.0 From f93fad61ab4fb3544f9976b40c7cc131f8355b2d Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:46:44 -0800 Subject: [PATCH 15/22] fix(copy): measure the characters at a wrap by code point, not UTF-16 unit isWide looked at a single UTF-16 code unit, so an astral character at the break (CJK Ext-B, emoji) was a lone surrogate of width 1 and the rejoin inserted a space between two wide characters. Co-Authored-By: Claude Opus 5.5 --- app/src/components/terminal/copyCleanup.test.ts | 8 ++++++++ app/src/components/terminal/copyCleanup.ts | 8 +++++--- 2 files changed, 13 insertions(+), 3 deletions(-) diff --git a/app/src/components/terminal/copyCleanup.test.ts b/app/src/components/terminal/copyCleanup.test.ts index cd4a3bf..3ab54dd 100644 --- a/app/src/components/terminal/copyCleanup.test.ts +++ b/app/src/components/terminal/copyCleanup.test.ts @@ -208,6 +208,14 @@ describe("rejoinHardWraps", () => { 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("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. diff --git a/app/src/components/terminal/copyCleanup.ts b/app/src/components/terminal/copyCleanup.ts index 15c3c0b..7948d45 100644 --- a/app/src/components/terminal/copyCleanup.ts +++ b/app/src/components/terminal/copyCleanup.ts @@ -172,7 +172,10 @@ function toRow(text: string, offset: number): Row { const lastToken = (s: string) => s.slice(s.lastIndexOf(" ") + 1); const firstToken = (s: string) => s.split(" ", 1)[0]; -const isWide = (ch: string | undefined) => !!ch && charWidth(ch) === 2; +/** 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. @@ -235,8 +238,7 @@ function joinerFor(prev: Row, cur: Row, head: Row, headPartial: boolean, cols: n const next = firstToken(cur.body); if (prev.end + 1 + cellWidth(next) <= width) return null; - const prevLast = prev.body[prev.body.length - 1]; - if (isWide(prevLast) && isWide(cur.body[0])) return ""; + if (isWide(lastChar(prev.body)) && isWide(firstChar(cur.body))) return ""; if (prev.end === width && cellWidth(lastToken(prev.body)) + cellWidth(next) > width - cur.lead) { return ""; } -- 2.52.0 From cef1723a1d3c6b815c936c9b981acbe10e7325fc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:47:16 -0800 Subject: [PATCH 16/22] fix(copy): keep the space when a URL that fits was moved down whole The empty-joiner rule fired whenever a row ended at the edge and its last token plus the next row's first token were wider than a line, so a short word at the edge followed by a URL that wrap-ansi moved down whole became 'thehttps://...'. Lengths alone cannot tell that from one long token split after 'the', so a next-row token that starts a URL (scheme://) is now treated as a fresh word, unless one of the two pieces fills its entire row (only the middle piece of a token longer than a line does). The captured 70-column URL split still rejoins without a space. Co-Authored-By: Claude Opus 5.5 --- .../components/terminal/copyCleanup.test.ts | 24 ++++++++++++ app/src/components/terminal/copyCleanup.ts | 37 +++++++++++++++---- 2 files changed, 53 insertions(+), 8 deletions(-) diff --git a/app/src/components/terminal/copyCleanup.test.ts b/app/src/components/terminal/copyCleanup.test.ts index 3ab54dd..b1ff19d 100644 --- a/app/src/components/terminal/copyCleanup.test.ts +++ b/app/src/components/terminal/copyCleanup.test.ts @@ -216,6 +216,30 @@ describe("rejoinHardWraps", () => { 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. diff --git a/app/src/components/terminal/copyCleanup.ts b/app/src/components/terminal/copyCleanup.ts index 7948d45..db72cd3 100644 --- a/app/src/components/terminal/copyCleanup.ts +++ b/app/src/components/terminal/copyCleanup.ts @@ -191,10 +191,9 @@ const isWide = (ch: string) => !!ch && charWidth(ch) === 2; * - 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 the - * previous row filled every column and its last token plus the next row's - * first token are wider than a line — a long token such as a URL split - * mid-token — or when the break is between two wide (CJK) characters. + * 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; @@ -239,10 +238,32 @@ function joinerFor(prev: Row, cur: Row, head: Row, headPartial: boolean, cols: n if (prev.end + 1 + cellWidth(next) <= width) return null; if (isWide(lastChar(prev.body)) && isWide(firstChar(cur.body))) return ""; - if (prev.end === width && cellWidth(lastToken(prev.body)) + cellWidth(next) > width - cur.lead) { - return ""; - } - 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 ──────────────────────────────────────────────────────── -- 2.52.0 From c76688f26058d9a3c80166cd07a51f46e910c670 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:47:38 -0800 Subject: [PATCH 17/22] fix(osc52): decode UTF-8 non-fatally instead of falling back to Latin-1 A single bad or truncated byte made the fatal decoder throw, and the whole payload was then re-read as Latin-1, re-mojibaking every valid multi-byte character in it. Decode non-fatally so only the bad bytes become U+FFFD. No Latin-1 fallback is kept: every sender here emits UTF-8. Co-Authored-By: Claude Opus 5.5 --- app/src/components/terminal/osc52.test.ts | 19 ++++++++++++++++--- app/src/components/terminal/osc52.ts | 16 ++++++++-------- 2 files changed, 24 insertions(+), 11 deletions(-) diff --git a/app/src/components/terminal/osc52.test.ts b/app/src/components/terminal/osc52.test.ts index d1e3254..63f4f8d 100644 --- a/app/src/components/terminal/osc52.test.ts +++ b/app/src/components/terminal/osc52.test.ts @@ -33,10 +33,23 @@ describe("decodeOsc52", () => { expect(decoded).not.toContain("â"); }); - it("falls back to Latin-1 when the payload is not valid UTF-8", () => { - // A legacy sender that base64'd Latin-1 bytes: "café" as 63 61 66 E9. + // 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é"); + expect(decodeOsc52(`c;${latin1}`)).toBe("caf\uFFFD"); }); it("tolerates whitespace/newlines inside the base64 (base64 without -w0)", () => { diff --git a/app/src/components/terminal/osc52.ts b/app/src/components/terminal/osc52.ts index 120689c..d94cf86 100644 --- a/app/src/components/terminal/osc52.ts +++ b/app/src/components/terminal/osc52.ts @@ -6,13 +6,17 @@ * 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; a payload - * that is not valid UTF-8 falls back to Latin-1, which is exactly what the old - * `atob` path produced, so a legacy Latin-1 sender is no worse off. + * 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; @@ -28,9 +32,5 @@ export function decodeOsc52(data: string): string | null { const bytes = new Uint8Array(binary.length); for (let i = 0; i < binary.length; i++) bytes[i] = binary.charCodeAt(i); - try { - return new TextDecoder("utf-8", { fatal: true }).decode(bytes); - } catch { - return binary; // not UTF-8: each byte is its Latin-1 code point - } + return UTF8.decode(bytes); } -- 2.52.0 From c3f62f96ef362967691df53e9c58885700ed7ddc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:48:22 -0800 Subject: [PATCH 18/22] fix(container): name the right click modifier in the file-link instructions The instructions told Claude the user can Shift+click a path, but the gate (TerminalView's opensOnClick) opens on a plain click unless a program holds the mouse, and then takes xterm's force-selection modifier: Shift on Linux/Windows, Option on macOS. Co-Authored-By: Claude Opus 5.5 --- app/src-tauri/src/docker/container.rs | 16 ++++++++++++++-- 1 file changed, 14 insertions(+), 2 deletions(-) diff --git a/app/src-tauri/src/docker/container.rs b/app/src-tauri/src/docker/container.rs index 81642a9..3d0ced1 100644 --- a/app/src-tauri/src/docker/container.rs +++ b/app/src-tauri/src/docker/container.rs @@ -124,14 +124,15 @@ 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 Shift+click by looking for it +/// The Triple-C terminal opens a printed path on click (Shift/Option+click while +/// a program holds the mouse — `opensOnClick` in TerminalView) by looking for it /// in the project, then `~` and `/tmp`. A relative name for a file elsewhere /// (Claude Code's scratchpad under `/tmp/claude-/...` above all) cannot /// be found, so ask for the whole path. Part of the instructions fingerprint, /// so adding or changing it recreates each container once on its next start. const FILE_LINK_INSTRUCTIONS: &str = r#"## Referring to files -The user can Shift+click a file path in this terminal to open it in a viewer. 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."#; +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. @@ -4165,6 +4166,17 @@ mod tests { 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) } -- 2.52.0 From 17f3863be3a83cea940c62b2ac416197a0633fbc Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 10:49:43 -0800 Subject: [PATCH 19/22] ci(container): rebuild the apt-get upgrade layer at least weekly CI builds with cache-from: type=gha, so the apt-get upgrade layer was reused until the base digest or the layer text changed, and security fixes published since then never reached the image. The RUN now uses an APT_REFRESH build arg, which build.yml sets to the ISO week, and a weekly schedule trigger rebuilds without a commit. Publishing is gated on event_name != 'pull_request' so the scheduled run (always on main) publishes and PR runs still only validate. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build.yml | 17 ++++++++++- app/src-tauri/src/docker/image.rs | 51 +++++++++++++++++++++++++++++++ container/Dockerfile | 17 ++++++++--- 3 files changed, 80 insertions(+), 5 deletions(-) diff --git a/.gitea/workflows/build.yml b/.gitea/workflows/build.yml index 3c3d8dc..ecd4453 100644 --- a/.gitea/workflows/build.yml +++ b/.gitea/workflows/build.yml @@ -11,6 +11,11 @@ on: paths: - "container/**" - ".gitea/workflows/build.yml" + # Weekly rebuild with no commit needed, so OS security fixes published since + # the last push reach the image (see APT_REFRESH in container/Dockerfile). + # Scheduled runs use the default branch, i.e. main. + schedule: + - cron: "17 4 * * 1" env: REGISTRY: repo.anhonesthost.net @@ -64,13 +69,23 @@ jobs: username: shadowdao password: ${{ secrets.GH_PAT }} + # Changes once a week, which invalidates the cached `apt-get upgrade` + # layer (container/Dockerfile) so later security fixes are picked up. + - name: Compute the apt refresh stamp + id: apt + run: echo "stamp=$(date -u +%G-W%V)" >> $GITHUB_OUTPUT + - name: Build and push container image uses: docker/build-push-action@v5 with: context: ./container file: ./container/Dockerfile platforms: linux/amd64,linux/arm64 - push: ${{ gitea.event_name == 'push' }} + # Publish from main pushes and the weekly scheduled rebuild (which + # runs on main); a pull_request run only validates. + push: ${{ gitea.event_name != 'pull_request' }} + build-args: | + APT_REFRESH=${{ steps.apt.outputs.stamp }} tags: | ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:latest ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} diff --git a/app/src-tauri/src/docker/image.rs b/app/src-tauri/src/docker/image.rs index 7a664b3..7c0970a 100644 --- a/app/src-tauri/src/docker/image.rs +++ b/app/src-tauri/src/docker/image.rs @@ -339,3 +339,54 @@ mod tests { assert_eq!(read(&dest.join("config.d/work")), "Host work"); } } + +#[cfg(test)] +mod apt_refresh_tests { + use super::DOCKERFILE; + + const BUILD_WORKFLOW: &str = include_str!("../../../../.gitea/workflows/build.yml"); + + /// The `RUN` instruction (with its `\` continuations) that contains `needle`. + fn run_containing(needle: &str) -> String { + let mut run = String::new(); + let mut current = String::new(); + for line in DOCKERFILE.lines() { + if line.starts_with("RUN ") { + current.clear(); + } + current.push_str(line); + current.push('\n'); + if !line.trim_end().ends_with('\\') { + if current.contains(needle) && current.starts_with("RUN ") { + run = current.clone(); + } + current.clear(); + } + } + run + } + + /// CI builds with `cache-from: type=gha`, so the `apt-get upgrade` layer is + /// only re-run when its cache key changes. An ARG changes a RUN's key only + /// where it is *used*, so the upgrade's RUN must reference it, after an + /// `ARG APT_REFRESH` declared in its stage. + #[test] + fn the_apt_upgrade_layer_is_keyed_on_apt_refresh() { + let arg = DOCKERFILE.find("\nARG APT_REFRESH").expect("ARG APT_REFRESH declared"); + let run = run_containing("upgrade -y"); + assert!(!run.is_empty(), "no RUN with apt-get upgrade"); + assert!(DOCKERFILE.find(&run).unwrap() > arg, "ARG must come before the RUN"); + assert!(run.contains("${APT_REFRESH"), "RUN must use APT_REFRESH: {run}"); + } + + /// …and CI must actually pass a value that changes at least weekly, and + /// rebuild on a schedule so a week with no commits still gets the fixes. + #[test] + fn ci_passes_a_weekly_apt_refresh_and_rebuilds_on_a_schedule() { + assert!(BUILD_WORKFLOW.contains("APT_REFRESH=${{ steps.apt.outputs.stamp }}")); + assert!(BUILD_WORKFLOW.contains("date -u +%G-W%V")); + assert!(BUILD_WORKFLOW.contains("schedule:")); + // A scheduled run must publish, a PR run must not. + assert!(BUILD_WORKFLOW.contains("push: ${{ gitea.event_name != 'pull_request' }}")); + } +} diff --git a/container/Dockerfile b/container/Dockerfile index ba4b9c8..0a16ad5 100644 --- a/container/Dockerfile +++ b/container/Dockerfile @@ -35,13 +35,20 @@ ENV DEBIAN_FRONTEND=noninteractive # 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. # -# Note CI builds with `cache-from: type=gha`, so this layer (and everything -# after it) only re-runs when the base digest or this layer's text changes. +# 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). -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/*; \ @@ -288,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/*; \ -- 2.52.0 From cda3a74b8b8d4536be661cc0de779d66928348dd Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 11:02:06 -0800 Subject: [PATCH 20/22] ci(stt): don't fail the build when the runner's gha cache is unreachable The STT job failed on the home runner with 'no route to host' to the act_runner cache service while exporting the build cache. Import errors are warnings, export errors are fatal without ignore-error=true, which build.yml already sets. Ephemeral runners hit the same thing. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build-stt.yml | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/.gitea/workflows/build-stt.yml b/.gitea/workflows/build-stt.yml index 8341957..a8c38c5 100644 --- a/.gitea/workflows/build-stt.yml +++ b/.gitea/workflows/build-stt.yml @@ -55,5 +55,10 @@ jobs: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}:${{ gitea.sha }} ghcr.io/shadowdao/triple-c-stt:latest ghcr.io/shadowdao/triple-c-stt:${{ gitea.sha }} + # The gha cache is served by the runner that picks the job up, and + # is unreachable from the job on some of ours (the home box's + # firewalled per-job bridge; ephemeral runners). Import failures + # only warn, but an export failure fails the build unless told not + # to -- the same setting build.yml already carries. cache-from: type=gha - cache-to: type=gha,mode=max + cache-to: type=gha,mode=max,ignore-error=true -- 2.52.0 From 2ece558fd5291ac66d4ed284589206430f32d193 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 11:14:31 -0800 Subject: [PATCH 21/22] ci: check only file modes after the AppImage repack --appimage-extract creates every directory 0700 regardless of the modes stored in the image (verified by packing a 0755 AppDir and extracting it under umask 022), so the post-repack guard failed on a correct image. Check files, which extract with their stored modes. Co-Authored-By: Claude Opus 5.5 --- scripts/finalize-appimage.sh | 12 +++++++----- 1 file changed, 7 insertions(+), 5 deletions(-) diff --git a/scripts/finalize-appimage.sh b/scripts/finalize-appimage.sh index c7c3385..ed4e41d 100755 --- a/scripts/finalize-appimage.sh +++ b/scripts/finalize-appimage.sh @@ -281,11 +281,13 @@ if [ "$demoted" = true ]; then fi [ -x "$out/usr/bin/triple-c" ] || fail "no executable usr/bin/triple-c." -# Every entry readable by all, every directory and program executable by all — -# what the `chmod -R a+rX` before the repack promised. A 0700 root here is an -# image other users of the machine cannot start. -unreadable="$(find "$out" ! -type l \( ! -perm -a+r -o \( -type d ! -perm -a+x \) \ - -o \( -type f -perm /111 ! -perm -a+x \) \) -print -quit)" +# 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 -- 2.52.0 From 9d48ace61007232509755e420effc87d7ab43d75 Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Mon, 2 Nov 2026 12:01:57 -0800 Subject: [PATCH 22/22] ci: make apt non-interactive so needrestart can't hang ephemeral runners On the eph-pve3 runners needrestart stops after apt-get install at an interactive 'Daemons using outdated libraries' prompt, and the job hangs until it times out (PR #66's build-linux, PR #68's test job). Set NEEDRESTART_MODE=a and DEBIAN_FRONTEND=noninteractive for the workflow and pass them through sudo, which resets the environment. Co-Authored-By: Claude Opus 5.5 --- .gitea/workflows/build-app-preview.yml | 19 +++++++++++++------ .gitea/workflows/build-app.yml | 13 ++++++++++--- 2 files changed, 23 insertions(+), 9 deletions(-) diff --git a/.gitea/workflows/build-app-preview.yml b/.gitea/workflows/build-app-preview.yml index 6ce586d..fb13e68 100644 --- a/.gitea/workflows/build-app-preview.yml +++ b/.gitea/workflows/build-app-preview.yml @@ -61,6 +61,13 @@ name: Build App (Preview) # for real releases. See triple-c#32.) env: + # The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after + # any apt-get install stops at an interactive "Daemons using outdated + # libraries" prompt and hangs the job until it times out. `sudo` resets the + # environment, so the apt-get calls below also pass these via `sudo env`; + # here they reach the NodeSource setup script through `sudo -E`. + NEEDRESTART_MODE: a + DEBIAN_FRONTEND: noninteractive GITEA_URL: ${{ gitea.server_url }} REPO: ${{ gitea.repository }} # How many preview releases survive a run, newest first — including the one @@ -262,7 +269,7 @@ jobs: fi if [ "$NEED_INSTALL" = true ]; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs hash -r fi node --version @@ -273,8 +280,8 @@ jobs: - name: Install system dependencies run: | - sudo apt-get update - sudo apt-get install -y \ + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \ libgtk-3-dev \ libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev \ @@ -344,7 +351,7 @@ jobs: fi if [ "$NEED_INSTALL" = true ]; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs hash -r fi node --version @@ -367,8 +374,8 @@ jobs: - name: Install system dependencies run: | - sudo apt-get update - sudo apt-get install -y \ + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \ libgtk-3-dev \ libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev \ diff --git a/.gitea/workflows/build-app.yml b/.gitea/workflows/build-app.yml index 51719a4..913e535 100644 --- a/.gitea/workflows/build-app.yml +++ b/.gitea/workflows/build-app.yml @@ -17,6 +17,13 @@ on: # was unreachable. Previews now carry the PR check; this workflow is releases. env: + # The ephemeral Proxmox runners (eph-pve3-*) ship needrestart, which after + # any apt-get install stops at an interactive "Daemons using outdated + # libraries" prompt and hangs the job until it times out. `sudo` resets the + # environment, so the apt-get calls below also pass these via `sudo env`; + # here they reach the NodeSource setup script through `sudo -E`. + NEEDRESTART_MODE: a + DEBIAN_FRONTEND: noninteractive GITEA_URL: ${{ gitea.server_url }} REPO: ${{ gitea.repository }} @@ -118,7 +125,7 @@ jobs: fi if [ "$NEED_INSTALL" = true ]; then curl -fsSL https://deb.nodesource.com/setup_22.x | sudo -E bash - - sudo apt-get install -y nodejs + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y nodejs hash -r fi echo "Node.js at: $(which node)" @@ -140,8 +147,8 @@ jobs: - name: Install system dependencies run: | - sudo apt-get update - sudo apt-get install -y \ + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get update + sudo env NEEDRESTART_MODE=a DEBIAN_FRONTEND=noninteractive apt-get install -y \ libgtk-3-dev \ libwebkit2gtk-4.1-dev \ libayatana-appindicator3-dev \ -- 2.52.0