From ea80a99b3c0e074bb837a8585b7d2c13c305c00c Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 10 Sep 2026 16:08:21 +0800 Subject: [PATCH 1/6] [fix] panel: refresh reloads the view you are looking at The header button only ever re-fetched the tool board, so on the key vault and the MCP matrix it spun, moved the timestamp and left the table that was loaded on mount. A header that already names the view you are in has to refresh that view, so the button and the 30s poll both bump a counter every view reads. A failed re-read of the MCP matrix also used to replace a working board with an error banner; it now keeps the matrix and puts the banner above it, which matters more once a re-read happens on a timer. The panel had no tests at all. vitest + Testing Library, mocked at `invoke` because that is the one thing genuinely absent from a test process, with four regressions that fail if either fix is reverted. --- CONTRIBUTING.md | 1 + app/bun.lock | 167 ++++++++++++++++++++++++++- app/package.json | 8 +- app/src/App.tsx | 16 ++- app/src/components/KeysView.test.tsx | 80 +++++++++++++ app/src/components/KeysView.tsx | 8 +- app/src/components/McpView.test.tsx | 78 +++++++++++++ app/src/components/McpView.tsx | 23 +++- app/src/test/setup.ts | 10 ++ app/tsconfig.node.json | 2 +- app/vitest.config.ts | 19 +++ 11 files changed, 400 insertions(+), 12 deletions(-) create mode 100644 app/src/components/KeysView.test.tsx create mode 100644 app/src/components/McpView.test.tsx create mode 100644 app/src/test/setup.ts create mode 100644 app/vitest.config.ts diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index cfcfb84..d0debb6 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -27,6 +27,7 @@ cd app bun install bunx tauri dev # panel + hot-reloading front end bunx tauri build # bundle a .app / .dmg +bun run test # the front end's vitest suite (`--run` for one pass) ``` ## Before you open a PR diff --git a/app/bun.lock b/app/bun.lock index 14078bd..d3e342d 100644 --- a/app/bun.lock +++ b/app/bun.lock @@ -12,15 +12,26 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^30.0.1", "typescript": "~5.8.3", "vite": "^7.0.4", + "vitest": "^5.0.0", }, }, }, "packages": { + "@adobe/css-tools": ["@adobe/css-tools@4.5.0", "", {}, "sha512-6OzddxPio9UiWTCemp4N8cYLV2ZN1ncRnV1cVGtve7dhPOtRkleRyx32GQCYSwDYgaHU3USMm84tNsvKzRCa1Q=="], + + "@asamuzakjp/css-color": ["@asamuzakjp/css-color@6.0.7", "", { "dependencies": { "@csstools/css-calc": "^3.3.0", "@csstools/css-color-parser": "^4.1.10", "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0", "lru-cache": "^11.5.2" } }, "sha512-vC/bk1Lz7Tn/EfU9/apOTBk80/8dyGyWMowPoV1tJ52muDGsDqt2HPT2klrFUiY60MQmQv9q8yIht15JnBgDGw=="], + + "@asamuzakjp/dom-selector": ["@asamuzakjp/dom-selector@8.3.2", "", { "dependencies": { "bidi-js": "^1.0.3", "css-tree": "^3.2.1", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2" } }, "sha512-93Z1N+BQNXysodoicpOIyNh2drHfz/CTf9nnT0FEx72GJcIiwgydD7tGAr78j41LsYn3hlRn+LdGPuBLn1Bl8Q=="], + "@babel/code-frame": ["@babel/code-frame@7.29.7", "", { "dependencies": { "@babel/helper-validator-identifier": "^7.29.7", "js-tokens": "^4.0.0", "picocolors": "^1.1.1" } }, "sha512-Aup7aUOfpbAUg2ROOJN6Iw5f9DMBlzu0mIkm/malLQFN/YQgO48wCj0Kxa3sEHJvPVFg7siR+qRInwXd2qhQKw=="], "@babel/compat-data": ["@babel/compat-data@7.29.7", "", {}, "sha512-locTkQyKvwIEgBzVrn8693ebc97F2U8ZHjbXwDXJ5Fn2TCpNwTlKcaKLkdHop5c/icOFE7qt7Q9JC5hnKNa6Gg=="], @@ -53,12 +64,28 @@ "@babel/plugin-transform-react-jsx-source": ["@babel/plugin-transform-react-jsx-source@7.29.7", "", { "dependencies": { "@babel/helper-plugin-utils": "^7.29.7" }, "peerDependencies": { "@babel/core": "^7.0.0-0" } }, "sha512-06IyK09H3wi4cGbhDBwp5gUGo0IKtnYa8tyTiephirPCK6fbobVGiXMMI5zLQ4aKEYP3wZ3ArU44o+8KMrSG/Q=="], + "@babel/runtime": ["@babel/runtime@7.29.7", "", {}, "sha512-Nq8OhGWiZIZGV6hLHoyAKLLcJihP/xFeBMGJoUrxTX2psI8dCifzLhZISFb+VWS3wFMRDmCGw5R+dOySCqPLhw=="], + "@babel/template": ["@babel/template@7.29.7", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/parser": "^7.29.7", "@babel/types": "^7.29.7" } }, "sha512-puq+Gf35oI24FeN11LkoUQFqv9uwNeWpxXZi/Ji3rRIoKAzKnxRaZ+Gkj0vKS9ZCiTESfng1N9LyOyXvo+m+Gg=="], "@babel/traverse": ["@babel/traverse@7.29.8", "", { "dependencies": { "@babel/code-frame": "^7.29.7", "@babel/generator": "^7.29.8", "@babel/helper-globals": "^7.29.7", "@babel/parser": "^7.29.8", "@babel/template": "^7.29.7", "@babel/types": "^7.29.8", "debug": "^4.3.1" } }, "sha512-I5z7H3bf/41ktsNVLtpN0wAa336HkqIHQ5BuPLEhTkt1jVSyZpeNKIzTgEWmlxjdg81R0IgUCcaE+Ok3NvrfZg=="], "@babel/types": ["@babel/types@7.29.8", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-Vj1jF3cPfxg7OAfoI7QnVKLoILlm2JF9pnVHrX8qx7AHMiYWT+NDAA7jChlNgRS4WTLc/fD1lXLmPixluj+3Gg=="], + "@bramus/specificity": ["@bramus/specificity@2.4.2", "", { "dependencies": { "css-tree": "^3.0.0" }, "bin": { "specificity": "bin/cli.js" } }, "sha512-ctxtJ/eA+t+6q2++vj5j7FYX3nRu311q1wfYH3xjlLOsczhlhxAg2FWNUXhpGvAw3BWo1xBcvOV6/YLc2r5FJw=="], + + "@csstools/color-helpers": ["@csstools/color-helpers@6.1.1", "", {}, "sha512-gLNsunvwf3mCi5u5o46/Z/JcJMnhbHSaZ69rkgPzNM3J4s8hWwpPUQB6/tt0EDFyCiWzxANlx+2LJwpYj4zS1w=="], + + "@csstools/css-calc": ["@csstools/css-calc@3.3.0", "", { "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-c5ihYsPkdG6JCkU2zTMm4+k6r7RXuGxtWYhu5DHMIiF1FHzrfmHL5so11AoFpUv/tu61xfcmT4AmKoFfMPoqdQ=="], + + "@csstools/css-color-parser": ["@csstools/css-color-parser@4.2.2", "", { "dependencies": { "@csstools/color-helpers": "^6.1.1", "@csstools/css-calc": "^3.3.0" }, "peerDependencies": { "@csstools/css-parser-algorithms": "^4.0.0", "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-3QKjR/vxyjcSXBLgb6lP0S3MGdvwbmqSsvLPbYdVORqPDc8FX1HAJ0Spk38bxaRXgvENTA47tlhhbb5Z2e8hEg=="], + + "@csstools/css-parser-algorithms": ["@csstools/css-parser-algorithms@4.0.0", "", { "peerDependencies": { "@csstools/css-tokenizer": "^4.0.0" } }, "sha512-+B87qS7fIG3L5h3qwJ/IFbjoVoOe/bpOdh9hAjXbvx0o8ImEmUsGXN0inFOnk2ChCFgqkkGFQ+TpM5rbhkKe4w=="], + + "@csstools/css-syntax-patches-for-csstree": ["@csstools/css-syntax-patches-for-csstree@1.1.12", "", { "peerDependencies": { "css-tree": "^3.2.1" }, "optionalPeers": ["css-tree"] }, "sha512-3vLQK+dXxhBMR2Wx99PTCifE+vHtW2ndZWyla8yK813ev6oGhyn8Lja8jCyGAWTJ+LEYZK7EVtJxrDj8ztevJw=="], + + "@csstools/css-tokenizer": ["@csstools/css-tokenizer@4.0.0", "", {}, "sha512-QxULHAm7cNu72w97JUNCBFODFaXpbDg+dP8b/oWFAZ2MTRppA3U00Y2L1HqaS4J6yBqxwa/Y3nMBaxVKbB/NsA=="], + "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.28.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-XExcO+dvLKvVtNTibSTBej1NCAbaGhWn9Ww1ZPx80qsahhPFe/8jgWP0IchNe0F3HwkU7n8ejhH8bjonqht8mQ=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.28.2", "", { "os": "android", "cpu": "arm" }, "sha512-kXXoiPVVGQcnIYGOeaovwOURpniDBpSq4A03qkQ+BMQqtGG6HYap3xne9C1O1yo4TR3qxlCX5IqqmX6fFo2Lqg=="], @@ -111,13 +138,15 @@ "@esbuild/win32-x64": ["@esbuild/win32-x64@0.28.2", "", { "os": "win32", "cpu": "x64" }, "sha512-5ebpxr3nWMzrL/rnUI755Jkuee0bHL/Gq0WTF9lvcpv73wAp5eu8MfBUgWK9bhWvZjj7yX8etf/8tI8Ney695g=="], + "@exodus/bytes": ["@exodus/bytes@1.15.1", "", { "peerDependencies": { "@noble/hashes": "^1.8.0 || ^2.0.0" }, "optionalPeers": ["@noble/hashes"] }, "sha512-S6mL0yNB/Abt9Ei4tq8gDhcczc4S3+vQ4ra7vxnAf+YHC02srtqxKKZghx2Dq6p0e66THKwR6r8N6P95wEty7Q=="], + "@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="], "@jridgewell/remapping": ["@jridgewell/remapping@2.3.5", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-LI9u/+laYG4Ds1TDKSJW2YPrIlcVYOwi2fUC6xB43lueCjgxV4lffOCZCtYFiH6TNOX+tQKXx97T4IKHbhyHEQ=="], "@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="], - "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.6.0", "", {}, "sha512-T7jf+5zgsZHwNJ4lvQ7/aezbyk0nNX+zJVWpmHA7VYsEx7a7qr5Rg5IbtJFqkgze5Y2sruq1RUY8Q837Od7iFw=="], "@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="], @@ -205,6 +234,14 @@ "@tauri-apps/plugin-updater": ["@tauri-apps/plugin-updater@2.10.1", "", { "dependencies": { "@tauri-apps/api": "^2.10.1" } }, "sha512-NFYMg+tWOZPJdzE/PpFj2qfqwAWwNS3kXrb1tm1gnBJ9mYzZ4WDRrwy8udzWoAnfGCHLuePNLY1WVCNHnh3eRA=="], + "@testing-library/dom": ["@testing-library/dom@10.4.1", "", { "dependencies": { "@babel/code-frame": "^7.10.4", "@babel/runtime": "^7.12.5", "@types/aria-query": "^5.0.1", "aria-query": "5.3.0", "dom-accessibility-api": "^0.5.9", "lz-string": "^1.5.0", "picocolors": "1.1.1", "pretty-format": "^27.0.2" } }, "sha512-o4PXJQidqJl82ckFaXUeoAW+XysPLauYI43Abki5hABd853iMhitooc6znOnczgbTYmEP6U6/y1ZyKAIsvMKGg=="], + + "@testing-library/jest-dom": ["@testing-library/jest-dom@7.0.1", "", { "dependencies": { "@adobe/css-tools": "^4.4.0", "aria-query": "^5.0.0", "css.escape": "^1.5.1", "dom-accessibility-api": "^0.6.3", "picocolors": "^1.1.1", "redent": "^3.0.0" }, "peerDependencies": { "@testing-library/dom": ">=10 <11", "vitest": ">= 0.32" }, "optionalPeers": ["vitest"] }, "sha512-oMDTC3oA+6CXSO2JZnvOI7CA6oVub6kij5ggk9ohwye5slmkwxYDXcPOVxgMw/RQlticjtO0C1RZkR97HgrWMw=="], + + "@testing-library/react": ["@testing-library/react@16.3.3", "", { "dependencies": { "@babel/runtime": "^7.12.5" }, "peerDependencies": { "@testing-library/dom": "^10.0.0", "@types/react": "^18.0.0 || ^19.0.0", "@types/react-dom": "^18.0.0 || ^19.0.0", "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" }, "optionalPeers": ["@types/react", "@types/react-dom"] }, "sha512-Uo193NgQbPMz6lrrhtRQQFcMC6Re/ELLFbbuVL30WDlZxlpZf9/lMHTAVxPRLw1q1iu9OJmR1c2BLiENRstdBg=="], + + "@types/aria-query": ["@types/aria-query@5.0.4", "", {}, "sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw=="], + "@types/babel__core": ["@types/babel__core@7.20.5", "", { "dependencies": { "@babel/parser": "^7.20.7", "@babel/types": "^7.20.7", "@types/babel__generator": "*", "@types/babel__template": "*", "@types/babel__traverse": "*" } }, "sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA=="], "@types/babel__generator": ["@types/babel__generator@7.27.0", "", { "dependencies": { "@babel/types": "^7.0.0" } }, "sha512-ufFd2Xi92OAVPYsy+P4n7/U7e68fex0+Ee8gSG9KX7eo084CWiQ4sdxktvdl0bOPupXtVJPY19zk6EwWqUQ8lg=="], @@ -213,6 +250,10 @@ "@types/babel__traverse": ["@types/babel__traverse@7.28.0", "", { "dependencies": { "@babel/types": "^7.28.2" } }, "sha512-8PvcXf70gTDZBgt9ptxJ8elBeBjcLOAcOtoO/mPJjtji1+CdGbHgm77om1GrsPxsiE+uXIpNSK64UYaIwQXd4Q=="], + "@types/chai": ["@types/chai@5.2.3", "", { "dependencies": { "@types/deep-eql": "*", "assertion-error": "^2.0.1" } }, "sha512-Mw558oeA9fFbv65/y4mHtXDs9bPnFMZAL/jxdPFUpOHHIXX91mcgEHbS5Lahr+pwZFR8A7GQleRWeI6cGFC2UA=="], + + "@types/deep-eql": ["@types/deep-eql@4.0.2", "", {}, "sha512-c9h9dVVMigMPc4bwTvC5dxqtqJZwQPePsWjPlpSOnojbor6pGqdk541lfA7AqFQr5pB1BRdq0juY9db81BwyFw=="], + "@types/estree": ["@types/estree@1.0.9", "", {}, "sha512-GhdPgy1el4/ImP05X05Uw4cw2/M93BCUmnEvWZNStlCzEKME4Fkk+YpoA5OiHNQmoS7Cafb8Xa3Pya8m1Qrzeg=="], "@types/react": ["@types/react@19.2.18", "", { "dependencies": { "csstype": "^3.2.2" } }, "sha512-AnzbBERsrLKtk2XSfTbYRLjQPdy116Sty4q+T+Bp3IC4l6jNBvreVPAHmpq9qhXQM7CXZPjLVmGMw9sy+hxQ3w=="], @@ -221,37 +262,89 @@ "@vitejs/plugin-react": ["@vitejs/plugin-react@4.7.0", "", { "dependencies": { "@babel/core": "^7.28.0", "@babel/plugin-transform-react-jsx-self": "^7.27.1", "@babel/plugin-transform-react-jsx-source": "^7.27.1", "@rolldown/pluginutils": "1.0.0-beta.27", "@types/babel__core": "^7.20.5", "react-refresh": "^0.17.0" }, "peerDependencies": { "vite": "^4.2.0 || ^5.0.0 || ^6.0.0 || ^7.0.0" } }, "sha512-gUu9hwfWvvEDBBmgtAowQCojwZmJ5mcLn3aufeCsitijs3+f2NsrPtlAWIR6OPiqljl96GVCUbLe0HyqIpVaoA=="], + "@vitest/mocker": ["@vitest/mocker@5.0.0", "", { "dependencies": { "@jridgewell/trace-mapping": "0.3.31", "@vitest/spy": "5.0.0", "estree-walker": "^3.0.3", "magic-string": "^1.2.3" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^6.0.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-66PGTMIiVJP3t4a5yxU9qPtf7MdTBs8jmToMvy+HVflB3Yy13WJZTtPePdvU+wjRV02SKK5doLbSA6o9pwOmiA=="], + + "@vitest/spy": ["@vitest/spy@5.0.0", "", {}, "sha512-uy+luWBAPw9XfthoHi5AkfHUnuPYEESjl0p/r+meoBnU8bxg5GDQ3Ey8MjcJ6sqahkL4PFyrvfMJJBw7LbU06g=="], + + "ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="], + + "ansi-styles": ["ansi-styles@5.2.0", "", {}, "sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA=="], + + "aria-query": ["aria-query@5.3.0", "", { "dependencies": { "dequal": "^2.0.3" } }, "sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A=="], + + "assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="], + "baseline-browser-mapping": ["baseline-browser-mapping@2.11.13", "", { "bin": { "baseline-browser-mapping": "dist/cli.cjs" } }, "sha512-k9HNuUVMlqVjQ9UHzfPjIqiDbWw7WqT1AoT7GL8VwvF3r0ZfArtgiSPAlmupyNquNgOJHTuH4CKYf8ttMTWBTQ=="], + "bidi-js": ["bidi-js@1.1.0", "", { "dependencies": { "require-from-string": "^2.0.2" } }, "sha512-fX1Onk0tdVPC7obPWB5EbJ1z7NVhLq4m2xZLq2YXBkxzMXIGRpNMU88n0EPgWseKl12J7zXs7qrDxPK4sRs2fg=="], + "browserslist": ["browserslist@4.28.8", "", { "dependencies": { "baseline-browser-mapping": "^2.11.12", "caniuse-lite": "^1.0.30001809", "electron-to-chromium": "^1.5.402", "node-releases": "^2.0.53", "update-browserslist-db": "^1.3.0" }, "bin": { "browserslist": "cli.js" } }, "sha512-V2NpofLblG64mfOtSgDhOJESZEGogzDMBv/q+W6oc4LXWP/q75eOXoOaaOu1EOadB9U4Bwx/e0yzbvwKH8zalA=="], "caniuse-lite": ["caniuse-lite@1.0.30001809", "", {}, "sha512-xxWVywk6a6Arlk+hymeycyn/VgqEfLDxupvhH/xiY5SJ/18kmi9o6MiO320DCUzypORHLtvh0I4i04tUhCNHNQ=="], + "chai": ["chai@6.2.2", "", {}, "sha512-NUPRluOfOiTKBKvWPtSD4PhFvWCqOi0BGStNWs57X9js7XGTprSmFoz5F0tWhR4WPjNeR9jXqdC7/UpSJTnlRg=="], + "convert-source-map": ["convert-source-map@2.0.0", "", {}, "sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg=="], + "css-tree": ["css-tree@3.2.1", "", { "dependencies": { "mdn-data": "2.27.1", "source-map-js": "^1.2.1" } }, "sha512-X7sjQzceUhu1u7Y/ylrRZFU2FS6LRiFVp6rKLPg23y3x3c3DOKAwuXGDp+PAGjh6CSnCjYeAul8pcT8bAl+lSA=="], + + "css.escape": ["css.escape@1.5.1", "", {}, "sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg=="], + "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], + "data-urls": ["data-urls@7.0.0", "", { "dependencies": { "whatwg-mimetype": "^5.0.0", "whatwg-url": "^16.0.0" } }, "sha512-23XHcCF+coGYevirZceTVD7NdJOqVn+49IHyxgszm+JIiHLoB2TkmPtsYkNWT1pvRSGkc35L6NHs0yHkN2SumA=="], + "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], + "decimal.js": ["decimal.js@10.6.0", "", {}, "sha512-YpgQiITW3JXGntzdUmyUR1V812Hn8T1YVXhCu+wO3OpS4eU9l4YdD3qjyiKdV6mvV29zapkMeD390UVEf2lkUg=="], + + "dequal": ["dequal@2.0.3", "", {}, "sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA=="], + + "dom-accessibility-api": ["dom-accessibility-api@0.5.16", "", {}, "sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg=="], + "electron-to-chromium": ["electron-to-chromium@1.5.405", "", {}, "sha512-bNglH7lPH5l+yHOes7Zr4VqxhOy4BQ9ZBUX4VdoFgxMpzJk7W1ZoO3Vgd9Pxa9PyjQ76sfm2aKH/nzEcCNRlew=="], + "entities": ["entities@8.1.0", "", {}, "sha512-kxL7msIffSuh9aaFAMD7rxAIuTRMAHMeBtgHW2yUdWw732ZNh4MehkF2gdjvtdmikkaIP9bFDDJOPlsvm7avrA=="], + + "es-module-lexer": ["es-module-lexer@2.3.2", "", {}, "sha512-poHGpORABojJJucnV9KbOavETW8lBVnphkW77ER5/BQ5Fz7oXSoCNek7IH3vR5nRjdsEz926ibFYX8KtLQmdyw=="], + "esbuild": ["esbuild@0.28.2", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.28.2", "@esbuild/android-arm": "0.28.2", "@esbuild/android-arm64": "0.28.2", "@esbuild/android-x64": "0.28.2", "@esbuild/darwin-arm64": "0.28.2", "@esbuild/darwin-x64": "0.28.2", "@esbuild/freebsd-arm64": "0.28.2", "@esbuild/freebsd-x64": "0.28.2", "@esbuild/linux-arm": "0.28.2", "@esbuild/linux-arm64": "0.28.2", "@esbuild/linux-ia32": "0.28.2", "@esbuild/linux-loong64": "0.28.2", "@esbuild/linux-mips64el": "0.28.2", "@esbuild/linux-ppc64": "0.28.2", "@esbuild/linux-riscv64": "0.28.2", "@esbuild/linux-s390x": "0.28.2", "@esbuild/linux-x64": "0.28.2", "@esbuild/netbsd-arm64": "0.28.2", "@esbuild/netbsd-x64": "0.28.2", "@esbuild/openbsd-arm64": "0.28.2", "@esbuild/openbsd-x64": "0.28.2", "@esbuild/openharmony-arm64": "0.28.2", "@esbuild/sunos-x64": "0.28.2", "@esbuild/win32-arm64": "0.28.2", "@esbuild/win32-ia32": "0.28.2", "@esbuild/win32-x64": "0.28.2" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-HKVLS8dvII+xoKW9kmqxbRKrnWEXfJJr/FZhhJmiqIB0e053QNYFqOBouTMO/k5sID4MvCiUCvv8b9M4h32wIA=="], "escalade": ["escalade@3.2.0", "", {}, "sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA=="], + "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="], + + "expect-type": ["expect-type@1.4.0", "", {}, "sha512-KfYbmpRm0VbLjEvVa9yGwCi9GI34xvi7A/HXYWQO65CSD2u3MczUJSuwXKFIxlGsgBQizV9q5J9NHj4VG0n+pA=="], + "fdir": ["fdir@6.5.0", "", { "peerDependencies": { "picomatch": "^3 || ^4" }, "optionalPeers": ["picomatch"] }, "sha512-tIbYtZbucOs0BRGqPJkshJUYdL+SDH7dVM8gjy+ERp3WAUjLEFJE+02kanyHtwjWOnwrKYBiwAmM0p4kLJAnXg=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "gensync": ["gensync@1.0.0-beta.2", "", {}, "sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg=="], + "html-encoding-sniffer": ["html-encoding-sniffer@6.0.0", "", { "dependencies": { "@exodus/bytes": "^1.6.0" } }, "sha512-CV9TW3Y3f8/wT0BRFc1/KAVQ3TUHiXmaAb6VW9vtiMFf7SLoMd1PdAc4W3KFOFETBJUb90KatHqlsZMWV+R9Gg=="], + + "indent-string": ["indent-string@4.0.0", "", {}, "sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg=="], + + "is-potential-custom-element-name": ["is-potential-custom-element-name@1.0.1", "", {}, "sha512-bCYeRA2rVibKZd+s2625gGnGF/t7DSqDs4dP7CrLA1m7jKWz6pps0LpYLJN8Q64HtmPKJ1hrN3nzPNKFEKOUiQ=="], + "js-tokens": ["js-tokens@4.0.0", "", {}, "sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ=="], + "jsdom": ["jsdom@30.0.1", "", { "dependencies": { "@asamuzakjp/css-color": "^6.0.5", "@asamuzakjp/dom-selector": "^8.3.0", "@bramus/specificity": "^2.4.2", "@csstools/css-syntax-patches-for-csstree": "^1.1.7", "@exodus/bytes": "^1.15.1", "css-tree": "^3.2.1", "data-urls": "^7.0.0", "decimal.js": "^10.6.0", "html-encoding-sniffer": "^6.0.0", "is-potential-custom-element-name": "^1.0.1", "lru-cache": "^11.5.2", "parse5": "^8.0.1", "saxes": "^6.0.0", "symbol-tree": "^3.2.4", "tough-cookie": "^6.0.2", "undici": "^8.9.0", "w3c-xmlserializer": "^5.0.0", "webidl-conversions": "^8.0.1", "whatwg-mimetype": "^5.0.0", "whatwg-url": "^17.1.0", "xml-name-validator": "^5.0.0" }, "peerDependencies": { "canvas": "^3.2.3" }, "optionalPeers": ["canvas"] }, "sha512-52v7mUVUfNQVYYqE1lcdaymWL0njO7lTLUog6ZvW2U5KsbiLk/GnZlVJ+qx0xfNJZ6Gn+KSpPNE52vurbxZwrA=="], + "jsesc": ["jsesc@3.1.0", "", { "bin": { "jsesc": "bin/jsesc" } }, "sha512-/sM3dO2FOzXjKQhJuo0Q173wf2KOo8t4I8vHy6lF9poUp7bKT0/NHE8fPX23PwfhnykfqnC2xRxOnVw5XuGIaA=="], "json5": ["json5@2.2.3", "", { "bin": { "json5": "lib/cli.js" } }, "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="], - "lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + "lru-cache": ["lru-cache@11.5.2", "", {}, "sha512-4pfM1Ff0x50o0tQwb5ucw/RzNyD0/YJME6IVcStalZuMWxdt3sR3huStTtxz4PUmvZfRguvDejasvQ2kifR11g=="], + + "lz-string": ["lz-string@1.5.0", "", { "bin": { "lz-string": "bin/bin.js" } }, "sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ=="], + + "magic-string": ["magic-string@1.3.1", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.6.0" } }, "sha512-rm91zr2Ou+XueDTohjQQjdQEcYM6zVi8KVUCG8Ec3vHwUEKrhSdCNyfuIywkA6hcCAteIn0ZOtAHA6eGpiX+Pg=="], + + "mdn-data": ["mdn-data@2.27.1", "", {}, "sha512-9Yubnt3e8A0OKwxYSXyhLymGW4sCufcLG6VdiDdUGVkPhpqLxlvP5vl1983gQjJl3tqbrM731mjaZaP68AgosQ=="], + + "min-indent": ["min-indent@1.0.1", "", {}, "sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], @@ -259,34 +352,104 @@ "node-releases": ["node-releases@2.0.53", "", {}, "sha512-D9UOmYG3UH1V+ENW56t5QXBwJw1YEY18ruVeus89Rw+SyIgjPkCO84bRzO3uNIYosJbNwiabWVn48o3uJLjxFQ=="], + "obug": ["obug@2.2.1", "", {}, "sha512-XrsrhT5sybtKI6wakr2SPOlGZWWYbUXZ7a0jT8/QOeAPau+1X/bSegNe5YR75oJmEZQbKningirmGOEJCIk61Q=="], + + "parse5": ["parse5@8.0.1", "", { "dependencies": { "entities": "^8.0.0" } }, "sha512-z1e/HMG90obSGeidlli3hj7cbocou0/wa5HacvI3ASx34PecNjNQeaHNo5WIZpWofN9kgkqV1q5YvXe3F0FoPw=="], + "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picomatch": ["picomatch@4.0.5", "", {}, "sha512-RvwwcruNjI1ncT5xRakeyS9Lf8lcItv34KD+aif+VH9kduAyfYBipGh12274xtenIPZ119/R9BdTBa8gAwSh0A=="], "postcss": ["postcss@8.5.26", "", { "dependencies": { "nanoid": "^3.3.17", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-u82N74LFzG8ca+dD8puPnplTXoGH4fTPpVGuIbt36G3qvNlkvfD0lEAZSxaly3KX8TS/L1A1gsCEmvKmBcVbkQ=="], + "pretty-format": ["pretty-format@27.5.1", "", { "dependencies": { "ansi-regex": "^5.0.1", "ansi-styles": "^5.0.0", "react-is": "^17.0.1" } }, "sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ=="], + + "punycode": ["punycode@2.3.1", "", {}, "sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg=="], + "react": ["react@19.2.8", "", {}, "sha512-PWaYA1L/q9u2u7xYQi+Y3L3Yfnie7XyLeaJICV1MGD6LprsBxcAqGjYyr0eY3p+QdsA+x/Irkt4Qif8D63+Sbw=="], "react-dom": ["react-dom@19.2.8", "", { "dependencies": { "scheduler": "^0.27.0" }, "peerDependencies": { "react": "^19.2.8" } }, "sha512-rVprimfGBG3DR+Tq0IQG2DT5PxKth1WIGDmj5yPmlzr4YBe7uyE+Du4oVqTDXZSHGGGXRtTJEGSSePyQCMBglQ=="], + "react-is": ["react-is@17.0.2", "", {}, "sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w=="], + "react-refresh": ["react-refresh@0.17.0", "", {}, "sha512-z6F7K9bV85EfseRCp2bzrpyQ0Gkw1uLoCel9XBVWPg/TjRj94SkJzUTGfOa4bs7iJvBWtQG0Wq7wnI0syw3EBQ=="], + "redent": ["redent@3.0.0", "", { "dependencies": { "indent-string": "^4.0.0", "strip-indent": "^3.0.0" } }, "sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg=="], + + "require-from-string": ["require-from-string@2.0.2", "", {}, "sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw=="], + "rollup": ["rollup@4.62.4", "", { "dependencies": { "@types/estree": "1.0.9" }, "optionalDependencies": { "@napi-rs/lzma-linux-x64-gnu": "1.5.1", "@rollup/rollup-android-arm-eabi": "4.62.4", "@rollup/rollup-android-arm64": "4.62.4", "@rollup/rollup-darwin-arm64": "4.62.4", "@rollup/rollup-darwin-x64": "4.62.4", "@rollup/rollup-freebsd-arm64": "4.62.4", "@rollup/rollup-freebsd-x64": "4.62.4", "@rollup/rollup-linux-arm-gnueabihf": "4.62.4", "@rollup/rollup-linux-arm-musleabihf": "4.62.4", "@rollup/rollup-linux-arm64-gnu": "4.62.4", "@rollup/rollup-linux-arm64-musl": "4.62.4", "@rollup/rollup-linux-loong64-gnu": "4.62.4", "@rollup/rollup-linux-loong64-musl": "4.62.4", "@rollup/rollup-linux-ppc64-gnu": "4.62.4", "@rollup/rollup-linux-ppc64-musl": "4.62.4", "@rollup/rollup-linux-riscv64-gnu": "4.62.4", "@rollup/rollup-linux-riscv64-musl": "4.62.4", "@rollup/rollup-linux-s390x-gnu": "4.62.4", "@rollup/rollup-linux-x64-gnu": "4.62.4", "@rollup/rollup-linux-x64-musl": "4.62.4", "@rollup/rollup-openbsd-x64": "4.62.4", "@rollup/rollup-openharmony-arm64": "4.62.4", "@rollup/rollup-win32-arm64-msvc": "4.62.4", "@rollup/rollup-win32-ia32-msvc": "4.62.4", "@rollup/rollup-win32-x64-gnu": "4.62.4", "@rollup/rollup-win32-x64-msvc": "4.62.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-RXOqwaPsBGjMNMa4sQjDjHieHEZDFoj/Rdr46l2MU5DfEs16wHJPC2RPTPHWhNl+M3aI472LLqFkFKut4SblOg=="], + "saxes": ["saxes@6.0.0", "", { "dependencies": { "xmlchars": "^2.2.0" } }, "sha512-xAg7SOnEhrm5zI3puOOKyy1OMcMlIJZYNJY7xLBwSze0UjhPLnWfj2GF2EpT0jmzaJKIWKHLsaSSajf35bcYnA=="], + "scheduler": ["scheduler@0.27.0", "", {}, "sha512-eNv+WrVbKu1f3vbYJT/xtiF5syA5HPIMtf9IgY/nKg0sWqzAUEvqY/xm7OcZc/qafLx/iO9FgOmeSAp4v5ti/Q=="], "semver": ["semver@6.3.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA=="], + "siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="], + "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], + "stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="], + + "std-env": ["std-env@4.2.0", "", {}, "sha512-oCUKSupKTHX53EyjDtuZQ64pjLJ6yYCtpmEw0goYxtjG9KpbRe8KAsl2tBUGU9DyMcJ0RwJ8GqJAFzMXcXW1Rw=="], + + "strip-indent": ["strip-indent@3.0.0", "", { "dependencies": { "min-indent": "^1.0.0" } }, "sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ=="], + + "symbol-tree": ["symbol-tree@3.2.4", "", {}, "sha512-9QNk5KwDF+Bvz+PyObkmSYjI5ksVUYtjW7AU22r2NKcfLJcXp96hkDWU3+XndOsUb+AQ9QhfzfCT2O+CNWT5Tw=="], + + "tinybench": ["tinybench@6.1.4", "", {}, "sha512-9APumHG7r4yOk4X4WlkmE71aZcv1gvin1czO3OQ1U9iJcFA5Ja/ygyb0vPOVHTthFozUYs8CLoLUlM8grb2lTQ=="], + + "tinyexec": ["tinyexec@1.3.0", "", {}, "sha512-QKAl9m8gWWGHV8jZcPeym6j+XULi6tOf1mT83WYJ4Lk2ytW/uwAWkrP0uFsdoYMdueVJ0qs26wZ+23xeB4ibNQ=="], + "tinyglobby": ["tinyglobby@0.2.17", "", { "dependencies": { "fdir": "^6.5.0", "picomatch": "^4.0.4" } }, "sha512-wXR/dYpcqKmfWpEdZjiKJOwCNFndD0DMnrW/cYjVGttEkBfVgcLFHoNrlj47mjOVic9yyNu65alsgF4NQyTa2g=="], + "tldts": ["tldts@7.4.12", "", { "dependencies": { "tldts-core": "^7.4.12" }, "bin": { "tldts": "bin/cli.js" } }, "sha512-WylhSDKVeYnWXL3a+vKTaOxjnOeEGw938hImY8zoRWJjRRK/Jp1K+IihBzIONpUmW4e3WmXT6q5FW6vlESVZCA=="], + + "tldts-core": ["tldts-core@7.4.12", "", {}, "sha512-nYNzS2WRf4QJmjzFFgAxLOBjyBxAGRbCy9PVBPaglcYyYajh40VBn+v5Ngr96ZMc7oM0+aCJdtQnNejvdBnXMQ=="], + + "tough-cookie": ["tough-cookie@6.0.2", "", { "dependencies": { "tldts": "^7.0.5" } }, "sha512-exgYmnmL/sJpR3upZfXG5PoatXQii55xAiXGXzY+sROLZ/Y+SLcp9PgJNI9Vz37HpQ74WvDcLT8eqm+kV3FzrA=="], + + "tr46": ["tr46@6.0.0", "", { "dependencies": { "punycode": "^2.3.1" } }, "sha512-bLVMLPtstlZ4iMQHpFHTR7GAGj2jxi8Dg0s2h2MafAE4uSWF98FC/3MomU51iQAMf8/qDUbKWf5GxuvvVcXEhw=="], + "typescript": ["typescript@5.8.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-p1diW6TqL9L07nNxvRMM7hMMw4c5XOo/1ibL4aAIGmSAt9slTE1Xgw5KWuof2uTOvCg9BY7ZRi+GaF+7sfgPeQ=="], + "undici": ["undici@8.10.2", "", {}, "sha512-/y4/bH9YNU5hi9NIrpOuvGXFcxrj3CMrV+/AYpowAYTpHn8gX/XPFjNy766FPoYY0miQhdW977JFWKGNhBdwyQ=="], + "update-browserslist-db": ["update-browserslist-db@1.3.1", "", { "dependencies": { "escalade": "^3.2.0", "picocolors": "^1.1.1" }, "peerDependencies": { "browserslist": ">= 4.21.0" }, "bin": { "update-browserslist-db": "cli.js" } }, "sha512-ZZ61DsRsOnakl74HAmp3oSN4aXUmEWXf+i/yv0h7tIBfICc3VdrFErQKUUKPgu3AMsTUMbcongALEN4l6GSUrQ=="], "vite": ["vite@7.3.6", "", { "dependencies": { "esbuild": "^0.27.0 || ^0.28.0", "fdir": "^6.5.0", "picomatch": "^4.0.3", "postcss": "^8.5.6", "rollup": "^4.43.0", "tinyglobby": "^0.2.15" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^20.19.0 || >=22.12.0", "jiti": ">=1.21.0", "less": "^4.0.0", "lightningcss": "^1.21.0", "sass": "^1.70.0", "sass-embedded": "^1.70.0", "stylus": ">=0.54.8", "sugarss": "^5.0.0", "terser": "^5.16.0", "tsx": "^4.8.1", "yaml": "^2.4.2" }, "optionalPeers": ["@types/node", "jiti", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser", "tsx", "yaml"], "bin": { "vite": "bin/vite.js" } }, "sha512-4XP60spRGjSZFf1qYH+dJIkK2znL3zQfl9KkOV9MkkRR/3Dls0dxaBsQPTloEc5BLXWPL9vsOxopxyKoMmDueg=="], + "vitest": ["vitest@5.0.0", "", { "dependencies": { "@types/chai": "^5.2.2", "@vitest/mocker": "5.0.0", "chai": "^6.2.2", "es-module-lexer": "^2.3.2", "expect-type": "^1.4.0", "magic-string": "^1.2.3", "obug": "^2.1.4", "picomatch": "^4.0.7", "std-env": "^4.2.0", "tinybench": "6.1.4", "tinyexec": "1.3.0", "tinyglobby": "^0.2.17", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@opentelemetry/api": "^1.9.0", "@types/node": "^22.0.0 || >=24.0.0", "@vitest/browser-playwright": "5.0.0", "@vitest/browser-preview": "5.0.0", "@vitest/browser-webdriverio": "^5.0.0-beta.5 || >=5.0.0", "@vitest/coverage-istanbul": "5.0.0", "@vitest/coverage-v8": "5.0.0", "@vitest/ui": "5.0.0", "happy-dom": "*", "jsdom": "*", "vite": "^6.4.0 || ^7.0.0 || ^8.0.0" }, "optionalPeers": ["@edge-runtime/vm", "@opentelemetry/api", "@types/node", "@vitest/browser-playwright", "@vitest/browser-preview", "@vitest/browser-webdriverio", "@vitest/coverage-istanbul", "@vitest/coverage-v8", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "./vitest.mjs" } }, "sha512-gpsMNoRhMjMktVxPtstOH4/PJuPyovVaMDr4oDilXaGH1EcqM2OE96SoHT2VIQ6fTGtTjqmHDrEu2X9RQiXf8Q=="], + + "w3c-xmlserializer": ["w3c-xmlserializer@5.0.0", "", { "dependencies": { "xml-name-validator": "^5.0.0" } }, "sha512-o8qghlI8NZHU1lLPrpi2+Uq7abh4GGPpYANlalzWxyWteJOCsr/P+oPBA49TOLu5FTZO4d3F9MnWJfiMo4BkmA=="], + + "webidl-conversions": ["webidl-conversions@8.0.1", "", {}, "sha512-BMhLD/Sw+GbJC21C/UgyaZX41nPt8bUTg+jWyDeg7e7YN4xOM05YPSIXceACnXVtqyEw/LMClUQMtMZ+PGGpqQ=="], + + "whatwg-mimetype": ["whatwg-mimetype@5.0.0", "", {}, "sha512-sXcNcHOC51uPGF0P/D4NVtrkjSU2fNsm9iog4ZvZJsL3rjoDAzXZhkm2MWt1y+PUdggKAYVoMAIYcs78wJ51Cw=="], + + "whatwg-url": ["whatwg-url@17.1.1", "", { "dependencies": { "@exodus/bytes": "^1.15.1", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-ohjk1mdUebJVadRt3bAhQhx8lSnISq+GDttK79LFl8EHQkAPvzwctoasC4hs8tBt6kLAncBWWyq1N52qEfKvDw=="], + + "why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="], + + "xml-name-validator": ["xml-name-validator@5.0.0", "", {}, "sha512-EvGK8EJ3DhaHfbRlETOWAS5pO9MZITeauHKJyb8wyajUfQUenkIg2MvLDTZ4T/TgIcm3HU0TFBgWWboAZ30UHg=="], + + "xmlchars": ["xmlchars@2.2.0", "", {}, "sha512-JZnDKK8B0RCDw84FNdDAIpZK+JuJw+s7Lz8nksI7SIuU3UXJJslUthsi+uWBUYOwPFwW7W7PRLRfUKpxjtjFCw=="], + "yallist": ["yallist@3.1.1", "", {}, "sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g=="], + + "@babel/helper-compilation-targets/lru-cache": ["lru-cache@5.1.1", "", { "dependencies": { "yallist": "^3.0.2" } }, "sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w=="], + + "@jridgewell/gen-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@jridgewell/trace-mapping/@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], + + "@testing-library/jest-dom/aria-query": ["aria-query@5.3.2", "", {}, "sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw=="], + + "@testing-library/jest-dom/dom-accessibility-api": ["dom-accessibility-api@0.6.3", "", {}, "sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w=="], + + "data-urls/whatwg-url": ["whatwg-url@16.0.1", "", { "dependencies": { "@exodus/bytes": "^1.11.0", "tr46": "^6.0.0", "webidl-conversions": "^8.0.1" } }, "sha512-1to4zXBxmXHV3IiSSEInrreIlu02vUOvrhxJJH5vcxYTBDAx51cqZiKdyTxlecdKNSjj8EcxGBxNf6Vg+945gw=="], + + "vitest/picomatch": ["picomatch@4.0.7", "", {}, "sha512-qcJu88Q2IWqJsDD529JKMdwGm/dvInW4HvQnRwiH9JtihJvzGOscDtHE3x1pBKeUOTysQ8kVmLnJ2kJu7yhcGA=="], } } diff --git a/app/package.json b/app/package.json index 16051d2..a77b98b 100644 --- a/app/package.json +++ b/app/package.json @@ -7,6 +7,7 @@ "dev": "vite", "build": "tsc && vite build", "preview": "vite preview", + "test": "vitest", "tauri": "tauri" }, "dependencies": { @@ -18,10 +19,15 @@ }, "devDependencies": { "@tauri-apps/cli": "^2", + "@testing-library/dom": "^10.4.1", + "@testing-library/jest-dom": "^7.0.1", + "@testing-library/react": "^16.3.3", "@types/react": "^19.1.8", "@types/react-dom": "^19.1.6", "@vitejs/plugin-react": "^4.6.0", + "jsdom": "^30.0.1", "typescript": "~5.8.3", - "vite": "^7.0.4" + "vite": "^7.0.4", + "vitest": "^5.0.0" } } diff --git a/app/src/App.tsx b/app/src/App.tsx index e63fc48..120b27e 100644 --- a/app/src/App.tsx +++ b/app/src/App.tsx @@ -39,6 +39,17 @@ export default function App() { const [version, setVersion] = useState(""); const [filters, setFilters] = useState(NO_FILTERS); const [view, setView] = useState("board"); + /** + * Bumped by every refresh, and read by the views that own their own data. + * + * The board is the only thing `statusAll` answers, so a refresh used to + * leave the key vault and the MCP matrix exactly as they were loaded on + * mount: the button spun, the stamp moved, and the table on screen was the + * one from ten minutes ago. A header that already names the view you are in + * has to refresh *that* view, so the poll and the button both bump this and + * every view re-reads. + */ + const [reload, setReload] = useState(0); const [detail, setDetail] = useState<{ tool: string; permissions: boolean } | null>(null); const [verdicts, setVerdicts] = useState>({}); @@ -53,6 +64,7 @@ export default function App() { const refresh = useCallback(async () => { setRefreshing(true); + setReload((n) => n + 1); try { setStatuses(await statusAll()); setError(null); @@ -233,8 +245,8 @@ export default function App() { and never worth covering the thing the window is for. */} - {view === "keys" && } - {view === "mcp" && } + {view === "keys" && } + {view === "mcp" && } {view === "board" && ( /* Same wrapper the other two views use: one idiom for "a view", diff --git a/app/src/components/KeysView.test.tsx b/app/src/components/KeysView.test.tsx new file mode 100644 index 0000000..a9fc95c --- /dev/null +++ b/app/src/components/KeysView.test.tsx @@ -0,0 +1,80 @@ +import { render, screen } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { KeysView } from "./KeysView"; +import type { KeyRow } from "../types"; + +/** + * The mock sits on `invoke`, not on `../api`. + * + * `invoke` is the only thing genuinely missing from a test process — there is + * no Tauri IPC here — so faking it fakes exactly the absent thing and leaves + * every line of our own code running, `api.ts` included. Mocking `../api` + * would stub out code we ship, and it would count calls to a wrapper rather + * than trips to the backend; here a re-read is a second `keys_list`, spelled + * the same way the Rust shell registers the handler. + */ +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +const invoked = vi.mocked(invoke); + +const timesAsked = (command: string) => + invoked.mock.calls.filter(([cmd]) => cmd === command).length; + +const keyRow = (id: string, over: Partial = {}): KeyRow => ({ + id, + provider: "cloudflare", + // Deliberately not the id: the id and the label are different columns, and a + // query for the id should land on one cell rather than two. + label: `${id} token`, + purpose: null, + scopes: [], + created_at: "2026-01-01T00:00:00Z", + expires_at: null, + last4: "9f2c", + source: "cli", + env: null, + expiry_state: "no_expiry", + ...over, +}); + +beforeEach(() => { + invoked.mockReset(); +}); + +describe("KeysView", () => { + it("re-reads the vault when reload changes and draws the rows that came back", async () => { + // The bug this suite exists for: the header's refresh only re-fetched the + // board, so the vault went on showing whatever it read on mount. Bumping + // `reload` is all that button does to this view, so it has to reach the + // backend a second time and repaint from the answer. + invoked + .mockResolvedValueOnce([keyRow("cf-deploy")]) + .mockResolvedValueOnce([keyRow("cf-deploy"), keyRow("neon-api", { provider: "neon" })]); + + const { rerender } = render(); + expect(await screen.findByText("cf-deploy")).toBeInTheDocument(); + expect(timesAsked("keys_list")).toBe(1); + + rerender(); + + expect(await screen.findByText("neon-api")).toBeInTheDocument(); + expect(timesAsked("keys_list")).toBe(2); + expect(screen.getByText("2 keys · metadata only")).toBeInTheDocument(); + }); + + it("keeps the rows it already drew when a re-read fails", async () => { + // A refresh that lands while `keys.json` is half-rewritten must not cost + // the user the table they were already reading. + invoked + .mockResolvedValueOnce([keyRow("cf-deploy")]) + .mockRejectedValueOnce("keys.json: unexpected end of input"); + + const { rerender } = render(); + expect(await screen.findByText("cf-deploy")).toBeInTheDocument(); + + rerender(); + + expect(await screen.findByText("keys.json: unexpected end of input")).toBeInTheDocument(); + expect(screen.getByText("cf-deploy")).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/KeysView.tsx b/app/src/components/KeysView.tsx index bebb65d..20999fb 100644 --- a/app/src/components/KeysView.tsx +++ b/app/src/components/KeysView.tsx @@ -20,7 +20,7 @@ import { KEY_EXPIRY_LABEL, KEY_EXPIRY_LEVEL, type KeyRow } from "../types"; * so the rule was protecting nothing here and cost the panel the one action a * key vault is for. */ -export function KeysView() { +export function KeysView({ reload }: { reload: number }) { const [rows, setRows] = useState(null); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); @@ -39,9 +39,13 @@ export function KeysView() { } }, []); + // `reload` is the header's refresh (and its poll). A vault registered from a + // terminal a moment ago is the ordinary case, so re-reading is the point. + // `load` keeps the rows it has if the read fails, so a transient error can + // never blank a table that was fine. useEffect(() => { void load(); - }, [load]); + }, [load, reload]); const remove = async (id: string) => { setRemoving(id); diff --git a/app/src/components/McpView.test.tsx b/app/src/components/McpView.test.tsx new file mode 100644 index 0000000..b170891 --- /dev/null +++ b/app/src/components/McpView.test.tsx @@ -0,0 +1,78 @@ +import { render, screen } from "@testing-library/react"; +import { invoke } from "@tauri-apps/api/core"; +import { beforeEach, describe, expect, it, vi } from "vitest"; +import { McpView } from "./McpView"; +import type { McpClient, McpServerEntry } from "../types"; + +/** Same boundary and the same reason as `KeysView.test.tsx` — see the note there. */ +vi.mock("@tauri-apps/api/core", () => ({ invoke: vi.fn() })); +const invoked = vi.mocked(invoke); + +const timesAsked = (command: string) => + invoked.mock.calls.filter(([cmd]) => cmd === command).length; + +const server = (name: string): McpServerEntry => ({ + name, + scope: null, + env_keys: [], + header_keys: [], + transport: "stdio", + command: "/opt/homebrew/bin/pb", + args_len: 1, +}); + +const client = (id: string, label: string, servers: McpServerEntry[]): McpClient => ({ + client: id, + label, + config_path: `/Users/dev/.config/${id}/config.json`, + present: true, + servers, + notes: [], +}); + +beforeEach(() => { + invoked.mockReset(); +}); + +describe("McpView", () => { + it("re-reads every client config when reload changes and draws the servers that came back", async () => { + // Six config files edited by six other programs is the state that goes + // stale while you look at it, and the header's refresh is the only thing + // the user has to say so. Before the fix it moved the timestamp and left + // this matrix exactly as it was on mount. + invoked + .mockResolvedValueOnce([client("claude-code", "Claude Code", [server("patchbay")])]) + .mockResolvedValueOnce([ + client("claude-code", "Claude Code", [server("patchbay"), server("grafana")]), + ]); + + const { rerender } = render(); + expect(await screen.findByText("patchbay")).toBeInTheDocument(); + expect(timesAsked("mcp_list")).toBe(1); + + rerender(); + + expect(await screen.findByText("grafana")).toBeInTheDocument(); + expect(timesAsked("mcp_list")).toBe(2); + expect(screen.getByText("2 servers across 1 of 1 clients")).toBeInTheDocument(); + }); + + it("leaves the matrix it already drew on screen and adds the error banner when a re-read fails", async () => { + // The cold-start branch renders the banner *instead of* the view, which is + // right when there is nothing to show and wrong on a refresh: one + // unreadable config would replace a matrix that was fine a second ago. + invoked + .mockResolvedValueOnce([client("claude-code", "Claude Code", [server("patchbay")])]) + .mockRejectedValueOnce("mcp.json: permission denied"); + + const { rerender } = render(); + expect(await screen.findByText("patchbay")).toBeInTheDocument(); + + rerender(); + + expect(await screen.findByText("mcp.json: permission denied")).toBeInTheDocument(); + expect(screen.getByRole("table")).toBeInTheDocument(); + expect(screen.getByText("patchbay")).toBeInTheDocument(); + expect(screen.getByRole("heading", { name: "MCP clients" })).toBeInTheDocument(); + }); +}); diff --git a/app/src/components/McpView.tsx b/app/src/components/McpView.tsx index 19e3073..ee3b283 100644 --- a/app/src/components/McpView.tsx +++ b/app/src/components/McpView.tsx @@ -34,7 +34,7 @@ type Open = { mode: "add" } | { mode: "edit"; name: string }; * edit that client's copy, copy it to the clients that are missing it, or take * it out. `pb mcp add/copy/rm` still does the same work from a terminal. */ -export function McpView() { +export function McpView({ reload }: { reload: number }) { const [clients, setClients] = useState(null); const [error, setError] = useState(null); const [open, setOpen] = useState(null); @@ -56,15 +56,23 @@ export function McpView() { setOpen(next); }; + // `reload` is the header's refresh (and its poll): six config files edited by + // six other programs is exactly the state that goes stale while you look at + // it. A failed re-read leaves `clients` alone, so the matrix you had stays on + // screen with the error above it rather than vanishing. useEffect(() => { let live = true; mcpList() - .then((c) => live && setClients(c)) + .then((c) => { + if (!live) return; + setClients(c); + setError(null); + }) .catch((e) => live && setError(String(e))); return () => { live = false; }; - }, []); + }, [reload]); const servers = useMemo(() => { if (!clients) return []; @@ -73,7 +81,7 @@ export function McpView() { return [...names].sort((a, b) => a.localeCompare(b)); }, [clients]); - if (error) { + if (error && !clients) { return (
@@ -99,6 +107,13 @@ export function McpView() {
+ {error && ( +
+ + {error} +
+ )} + {note && (
{note} diff --git a/app/src/test/setup.ts b/app/src/test/setup.ts new file mode 100644 index 0000000..e77be45 --- /dev/null +++ b/app/src/test/setup.ts @@ -0,0 +1,10 @@ +import { afterEach } from "vitest"; +import { cleanup } from "@testing-library/react"; +// Teaches `expect` the DOM matchers (`toBeInTheDocument` and friends). +import "@testing-library/jest-dom/vitest"; + +// Testing Library only installs its own auto-cleanup when the runner's hooks +// are globals, and this suite imports them instead. Without this every test +// would render into the document the previous one left behind, and a query for +// a row would find the stale copy. +afterEach(cleanup); diff --git a/app/tsconfig.node.json b/app/tsconfig.node.json index 8bfb1d4..96763eb 100644 --- a/app/tsconfig.node.json +++ b/app/tsconfig.node.json @@ -8,5 +8,5 @@ "strict": true, "types": ["vite/client"] }, - "include": ["vite.config.ts"] + "include": ["vite.config.ts", "vitest.config.ts"] } diff --git a/app/vitest.config.ts b/app/vitest.config.ts new file mode 100644 index 0000000..e7a3e15 --- /dev/null +++ b/app/vitest.config.ts @@ -0,0 +1,19 @@ +import { defineConfig, mergeConfig } from "vitest/config"; +import viteConfig from "./vite.config"; + +// Merged onto the real vite config rather than written standalone: the React +// plugin is declared once, so a test can never compile JSX under different +// settings than the app it is testing. +export default mergeConfig( + viteConfig, + defineConfig({ + test: { + environment: "jsdom", + // Registers the DOM matchers and tears the tree down between tests. The + // suite imports `describe`/`it`/`expect` explicitly, so nothing here + // depends on vitest globals. + setupFiles: ["./src/test/setup.ts"], + include: ["src/**/*.test.{ts,tsx}"], + }, + }), +); From 01794341206ac7cf19bb370624ce6e36e1b6ce4e Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 10 Sep 2026 16:08:32 +0800 Subject: [PATCH 2/6] [fix] import: nothing is written before the keychain has answered MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A real move of 64 keys onto a second Mac wrote all 53 credential files, had every one of the 64 keychain writes refused because the session had no desktop login, and exited 0. The reason was recorded per key and dropped by the printer, so the output was a column of bare `skip` with nothing that explained it. `pb import` now probes the keystore before it copies a file and refuses the whole import if a write will not be taken; a refused key prints its reason like a file always did, plus a summary saying the bundle still holds the values; and an import that dropped secrets exits 1. `--keys-only` finishes the vault half once the session is fixed. Two more things the same move turned up. The bundle`s SETUP.md explains how to install patchbay on a machine that has none, from inside the encrypted payload — so `pb export` now writes a cleartext `-SETUP.md` beside it, carrying the install step and nothing about the machine; it takes no manifest, which is why no inventory can leak into it. And `pb plan` now re-derives the `KUBECONFIG` line for kubeconfigs an import left unmerged, which the import knew and the plan had been throwing away. `--passphrase-file` and `--passphrase-fd` replace the refusal to read a passphrase from anything but a terminal. The reason behind that refusal — argv is visible to `ps` and lands in shell history — is kept, but a TTY test is the wrong instrument for it: it stops no attacker, and it pushed a real move into a pty wrapper whose echo wrote the passphrase into a log, which is the leak the rule exists to prevent. A passphrase file the group or the world can read is refused, because with that flag the file is the secret. --- crates/patchbay-cli/src/main.rs | 28 ++ crates/patchbay-cli/src/migrate.rs | 352 ++++++++++++++++++++- crates/patchbay-core/src/keys.rs | 36 +++ crates/patchbay-core/src/migrate/export.rs | 85 ++++- crates/patchbay-core/src/migrate/import.rs | 257 ++++++++++++++- crates/patchbay-core/src/migrate/plan.rs | 147 ++++++++- crates/patchbay-core/src/migrate/setup.rs | 107 ++++++- docs/migration.md | 37 ++- 8 files changed, 1008 insertions(+), 41 deletions(-) diff --git a/crates/patchbay-cli/src/main.rs b/crates/patchbay-cli/src/main.rs index 13cb4b6..1813e14 100644 --- a/crates/patchbay-cli/src/main.rs +++ b/crates/patchbay-cli/src/main.rs @@ -123,6 +123,13 @@ enum Command { /// Write into a cloud-sync folder anyway. #[arg(long)] force: bool, + /// Take the passphrase from this file's first line (mode 0600 only), + /// not from a prompt. Never as an argument: argv is visible to `ps`. + #[arg(long, value_name = "PATH", conflicts_with = "passphrase_fd")] + passphrase_file: Option, + /// Take the passphrase from an already-open descriptor's first line. + #[arg(long, value_name = "N", conflicts_with = "passphrase_file")] + passphrase_fd: Option, #[arg(long)] json: bool, }, @@ -138,6 +145,17 @@ enum Command { /// Print the plan and write nothing. #[arg(long)] dry_run: bool, + /// Restore the key vault half only, skipping files, MCP servers and env + /// projects. The file half is idempotent anyway. + #[arg(long)] + keys_only: bool, + /// Take the passphrase from this file's first line (mode 0600 only), + /// not from a prompt. Never as an argument: argv is visible to `ps`. + #[arg(long, value_name = "PATH", conflicts_with = "passphrase_fd")] + passphrase_file: Option, + /// Take the passphrase from an already-open descriptor's first line. + #[arg(long, value_name = "N", conflicts_with = "passphrase_file")] + passphrase_fd: Option, #[arg(long)] json: bool, }, @@ -297,12 +315,16 @@ fn run() -> Result { out, keys, force, + passphrase_file, + passphrase_fd, json, } => migrate::run( migrate::Command::Export { out, keys, force, + passphrase_file, + passphrase_fd, json, }, &styles(), @@ -311,11 +333,17 @@ fn run() -> Result { Command::Import { bundle, dry_run, + keys_only, + passphrase_file, + passphrase_fd, json, } => migrate::run( migrate::Command::Import { bundle, dry_run, + keys_only, + passphrase_file, + passphrase_fd, json, }, &styles(), diff --git a/crates/patchbay-cli/src/migrate.rs b/crates/patchbay-cli/src/migrate.rs index 62175bf..307f1c0 100644 --- a/crates/patchbay-cli/src/migrate.rs +++ b/crates/patchbay-cli/src/migrate.rs @@ -6,6 +6,15 @@ //! On export it is asked for twice, because a bundle nobody can decrypt is a //! bundle that has to be made again from a machine you may have already wiped. //! +//! Automation names its source instead — `--passphrase-file` or +//! `--passphrase-fd`, the way gpg, restic, borg and age do it — which keeps the +//! value out of argv and out of a history file just as well. The refusal that +//! used to stand here instead ("stdin is not a terminal") stopped none of that +//! and was worked around with a pty wrapper, which echoed the passphrase into a +//! log: exactly the leak the rule exists to prevent. A file or fd source also +//! skips the confirmation read on export — nothing was typed, so there is +//! nothing to have mistyped. +//! //! Nothing here reads a credential file itself: [`patchbay_core::migrate`] does //! that. This module decides what to print. @@ -37,6 +46,17 @@ pub enum Command { /// Write into a cloud-sync folder anyway. #[arg(long)] force: bool, + /// Read the passphrase from the first line of this file instead of + /// prompting. The file must not be group- or world-readable. patchbay + /// will not take a passphrase as an argument: argv is visible to `ps` + /// and is written to your shell history. + #[arg(long, value_name = "PATH", conflicts_with = "passphrase_fd")] + passphrase_file: Option, + /// Read the passphrase from the first line of an already-open file + /// descriptor (`--passphrase-fd 3`), for automation that will not put + /// it in a file either. + #[arg(long, value_name = "N", conflicts_with = "passphrase_file")] + passphrase_fd: Option, #[arg(long)] json: bool, }, @@ -52,6 +72,23 @@ pub enum Command { /// Print the plan and write nothing. #[arg(long)] dry_run: bool, + /// Restore the key vault half only, skipping the credential files, the + /// MCP registrations and the env projects. For finishing a move whose + /// keychain writes were refused; the file half is idempotent anyway, so + /// a plain re-run is equally safe and reports `unchanged`. + #[arg(long)] + keys_only: bool, + /// Read the passphrase from the first line of this file instead of + /// prompting. The file must not be group- or world-readable. patchbay + /// will not take a passphrase as an argument: argv is visible to `ps` + /// and is written to your shell history. + #[arg(long, value_name = "PATH", conflicts_with = "passphrase_fd")] + passphrase_file: Option, + /// Read the passphrase from the first line of an already-open file + /// descriptor (`--passphrase-fd 3`), for automation that will not put + /// it in a file either. + #[arg(long, value_name = "N", conflicts_with = "passphrase_file")] + passphrase_fd: Option, #[arg(long)] json: bool, }, @@ -82,8 +119,11 @@ pub fn run(command: Command, styles: &Styles) -> Result { out, keys, force, + passphrase_file, + passphrase_fd, json, } => { + let source = PassphraseSource::from_flags(passphrase_file, passphrase_fd)?; let path = out.unwrap_or_else(|| PathBuf::from(export::default_file_name(Utc::now()))); // Refuse a bad destination BEFORE asking for a passphrase: being // made to type one twice and then told no is a small cruelty. @@ -99,7 +139,7 @@ pub fn run(command: Command, styles: &Styles) -> Result { } .payload(&selection, Utc::now())?; - let passphrase = ask_passphrase_twice()?; + let passphrase = ask_passphrase_twice(&source)?; let report = export::write(&path, &payload, &passphrase, force, None)?; drop(passphrase); @@ -140,12 +180,16 @@ pub fn run(command: Command, styles: &Styles) -> Result { Command::Import { bundle, dry_run, + keys_only, + passphrase_file, + passphrase_fd, json, } => { + let source = PassphraseSource::from_flags(passphrase_file, passphrase_fd)?; // Version first, so a bundle from a newer patchbay is refused // before the user types anything. migrate::peek_version(&bundle)?; - let passphrase = ask_passphrase("passphrase: ")?; + let passphrase = read_passphrase(&source, "passphrase: ")?; let payload = migrate::bundle::read(&bundle, &passphrase)?; drop(passphrase); @@ -156,14 +200,18 @@ pub fn run(command: Command, styles: &Styles) -> Result { clients: &clients, envs: &envs, } - .run(&payload, &ImportOptions { dry_run })?; + .run(&payload, &ImportOptions { dry_run, keys_only })?; if json { println!("{}", serde_json::to_string_pretty(&import_json(&report))?); } else { print_import(&report, styles); } - Ok(0) + // A keychain that refused the secrets leaves the vault half of this + // machine incomplete however well the files went, so + // `pb import && ./something` must not read as success — the same + // contract `pb plan` keeps below. + Ok(i32::from(report.keys_refused() > 0)) } Command::Plan { @@ -250,25 +298,151 @@ fn key_selection(keys: Option>) -> KeySelection { // passphrase // --------------------------------------------------------------------------- -fn ask_passphrase(prompt: &str) -> Result { - if !std::io::stdin().is_terminal() { - anyhow::bail!( - "a passphrase is required and stdin is not a terminal; run this from a shell rather \ - than a pipe (patchbay will not take a passphrase as an argument — argv is visible to \ - `ps` and is written to your shell history)" - ); +/// Where the passphrase comes from. None of the three is argv. +#[derive(Debug, Clone, PartialEq, Eq)] +enum PassphraseSource { + /// A hidden prompt on the terminal. The default, and the only one that can + /// be mistyped. + Prompt, + /// The first line of a file that nobody else may read. + File(PathBuf), + /// The first line of a descriptor the caller already opened. + Fd(i32), +} + +impl PassphraseSource { + /// `clap` refuses the two flags together as well. The rule is repeated here + /// because it belongs to the source rather than to one command's argument + /// table, and both commands take the pair. + fn from_flags(file: Option, fd: Option) -> Result { + match (file, fd) { + (Some(_), Some(_)) => anyhow::bail!( + "--passphrase-file and --passphrase-fd both name a source for the same \ + passphrase; pass one of them" + ), + (Some(path), None) => Ok(Self::File(path)), + (None, Some(fd)) => Ok(Self::Fd(fd)), + (None, None) => Ok(Self::Prompt), + } } - let value = rpassword::prompt_password(prompt) - .map_err(|e| anyhow::anyhow!("could not read the passphrase: {e}"))?; + + fn is_typed(&self) -> bool { + *self == Self::Prompt + } +} + +fn read_passphrase(source: &PassphraseSource, prompt: &str) -> Result { + let value = match source { + PassphraseSource::Prompt => prompt_passphrase(prompt)?, + PassphraseSource::File(path) => { + let file = std::fs::File::open(path) + .with_context(|| format!("could not open {}", path.display()))?; + refuse_a_readable_file(path, &file)?; + first_line( + &mut std::io::BufReader::new(file), + &path.display().to_string(), + )? + } + PassphraseSource::Fd(fd) => read_fd(*fd)?, + }; if value.is_empty() { anyhow::bail!("an empty passphrase would leave the bundle effectively unencrypted"); } Ok(value) } -fn ask_passphrase_twice() -> Result { - let first = ask_passphrase("passphrase for the bundle: ")?; - let second = ask_passphrase("again: ")?; +fn prompt_passphrase(prompt: &str) -> Result { + if !std::io::stdin().is_terminal() { + anyhow::bail!( + "a passphrase is required and stdin is not a terminal; name a source explicitly with \ + --passphrase-file (mode 0600) or --passphrase-fd . patchbay will not take \ + a passphrase as an argument — argv is visible to `ps` and is written to your shell \ + history — and it will not read one from a stdin nobody named, which is how a pty \ + wrapper ends up echoing it into a log." + ); + } + rpassword::prompt_password(prompt) + .map_err(|e| anyhow::anyhow!("could not read the passphrase: {e}")) +} + +/// The first line, with one trailing newline removed. +/// +/// A trailing `\r` goes with it: a file written on Windows would otherwise +/// carry one into the key derivation, and what comes back then looks exactly +/// like a wrong passphrase rather than like a stray byte. +fn first_line(reader: &mut dyn std::io::BufRead, origin: &str) -> Result { + let mut line = String::new(); + reader + .read_line(&mut line) + .with_context(|| format!("could not read a passphrase from {origin}"))?; + let line = line.strip_suffix('\n').unwrap_or(&line); + Ok(line.strip_suffix('\r').unwrap_or(line).to_string()) +} + +/// Refuse a passphrase file that anybody else on the machine can read. +/// +/// With this flag the file *is* the secret, so it gets the rule ssh gives a +/// private key. Only the mode is checked: it is the mistake that actually +/// happens — a passphrase written with a default umask — and it is the one the +/// user can fix in one command. +#[cfg(unix)] +fn refuse_a_readable_file(path: &std::path::Path, file: &std::fs::File) -> Result<()> { + use std::os::unix::fs::PermissionsExt; + let mode = file + .metadata() + .with_context(|| format!("could not stat {}", path.display()))? + .permissions() + .mode() + & 0o777; + if mode & 0o077 != 0 { + anyhow::bail!( + "{} is mode {mode:o}: its group or every user on this machine can read it, and with \ + --passphrase-file that file is the secret. Run `chmod 600 {}` and try again.", + path.display(), + path.display() + ); + } + Ok(()) +} + +/// No file mode to read, so nothing to check. +#[cfg(not(unix))] +fn refuse_a_readable_file(_path: &std::path::Path, _file: &std::fs::File) -> Result<()> { + Ok(()) +} + +/// The first line of a descriptor the caller opened for us — `--passphrase-fd 3` +/// with a `3< …` redirect or a process substitution. +/// +/// The descriptor belongs to that caller, so the `File` wrapper is leaked +/// rather than dropped: dropping it would close somebody else's fd. +#[cfg(unix)] +fn read_fd(fd: i32) -> Result { + use std::os::fd::FromRawFd; + if fd < 0 { + anyhow::bail!("--passphrase-fd {fd} is not a file descriptor"); + } + let file = std::mem::ManuallyDrop::new(unsafe { std::fs::File::from_raw_fd(fd) }); + first_line( + &mut std::io::BufReader::new(&*file), + &format!("file descriptor {fd}"), + ) +} + +#[cfg(not(unix))] +fn read_fd(fd: i32) -> Result { + anyhow::bail!("--passphrase-fd {fd} needs unix file descriptors; use --passphrase-file") +} + +/// Twice for a typed passphrase, once for a file or a descriptor: a value +/// nobody typed cannot have been mistyped, and reading the same file twice +/// would only look like the tool did not believe the first read. +fn ask_passphrase_twice(source: &PassphraseSource) -> Result { + let first = read_passphrase(source, "passphrase for the bundle: ")?; + if !source.is_typed() { + return Ok(first); + } + let second = read_passphrase(source, "again: ")?; if first != second { anyhow::bail!("the two passphrases do not match; nothing was written"); } @@ -329,6 +503,15 @@ fn print_export(report: &export::ExportReport, styles: &Styles) { " {} item(s) will need doing on the new machine", report.gaps ); + if let Some(sidecar) = &report.sidecar { + // Named because it is the one file here that is safe to send ahead of + // the bundle, and because the machine that needs it cannot read the + // copy inside the bundle without the `pb` it explains how to install. + println!( + " also wrote {} — install instructions in the clear, no inventory in it", + file_name(sidecar) + ); + } println!(); for warning in &report.warnings { println!("{}", styles.paint(warn_style(), &format!("! {warning}"))); @@ -337,6 +520,12 @@ fn print_export(report: &export::ExportReport, styles: &Styles) { "\nnext: copy the file across, then `pb import {}`", file_name(&report.path) ); + if let Some(sidecar) = &report.sidecar { + println!( + " on a machine with no `pb` yet, start from {}", + file_name(sidecar) + ); + } } /// Written-to-a-file summary. Deliberately counts rather than lists: the file @@ -389,8 +578,17 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { println!(" {reason}"); } } + // A key carries its reason the same way a file does. `restore_keys` puts + // the keystore's error in there and this loop used to drop it, which turns + // "every secret in the bundle was refused" into a column of bare `skip` + // and leaves the one fact that explains it unprinted. + let mut keys_refused = 0usize; for key in &report.keys { println!(" {:<10} key {}", key.outcome.label(), key.id); + if let import::FileOutcome::Skipped { reason } = &key.outcome { + println!(" {reason}"); + keys_refused += 1; + } } for server in &report.mcp { println!( @@ -411,6 +609,28 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { } } println!(); + // The file half of an import can succeed while every secret in it is + // refused, and per-key lines scroll away. A keychain that will not take a + // write is nearly always a session without a desktop login — over ssh, in + // a cron job — which is a property of how the command was started and not + // of the bundle, so it says how to start it differently. + if keys_refused > 0 { + println!( + "{}", + styles.paint( + warn_style(), + &format!( + "! {keys_refused} of {} key value(s) never reached the keychain — the bundle \ + still holds them, so nothing is lost, but the vault on this machine is \ + incomplete. On macOS a keychain refuses every write from a session with no \ + desktop login (ssh, cron): re-run this import from a Terminal in your own \ + desktop session, and the file half above is idempotent — it will report \ + `unchanged` rather than write anything twice.", + report.keys.len() + ) + ) + ); + } for note in &report.notes { println!("{}", styles.paint(warn_style(), &format!("! {note}"))); } @@ -486,6 +706,7 @@ fn dim_style() -> anstyle::Style { fn export_json(report: &export::ExportReport) -> serde_json::Value { serde_json::json!({ "path": report.path, + "setup_sidecar": report.sidecar, "files": report.files, "bytes": report.bytes, "tools_carried": report.tools_carried, @@ -515,6 +736,7 @@ fn import_json(report: &import::ImportReport) -> serde_json::Value { .collect(); serde_json::json!({ "dry_run": report.dry_run, + "keys_refused": report.keys_refused(), "files": files, "keys": report.keys.iter().map(|k| serde_json::json!({ "id": k.id, "action": k.outcome.label(), @@ -556,6 +778,104 @@ mod tests { assert_eq!(human_bytes(3 * 1024 * 1024), "3.0 MB"); } + /// A passphrase file with the mode the flag insists on. + #[cfg(unix)] + fn passphrase_file(dir: &tempfile::TempDir, body: &str, mode: u32) -> PathBuf { + use std::os::unix::fs::PermissionsExt; + let path = dir.path().join("pass"); + std::fs::write(&path, body).unwrap(); + std::fs::set_permissions(&path, std::fs::Permissions::from_mode(mode)).unwrap(); + path + } + + #[cfg(unix)] + #[test] + fn test_a_passphrase_file_is_read_as_its_first_line() { + let dir = tempfile::tempdir().unwrap(); + // A second line, and a trailing newline on the first: both are the + // shape `echo … > pass` produces. + let path = passphrase_file(&dir, "hunter2\nignored\n", 0o600); + let source = PassphraseSource::File(path); + assert_eq!(read_passphrase(&source, "p: ").unwrap(), "hunter2"); + // A file cannot be mistyped, so it is not read twice. + assert_eq!(ask_passphrase_twice(&source).unwrap(), "hunter2"); + } + + #[cfg(unix)] + #[test] + fn test_a_passphrase_file_others_can_read_is_refused() { + let dir = tempfile::tempdir().unwrap(); + for mode in [0o640, 0o604, 0o666] { + let path = passphrase_file(&dir, "hunter2\n", mode); + let err = read_passphrase(&PassphraseSource::File(path), "p: ") + .unwrap_err() + .to_string(); + assert!(err.contains("chmod 600"), "mode {mode:o}: {err}"); + // The error may name the file; it may never name what is in it. + assert!(!err.contains("hunter2"), "{err}"); + } + } + + #[cfg(unix)] + #[test] + fn test_a_passphrase_arrives_on_a_file_descriptor_the_caller_opened() { + use std::os::fd::AsRawFd; + let dir = tempfile::tempdir().unwrap(); + let path = passphrase_file(&dir, "hunter2\n", 0o600); + let handle = std::fs::File::open(&path).unwrap(); + + let read = read_passphrase(&PassphraseSource::Fd(handle.as_raw_fd()), "p: ").unwrap(); + assert_eq!(read, "hunter2"); + // The fd is still ours: reading it did not close it, which is what + // `--passphrase-fd 3` depends on. + assert!(handle.metadata().is_ok()); + } + + #[cfg(unix)] + #[test] + fn test_an_empty_passphrase_is_refused_whatever_the_source() { + let dir = tempfile::tempdir().unwrap(); + for body in ["", "\n", "\r\n"] { + let path = passphrase_file(&dir, body, 0o600); + let err = read_passphrase(&PassphraseSource::File(path), "p: ") + .unwrap_err() + .to_string(); + assert!(err.contains("empty passphrase"), "{body:?}: {err}"); + } + } + + #[test] + fn test_the_two_passphrase_flags_cannot_both_be_given() { + let err = PassphraseSource::from_flags(Some(PathBuf::from("pass")), Some(3)) + .unwrap_err() + .to_string(); + assert!(err.contains("--passphrase-file"), "{err}"); + assert!(err.contains("--passphrase-fd"), "{err}"); + + // One of each is fine, and neither is the interactive default. + assert_eq!( + PassphraseSource::from_flags(Some(PathBuf::from("pass")), None).unwrap(), + PassphraseSource::File(PathBuf::from("pass")) + ); + assert_eq!( + PassphraseSource::from_flags(None, Some(3)).unwrap(), + PassphraseSource::Fd(3) + ); + let default = PassphraseSource::from_flags(None, None).unwrap(); + assert_eq!(default, PassphraseSource::Prompt); + assert!(default.is_typed()); + } + + #[test] + fn test_a_first_line_loses_one_newline_and_nothing_else() { + let mut line = std::io::Cursor::new(b" hunter2 \r\nrest\n".to_vec()); + // Leading and trailing spaces are part of a passphrase; the line + // ending is not. + assert_eq!(first_line(&mut line, "test").unwrap(), " hunter2 "); + let mut no_newline = std::io::Cursor::new(b"hunter2".to_vec()); + assert_eq!(first_line(&mut no_newline, "test").unwrap(), "hunter2"); + } + #[test] fn test_file_name_survives_a_bare_name() { assert_eq!(file_name(std::path::Path::new("a/b.pbx")), "b.pbx"); diff --git a/crates/patchbay-core/src/keys.rs b/crates/patchbay-core/src/keys.rs index 4b277e3..221b227 100644 --- a/crates/patchbay-core/src/keys.rs +++ b/crates/patchbay-core/src/keys.rs @@ -408,6 +408,28 @@ impl KeyRegistry { // --- writes ------------------------------------------------------------- + /// Whether the keystore would accept a write at all, right now. + /// + /// There is no way to ask a keychain this except by trying, so a throwaway + /// item is stored and deleted again. It is filed under an id + /// [`validate_id`] refuses, so it can never collide with a registered key, + /// and it carries no credential material. + /// + /// [`crate::migrate::import`] asks before it restores anything: on macOS a + /// keychain refuses *every* write from a session with no desktop login + /// (ssh, cron), and discovering that one key at a time — after the + /// credential files have already landed — is how a machine ends up half + /// migrated. + pub fn probe_writable(&self) -> anyhow::Result<()> { + const PROBE_ID: &str = ".patchbay-write-probe"; + self.store.put(PROBE_ID, "probe")?; + // The question has been answered by the line above. A delete that then + // fails leaves one valueless item behind, which is not worth turning a + // successful probe into a refusal. + let _ = self.store.delete(PROBE_ID); + Ok(()) + } + /// Register a key: metadata to disk, value to the keystore. /// /// Both or neither. The metadata file is written first; if the keystore @@ -971,6 +993,20 @@ mod tests { assert_eq!(v.store.get("dup").unwrap().as_deref(), Some("second-2222")); } + #[test] + fn test_a_write_probe_leaves_neither_a_key_nor_a_stored_value() { + let v = vault(); + v.registry.probe_writable().unwrap(); + assert!(v.registry.list().unwrap().is_empty()); + assert!(!v.registry.path().exists(), "the probe wrote metadata"); + assert!(v.store.is_empty(), "the probe left its throwaway item"); + + // And a store that refuses writes says so, which is the answer an + // import needs before it touches a single file. + let refusing = vault_with(MemoryKeystore::failing_put()); + assert!(refusing.registry.probe_writable().is_err()); + } + #[test] fn test_keystore_failure_rolls_the_metadata_back() { let v = vault_with(MemoryKeystore::failing_put()); diff --git a/crates/patchbay-core/src/migrate/export.rs b/crates/patchbay-core/src/migrate/export.rs index 30ac553..7c3dd94 100644 --- a/crates/patchbay-core/src/migrate/export.rs +++ b/crates/patchbay-core/src/migrate/export.rs @@ -13,6 +13,11 @@ //! 4. **`SETUP.md`** — written at export time so a machine with no patchbay on //! it yet still has instructions. //! +//! One thing is written *outside* the bundle: a cleartext `-SETUP.md` sidecar +//! ([`sidecar_path`]) holding the install step and the import line, and nothing +//! else. `SETUP.md` inside the payload cannot be read without a `pb`, which +//! makes its own first step circular on the one machine that needs it. +//! //! And one thing that travels as *metadata only, by construction*: the env //! vault's project manifest ([`crate::envs`]). Ids, environments, sync pins — //! never a value, and never this machine's `attachments.json`, whose paths mean @@ -74,6 +79,9 @@ impl KeySelection { #[derive(Debug, Clone, PartialEq)] pub struct ExportReport { pub path: PathBuf, + /// The cleartext install instructions written beside the bundle. `None` + /// when that write failed — a warning says so, and the bundle is fine. + pub sidecar: Option, pub files: usize, pub bytes: usize, /// Tools with at least one file in the bundle. @@ -104,6 +112,18 @@ pub fn default_file_name(now: DateTime) -> String { ) } +/// `patchbay-2026-08-13-SETUP.md` beside `patchbay-2026-08-13.pbx`. +/// +/// Beside rather than inside, because this is the copy for a machine that has +/// nothing yet: see [`setup::sidecar`] for what may be in it. +pub fn sidecar_path(bundle: &Path) -> PathBuf { + let stem = bundle + .file_stem() + .map(|s| s.to_string_lossy().into_owned()) + .unwrap_or_else(|| "patchbay".to_string()); + bundle.with_file_name(format!("{stem}-SETUP.md")) +} + /// Directory names that mean "this file is about to be uploaded". /// /// Matched on path components so `~/Documents/dropbox-migration-notes` is not @@ -629,9 +649,27 @@ pub fn write( force: bool, work_factor: Option, ) -> anyhow::Result { - let warnings = check_destination(path, force)?; + let mut warnings = check_destination(path, force)?; bundle::write(path, payload, passphrase, work_factor)?; + // The install step, in the clear, next to the file it explains. Not 0600 + // and not encrypted on purpose: it holds no inventory and no name from this + // machine, and the whole reason it exists is to be readable by a laptop + // with no `pb` on it. + let sidecar = sidecar_path(path); + let mut sidecar_written = Some(sidecar.clone()); + if let Err(e) = std::fs::write(&sidecar, setup::sidecar(&file_name(path))) { + // The bundle is already on disk and is the half that matters. Failing + // the export here would report that nothing was written when in fact + // every credential was packed. + warnings.push(format!( + "could not write the install instructions to {}: {e}. The bundle itself is fine — the \ + same instructions are in `SETUP.md` inside it, once there is a `pb` to read them", + sidecar.display() + )); + sidecar_written = None; + } + let mut tools_carried: Vec = Vec::new(); for file in &payload.files { if !tools_carried.contains(&file.tool) { @@ -649,6 +687,7 @@ pub fn write( Ok(ExportReport { path: path.to_path_buf(), + sidecar: sidecar_written, files: payload.files.len(), bytes: payload.bytes_carried(), tools_carried, @@ -675,6 +714,13 @@ pub fn write( }) } +/// The bundle's own name, for the import line in the sidecar. +fn file_name(path: &Path) -> String { + path.file_name() + .map(|n| n.to_string_lossy().into_owned()) + .unwrap_or_else(|| "".to_string()) +} + #[cfg(test)] pub(crate) mod tests { use super::*; @@ -1214,6 +1260,43 @@ pub(crate) mod tests { assert!(path.is_file()); } + #[test] + fn test_the_install_instructions_land_beside_the_bundle_in_the_clear() { + let home = machine(); + let out = tempfile::tempdir().unwrap(); + let (paths, registry, vault, clients, envs) = exporter_parts(home.path()); + vault + .add( + crate::keys::NewKey::new("cf-api", "cli").provider("cloudflare"), + "cf-secret-1234", + false, + ) + .unwrap(); + let payload = Exporter { + paths: &paths, + registry: ®istry, + vault: &vault, + clients: &clients, + envs: &envs, + } + .payload(&KeySelection::All, Utc::now()) + .unwrap(); + + let path = out.path().join("patchbay-2026-08-13.pbx"); + let report = write(&path, &payload, "pass", false, Some(10)).unwrap(); + + let beside = report.sidecar.expect("the sidecar is the point of this"); + assert_eq!(beside, out.path().join("patchbay-2026-08-13-SETUP.md")); + let text = std::fs::read_to_string(&beside).unwrap(); + assert!(text.contains("releases/download"), "{text}"); + assert!(text.contains("pb import patchbay-2026-08-13.pbx"), "{text}"); + // It sits in the clear next to the bundle, so it carries nothing from + // the machine that wrote it — not a tool name, not a key id. + for forbidden in ["aws", "gcloud", "kubectl", "cf-api", "1234"] { + assert!(!text.contains(forbidden), "`{forbidden}` in:\n{text}"); + } + } + #[test] fn test_locations_used_by_the_exporter_are_the_ones_the_policy_names() { // Guards against a location that exists but no tool references, which diff --git a/crates/patchbay-core/src/migrate/import.rs b/crates/patchbay-core/src/migrate/import.rs index 71a30b3..c10a212 100644 --- a/crates/patchbay-core/src/migrate/import.rs +++ b/crates/patchbay-core/src/migrate/import.rs @@ -9,6 +9,15 @@ //! directory, so there is no window in which every credential on the machine //! sits unencrypted in `/tmp` waiting for a crash to leave it there. //! +//! # Nothing is written before the keychain has answered +//! +//! A bundle carrying key values is refused outright when the keystore will not +//! take a write ([`Importer::preflight_keystore`]). The ordering is the point: +//! the keychain half runs after the file half, and a macOS keychain refuses +//! *every* write from a session with no desktop login, so without the +//! preflight an import over ssh copies every credential file and then drops +//! every secret — a half-migrated machine that exited 0. +//! //! # Never clobber silently //! //! Every destination that already exists is copied to `.patchbay-bak` @@ -47,6 +56,10 @@ use crate::util::{backup, backup_path}; pub struct ImportOptions { /// Print the plan, change nothing. pub dry_run: bool, + /// Restore the key vault half only — no files, no MCP registrations, no + /// env projects. What finishes a move whose keychain writes were refused + /// while its files landed. + pub keys_only: bool, } /// What happened, or would happen, to one destination. @@ -134,6 +147,17 @@ impl ImportReport { pub fn open_items(&self) -> impl Iterator { self.remaining.iter().filter(|i| i.is_open()) } + + /// Key values the keystore would not take. Anything above zero means the + /// vault half of this machine is incomplete however well the file half + /// went, which is why the CLI exits non-zero on it: `pb import && ./deploy` + /// must not run against a machine whose secrets never arrived. + pub fn keys_refused(&self) -> usize { + self.keys + .iter() + .filter(|k| matches!(k.outcome, FileOutcome::Skipped { .. })) + .count() + } } /// Everything an import writes to. @@ -157,10 +181,21 @@ impl Importer<'_> { notes: Vec::new(), }; - self.restore_files(payload, options, &mut report)?; - self.restore_keys(payload, options, &mut report); - self.restore_mcp(payload, options, &mut report); - self.restore_env_projects(payload, options, &mut report); + self.preflight_keystore(payload, options, &mut report)?; + + if options.keys_only { + report.notes.push( + "--keys-only: the credential files, MCP registrations and env projects in this \ + bundle were not looked at; re-run without the flag to restore them" + .to_string(), + ); + self.restore_keys(payload, options, &mut report); + } else { + self.restore_files(payload, options, &mut report)?; + self.restore_keys(payload, options, &mut report); + self.restore_mcp(payload, options, &mut report); + self.restore_env_projects(payload, options, &mut report); + } // The gaps are recomputed here rather than copied out of the manifest: // the manifest's list is what the *source* predicted, and by now some @@ -176,6 +211,51 @@ impl Importer<'_> { Ok(report) } + /// Ask the keystore whether it will accept a write at all, before anything + /// at all is written. + /// + /// A keychain that refuses one write refuses all of them: on macOS that is + /// what a session with no desktop login looks like, and it is a property of + /// how the command was started rather than of the bundle. So the answer is + /// worth one throwaway item ([`KeyRegistry::probe_writable`]) up front: + /// every credential file restored and then every secret in the bundle + /// dropped is the worst outcome this command has, and it must not be + /// reachable. + /// + /// A dry run cannot ask, because asking is writing. It says so instead. + fn preflight_keystore( + &self, + payload: &Payload, + options: &ImportOptions, + report: &mut ImportReport, + ) -> anyhow::Result<()> { + if payload.secrets.is_empty() { + return Ok(()); + } + if options.dry_run { + report.notes.push(format!( + "the {} key value(s) in this bundle were NOT checked against the {}: the only way \ + to ask whether it will accept a write is to write, and a dry run writes nothing. \ + A real import asks before it copies a single file.", + payload.secrets.len(), + self.vault.store_name() + )); + return Ok(()); + } + self.vault.probe_writable().map_err(|e| { + anyhow::anyhow!( + "the {} refused a test write, so none of the {} key value(s) in this bundle can \ + be stored ({e:#}) — nothing was written, and the bundle still holds them. On \ + macOS a keychain refuses every write from a session with no desktop login (ssh, \ + cron, a launchd job): re-run this from a Terminal in your own desktop session. \ + If the files are already here, `pb import --keys-only` finishes just \ + the vault half.", + self.vault.store_name(), + payload.secrets.len() + ) + }) + } + fn restore_files( &self, payload: &Payload, @@ -477,6 +557,15 @@ mod tests { impl Machine { fn new(files: &[(&str, &str)]) -> Self { + Self::with_keystore(files, Box::new(MemoryKeystore::new())) + } + + /// A machine whose vault is backed by something other than a working + /// store, for the keychain-refuses-everything paths. + fn with_keystore( + files: &[(&str, &str)], + store: Box, + ) -> Self { let dir = tempfile::tempdir().unwrap(); for (rel, body) in files { let path = dir.path().join(rel); @@ -487,7 +576,7 @@ mod tests { let paths = Paths::for_test(&home); Self { registry: Registry::all(paths.clone()), - vault: KeyRegistry::new(home.join("keys.json"), Box::new(MemoryKeystore::new())), + vault: KeyRegistry::new(home.join("keys.json"), store), clients: McpClientRegistry::with_paths(paths.clone()), envs: EnvRegistry::new( home.join("projects.json"), @@ -513,6 +602,21 @@ mod tests { } fn import(&self, payload: &Payload, dry_run: bool) -> ImportReport { + self.try_import( + payload, + &ImportOptions { + dry_run, + ..Default::default() + }, + ) + .unwrap() + } + + fn try_import( + &self, + payload: &Payload, + options: &ImportOptions, + ) -> anyhow::Result { Importer { paths: &self.paths, registry: &self.registry, @@ -520,8 +624,7 @@ mod tests { clients: &self.clients, envs: &self.envs, } - .run(payload, &ImportOptions { dry_run }) - .unwrap() + .run(payload, options) } fn read(&self, rel: &str) -> String { @@ -719,6 +822,146 @@ mod tests { assert!(gap.command.contains("pb key add cf-api"), "{gap:?}"); } + /// A store that takes the write probe and then refuses every real key. + /// + /// Only the probe's id can start with a `.` — `validate_id` refuses that + /// shape for a registered key — so this is the shape of a session that + /// locks *between* the preflight and the restore, which the preflight + /// cannot promise away. + struct RefusesEveryRealKey; + + impl crate::keystore::Keystore for RefusesEveryRealKey { + fn put(&self, id: &str, _secret: &str) -> anyhow::Result<()> { + if id.starts_with('.') { + return Ok(()); + } + anyhow::bail!("User interaction is not allowed") + } + fn get(&self, _id: &str) -> anyhow::Result> { + Ok(None) + } + fn delete(&self, _id: &str) -> anyhow::Result { + Ok(true) + } + fn describe(&self) -> &'static str { + "test keystore that refuses real keys" + } + } + + /// The source machine of a bundle that carries files and one key value. + fn source_with_a_key() -> Machine { + let source = Machine::new(&source_files()); + source + .vault + .add( + NewKey::new("cf-api", "cli").provider("cloudflare"), + "cf-secret-1234", + false, + ) + .unwrap(); + source + } + + #[test] + fn test_a_keychain_that_refuses_writes_aborts_before_a_single_file_lands() { + let source = source_with_a_key(); + let payload = source.payload(KeySelection::All); + + let dest = Machine::with_keystore(&[], Box::new(MemoryKeystore::failing_put())); + let err = dest + .try_import(&payload, &ImportOptions::default()) + .unwrap_err(); + let msg = format!("{err:#}"); + assert!(msg.contains("refused a test write"), "{msg}"); + // The cause and the fix, both named. + assert!(msg.contains("no desktop login"), "{msg}"); + assert!(msg.contains("desktop session"), "{msg}"); + assert!(msg.contains("--keys-only"), "{msg}"); + + // The whole point of the ordering: not one file was copied. + assert!(walk(&dest.home).is_empty(), "{:?}", walk(&dest.home)); + assert!(!dest.exists(".aws/config")); + } + + #[test] + fn test_a_dry_run_says_the_keychain_was_never_asked() { + let source = source_with_a_key(); + let payload = source.payload(KeySelection::All); + + // Even a store that would refuse: a dry run does not find out, because + // finding out is a write. + let dest = Machine::with_keystore(&[], Box::new(MemoryKeystore::failing_put())); + let report = dest + .try_import( + &payload, + &ImportOptions { + dry_run: true, + ..Default::default() + }, + ) + .unwrap(); + assert!( + report.notes.iter().any(|n| n.contains("NOT checked")), + "{:?}", + report.notes + ); + assert!(walk(&dest.home).is_empty()); + } + + #[test] + fn test_a_key_the_keychain_refuses_is_counted_not_hidden() { + let source = source_with_a_key(); + let payload = source.payload(KeySelection::All); + + let dest = Machine::with_keystore(&[], Box::new(RefusesEveryRealKey)); + let report = dest.import(&payload, false); + assert_eq!(report.keys_refused(), 1); + match &report.keys[0].outcome { + FileOutcome::Skipped { reason } => { + assert!(reason.contains("User interaction"), "{reason}"); + // The reason names the id and nothing else about the value. + assert!(!reason.contains("cf-secret-1234"), "{reason}"); + } + other => panic!("expected a skip, got {other:?}"), + } + // One refusal does not abort the file half — which is why the count + // has to survive into the report at all. + assert!(report.written() >= 6, "{report:?}"); + } + + #[test] + fn test_keys_only_restores_the_vault_and_touches_nothing_else() { + let source = source_with_a_key(); + crate::migrate::export::tests::seed_env_vault( + &source.envs, + &source.home.join("repos/pathors"), + ); + let payload = source.payload(KeySelection::All); + + let dest = Machine::new(&[]); + let report = dest + .try_import( + &payload, + &ImportOptions { + keys_only: true, + ..Default::default() + }, + ) + .unwrap(); + + assert_eq!(dest.vault.get_secret("cf-api").unwrap(), "cf-secret-1234"); + assert!(report.files.is_empty()); + assert!(report.mcp.is_empty()); + assert!(report.env_projects.is_empty()); + assert!(!dest.exists(".aws/config")); + assert!(dest.envs.projects().unwrap().is_empty()); + assert!( + report.notes.iter().any(|n| n.contains("--keys-only")), + "{:?}", + report.notes + ); + } + #[test] fn test_mcp_registrations_are_restored_through_the_client_writer() { let source = Machine::new(&[( diff --git a/crates/patchbay-core/src/migrate/plan.rs b/crates/patchbay-core/src/migrate/plan.rs index 1c3165f..5609611 100644 --- a/crates/patchbay-core/src/migrate/plan.rs +++ b/crates/patchbay-core/src/migrate/plan.rs @@ -40,13 +40,12 @@ pub fn plan( envs: &EnvRegistry, manifest: Option<&Manifest>, ) -> Vec { - let _ = paths; let mut items = Vec::new(); let statuses = registry.status_all(); for status in &statuses { let expected = manifest.and_then(|m| m.tool(&status.tool)); - items.extend(tool_items(status, expected)); + items.extend(tool_items(paths, status, expected)); } items.extend(key_items(vault, manifest)); items.extend(mcp_items(clients, manifest)); @@ -85,7 +84,7 @@ fn source_had_something(expected: Option<&ToolRecord>, status: &ToolStatus) -> b } } -fn tool_items(status: &ToolStatus, expected: Option<&ToolRecord>) -> Vec { +fn tool_items(paths: &Paths, status: &ToolStatus, expected: Option<&ToolRecord>) -> Vec { let Some(policy) = policy_for(&status.tool) else { return Vec::new(); }; @@ -180,6 +179,9 @@ fn tool_items(status: &ToolStatus, expected: Option<&ToolRecord>) -> Vec) -> Vec SetupItem { + let unmerged = unmerged_kubeconfigs(paths); + if unmerged.is_empty() { + return item; + } + let dir = super::policy::Location::KubeConfigs.destination(paths, ""); + let list: Vec = unmerged.iter().map(|p| p.display().to_string()).collect(); + item.command(format!("export KUBECONFIG={}", list.join(":")), false) + .detail(format!( + "{} kubeconfig(s) are already in {} — an import puts them there — but KUBECONFIG \ + names none of them, and kubectl merges only the files it names", + unmerged.len(), + dir.display() + )) +} + +/// Kubeconfigs in the directory an import restores them into that +/// `KUBECONFIG` does not name. +/// +/// The `.yaml`/`.yml` test is the one the kubectl probe already applies when it +/// scans a config *directory*. Everything else in `~/.kube` — `cache/`, +/// `http-cache/`, a `.patchbay-bak` this import wrote — is not a kubeconfig, +/// and a `KUBECONFIG` line naming one of those is worse than none. +fn unmerged_kubeconfigs(paths: &Paths) -> Vec { + let named = paths.kube_configs(); + let dir = super::policy::Location::KubeConfigs.destination(paths, ""); + let Ok(entries) = std::fs::read_dir(&dir) else { + return Vec::new(); + }; + let mut found: Vec = entries + .filter_map(Result::ok) + .map(|entry| entry.path()) + .filter(|path| path.is_file()) + .filter(|path| { + matches!( + path.extension().and_then(|e| e.to_str()), + Some("yaml") | Some("yml") + ) + }) + .filter(|path| !named.contains(path)) + .collect(); + found.sort(); + found +} + // --------------------------------------------------------------------------- // vault // --------------------------------------------------------------------------- @@ -853,6 +911,89 @@ mod tests { .is_none()); } + /// The state the real move ended in: kubectl installed, several + /// kubeconfigs sitting where the import put them, and `KUBECONFIG` + /// naming none of them. + fn machine_with_loose_kubeconfigs() -> (tempfile::TempDir, Paths) { + let dir = tempfile::tempdir().unwrap(); + let kube = dir.path().join(".kube"); + fs::create_dir_all(kube.join("cache")).unwrap(); + for name in ["prod.yaml", "staging.yaml"] { + fs::write(kube.join(name), "apiVersion: v1\nclusters: []\n").unwrap(); + } + // Neither of these is a kubeconfig, and neither may reach the line. + fs::write(kube.join("config.patchbay-bak"), "apiVersion: v1\n").unwrap(); + fs::write(kube.join("http-cache.json"), "{}").unwrap(); + + // `has_binary` is true under a scripted exec, which is what makes + // kubectl read as installed with nothing logged in. + let paths = Paths::for_test(dir.path()) + .with_exec(std::sync::Arc::new(crate::util::FakeExec::new())); + (dir, paths) + } + + fn plan_for(paths: &Paths) -> Vec { + let registry = Registry::all(paths.clone()); + let vault = KeyRegistry::new( + paths.home().join("keys.json"), + Box::new(MemoryKeystore::new()), + ); + let clients = McpClientRegistry::with_paths(paths.clone()); + let envs = EnvRegistry::new( + paths.home().join("projects.json"), + paths.home().join("attachments.json"), + Box::new(MemoryKeystore::new()), + ); + plan(paths, ®istry, &vault, &clients, &envs, None) + } + + #[test] + fn test_the_kubectl_item_carries_the_kubeconfig_line_an_import_needs() { + let (dir, paths) = machine_with_loose_kubeconfigs(); + let items = plan_for(&paths); + let kubectl = item(&items, "tool:kubectl"); + assert!(kubectl.is_open(), "{kubectl:?}"); + + // The fix, not another look at the problem. + assert!( + kubectl.command.starts_with("export KUBECONFIG="), + "{kubectl:?}" + ); + let kube = dir.path().join(".kube"); + assert_eq!( + kubectl.command, + format!( + "export KUBECONFIG={}:{}", + kube.join("prod.yaml").display(), + kube.join("staging.yaml").display() + ) + ); + assert!( + kubectl.detail.iter().any(|d| d.contains("merges only")), + "{kubectl:?}" + ); + } + + #[test] + fn test_kubeconfigs_the_variable_already_names_change_nothing() { + let (dir, _) = machine_with_loose_kubeconfigs(); + let kube = dir.path().join(".kube"); + let list = format!( + "{}:{}", + kube.join("prod.yaml").display(), + kube.join("staging.yaml").display() + ); + // Same files, this time merged: there is no gap to describe, so the + // item goes back to the policy's own command. + let paths = Paths::for_test(dir.path()) + .with_exec(std::sync::Arc::new(crate::util::FakeExec::new())) + .with_env("KUBECONFIG", &list); + let items = plan_for(&paths); + let kubectl = item(&items, "tool:kubectl"); + assert_eq!(kubectl.command, "kubectl config get-contexts"); + assert!(kubectl.detail.is_empty(), "{kubectl:?}"); + } + #[test] fn test_every_item_carries_a_command() { let source = Machine::new(&[ diff --git a/crates/patchbay-core/src/migrate/setup.rs b/crates/patchbay-core/src/migrate/setup.rs index b1759b6..3903e61 100644 --- a/crates/patchbay-core/src/migrate/setup.rs +++ b/crates/patchbay-core/src/migrate/setup.rs @@ -7,10 +7,64 @@ //! //! Same rule as the manifest it is generated from: **no secret values.** It is //! rendered from a [`Manifest`], which cannot contain any. +//! +//! One step of it is written twice. Everything here is inside the encrypted +//! payload, which makes the first step circular — you need `pb` to read the +//! instructions for installing `pb` — so [`sidecar`] emits that step on its own, +//! in the clear, next to the bundle. It is the only part that may travel +//! unencrypted, and it says nothing about the machine. use super::manifest::Manifest; use super::policy::{policy_for, PortabilityKind}; +/// The heading of the install step, so the bundle's `SETUP.md` and the +/// cleartext [`sidecar`] name the same thing. +const INSTALL_HEADING: &str = "Get patchbay onto this machine"; + +/// The install commands themselves, without a heading. +/// +/// A function rather than prose written twice: `pb export` emits these both +/// inside the bundle and in the clear beside it, and the copy that drifted +/// would be the one somebody was reading on a laptop with no `pb` on it. +fn install_body() -> &'static str { + "```sh\n\ + # CLI + MCP server (Apple silicon; use x86_64-apple-darwin on Intel)\n\ + tag=v0.1.0; arch=aarch64-apple-darwin; tmp=$(mktemp -d)\n\ + curl -fsSL \"https://github.com/pathorsAI/patchbay/releases/download/$tag/pb-$tag-$arch.tar.gz\" \\\n\ + \x20 | tar xz -C \"$tmp\"\n\ + sudo mv \"$tmp/pb\" /usr/local/bin/\n\ + ```\n\n\ + Or build it: `git clone https://github.com/pathorsAI/patchbay && cd patchbay && \ + cargo install --path crates/patchbay-cli`.\n\n" +} + +/// The install step, in the clear, to be written *beside* the bundle. +/// +/// `SETUP.md` is the fuller document and it lives inside the encrypted +/// payload, which makes the first step of it circular: you need `pb` to read +/// the instructions for installing `pb`. On a real move the receiving Mac had +/// neither, and the command was looked up on the releases page by hand. +/// +/// So this copy is cleartext, and therefore says **nothing about the machine** +/// — no inventory, no tool list, no key id, no variable name. It takes no +/// [`Manifest`], which is the guarantee rather than the intention: assume it +/// gets pasted into a chat. `bundle` is used for the import line and nothing +/// else. +pub fn sidecar(bundle: &str) -> String { + format!( + "# {INSTALL_HEADING}\n\n\ + {}\ + ## Then import\n\n\ + ```sh\n\ + pb import {bundle} --dry-run # see the plan, write nothing\n\ + pb import {bundle} # do it\n\ + ```\n\n\ + The passphrase is not in this file. Everything else about the move is inside the bundle, \ + as `SETUP.md`, which the import writes out.\n", + install_body() + ) +} + /// The whole document, as Markdown. pub fn render(manifest: &Manifest) -> String { let mut out = String::new(); @@ -23,18 +77,8 @@ pub fn render(manifest: &Manifest) -> String { manifest.source.patchbay_version, manifest.source.os )); - out.push_str( - "## 1. Get patchbay onto this machine\n\n\ - ```sh\n\ - # CLI + MCP server (Apple silicon; use x86_64-apple-darwin on Intel)\n\ - tag=v0.1.0; arch=aarch64-apple-darwin; tmp=$(mktemp -d)\n\ - curl -fsSL \"https://github.com/pathorsAI/patchbay/releases/download/$tag/pb-$tag-$arch.tar.gz\" \\\n\ - \x20 | tar xz -C \"$tmp\"\n\ - sudo mv \"$tmp/pb\" /usr/local/bin/\n\ - ```\n\n\ - Or build it: `git clone https://github.com/pathorsAI/patchbay && cd patchbay && \ - cargo install --path crates/patchbay-cli`.\n\n", - ); + out.push_str(&format!("## 1. {INSTALL_HEADING}\n\n")); + out.push_str(install_body()); out.push_str( "## 2. Import\n\n\ @@ -301,6 +345,45 @@ mod tests { assert!(md.contains(".patchbay-bak"), "{md}"); } + #[test] + fn test_the_cleartext_sidecar_carries_the_install_step_and_nothing_else() { + let md = sidecar("patchbay-2026-08-13.pbx"); + // The one step that cannot be read from inside the bundle. + assert!(md.contains("releases/download"), "{md}"); + assert!( + md.contains("cargo install --path crates/patchbay-cli"), + "{md}" + ); + assert!(md.contains("pb import patchbay-2026-08-13.pbx"), "{md}"); + + // Assume it is pasted into a chat. `sidecar` takes no manifest at all, + // which is the real guarantee; this is here so that a change which + // hands it one gets caught. (`gh` and `pathors` are not on the list: + // both are substrings of the patchbay repo URL.) + let manifest = manifest(); + for forbidden in [ + "aws", + "cf-api", + "9876", + "GRAFANA_TOKEN", + "cursor", + "octocat", + "me@work.com", + ] { + assert!( + !md.contains(forbidden), + "`{forbidden}` in the sidecar:\n{md}" + ); + } + // …and the inventory really is in the document it sits beside, so the + // loop above is a difference between the two rather than a vacuous + // assertion. + let inside = render(&manifest); + for present in ["aws", "cf-api", "9876", "me@work.com"] { + assert!(inside.contains(present), "`{present}` missing:\n{inside}"); + } + } + #[test] fn test_setup_md_lists_each_gap_with_its_exact_command() { let md = render(&manifest()); diff --git a/docs/migration.md b/docs/migration.md index 45b5ce4..b25e5e5 100644 --- a/docs/migration.md +++ b/docs/migration.md @@ -12,6 +12,8 @@ with the exact command for every gap. ```sh # old machine pb export # -> patchbay-2026-08-13.pbx, encrypted + # + patchbay-2026-08-13-SETUP.md, in the clear: + # how to install pb on a machine with no pb # copy it across by AirDrop / USB / LAN — not by cloud sync @@ -52,6 +54,7 @@ into a chat with your agent: | encrypted | yes, passphrase required | no — there is nothing to encrypt | | safe to commit | **no** | yes | | refuses a cloud-sync folder | yes | no; that is where it belongs | +| readable without patchbay | only the `-SETUP.md` sidecar: install commands, nothing about the machine | the whole file; that is the point of it | One thing does travel verbatim that is worth knowing about: a key's `purpose` note is free text written by whoever registered it. patchbay never puts a secret @@ -85,6 +88,13 @@ One encrypted file, four parts: Parts 3 and 4 live *inside* the encrypted payload and are written out on import. +Part 4 is *also* written outside it, as a cleartext `-SETUP.md` beside +the file. Instructions for installing `pb` that need a `pb` to be read are not +instructions: on a real move the receiving Mac had neither, and the install +command was looked up on the releases page by hand. So the sidecar carries the +install step and the `pb import` line, and nothing else — no inventory, no tool +list, no key id, no variable name. Assume it will be pasted into a chat. + ## The project env vault [`pb env`](env-vault.md) rides along, as metadata. The bundle carries the @@ -172,7 +182,12 @@ reason next to it. A probe added without a policy fails the build's - **Encryption** is `age` with a passphrase (scrypt recipient) — no key files to manage or lose. Prompted twice on export, hidden, never taken as an argument: - argv is visible to `ps` and lands in your shell history. + argv is visible to `ps` and lands in your shell history. Automation names a + source instead of being blocked — `--passphrase-file `, which refuses a + file anybody else can read because with that flag the file *is* the secret, or + `--passphrase-fd `, the way gpg, restic and age do it. A documented path + matters here: the refusal these replace was worked around with a pty wrapper, + which echoed the passphrase into a log. - **The bundle is `0600`** and starts with a 19-byte cleartext header (`patchbay-bundle/1`) so a version skew is refused before you type a passphrase. Nothing else about it is readable. @@ -203,7 +218,25 @@ reason next to it. A probe added without a policy fails the build's `AWS_SHARED_CREDENTIALS_FILE` or a `[paths]` entry on the new machine decides where a file lands. - Several kubeconfigs land in one directory, with a note telling you the - `KUBECONFIG` line to set — kubectl only merges what the variable names. + `KUBECONFIG` line to set — kubectl only merges what the variable names. That + line does not live only in the import's output: `pb plan` re-derives it from + the directory, so the kubectl item carries the `export KUBECONFIG=…` itself + however long after the import you ask. +- **The keychain is asked before anything is written.** A bundle carrying key + values starts by storing and deleting one throwaway keychain item. If that is + refused — on macOS every write from a session with no desktop login is, so + ssh and cron are — the import stops with nothing written and says to re-run + from a Terminal in your own desktop session. The ordering is the whole point: + the files are restored before the secrets, so without the probe an import over + ssh copies every credential file, drops every secret and exits 0. `--dry-run` + cannot ask, because asking is a write, and says so instead of guessing. +- **An import that lost a secret exits 1**, like `pb plan`, so + `pb import x.pbx && ./something` does the obvious thing. Everything landed or + `unchanged` exits 0. +- **`pb import --keys-only`** restores the vault half alone — no files, + no MCP registrations, no env projects — which is how the move is finished once + the session is fixed. The file half is idempotent anyway, so a plain re-run is + just as safe. ## The AI-guided half From e03ae8240b41729a0b42a8ed552a6c0c57e09d6b Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 10 Sep 2026 16:08:40 +0800 Subject: [PATCH 3/6] [fix] key verify: a rejection from an endpoint that cannot answer is not a verdict MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cloudflare`s /user/tokens/verify only answers for user-owned tokens. An account-owned or scoped token gets the same "Invalid API Token" rejection whether it is live or revoked, and patchbay believed it — telling the user to replace nine working keys in one real vault, which is the mistake the verdict enum`s own doc comment warns about. A rejection now falls back to listing accounts with the token: if that works, the token demonstrably does. If it does not, the answer is the new `inconclusive` verdict, because at that point a revoked token and a narrowly scoped one are the same HTTP response and asserting either would be a guess. A GitHub App private key had the same false alarm from `GET /user`, which can never authenticate a PEM; a PEM-shaped value is now `unsupported` before any request is made. `pb key verify` also took exactly one id, which in a vault of 64 means nobody checks. It now takes a list or `--all`, sweeps bounded-concurrent, and exits 1 for bad news, 2 for a provider it could not reach, 0 otherwise — `inconclusive` and `unsupported` are in that 0, because neither is a fact about the key. --- app/src/types.ts | 24 ++ crates/patchbay-cli/src/keys.rs | 427 ++++++++++++++++++++---- crates/patchbay-core/src/keys_verify.rs | 289 +++++++++++++++- crates/patchbay-core/src/lib.rs | 2 +- crates/patchbay-core/src/versions.rs | 10 +- crates/patchbay-mcp/src/keys.rs | 11 +- docs/key-vault.md | 44 ++- 7 files changed, 724 insertions(+), 83 deletions(-) diff --git a/app/src/types.ts b/app/src/types.ts index f947abc..6e1206a 100644 --- a/app/src/types.ts +++ b/app/src/types.ts @@ -203,6 +203,30 @@ export const KEY_EXPIRY_LEVEL: Record = { no_expiry: "unknown", }; +/** Mirrors `patchbay_core::KeyVerifyStatus` — what the *issuer* said, as + * opposed to what the registry recorded. There is no label map because the + * status strings are already the labels core prints. */ +export type KeyVerifyStatus = + | "valid" + | "invalid" + | "expired" + | "inconclusive" + | "unsupported" + | "unreachable"; + +/** The chip class each verify verdict draws with. `inconclusive`, + * `unsupported` and `unreachable` share the plainest one on purpose: they are + * patchbay failing to get an answer, and drawing them as warnings would put a + * working key on the same footing as a revoked one. */ +export const KEY_VERIFY_LEVEL: Record = { + valid: "ok", + invalid: "expired", + expired: "expired", + inconclusive: "unknown", + unsupported: "unknown", + unreachable: "unknown", +}; + /** * A vault key as it rides along on a tool's status — metadata only. There is no * secret value in this shape and there must never be one: the vault's values diff --git a/crates/patchbay-cli/src/keys.rs b/crates/patchbay-cli/src/keys.rs index ea6fc5b..065c3c4 100644 --- a/crates/patchbay-cli/src/keys.rs +++ b/crates/patchbay-cli/src/keys.rs @@ -21,6 +21,7 @@ use patchbay_core::keys::{ NewKey, }; use patchbay_core::keys_verify::{verify_key, KeyVerifyOutcome, KeyVerifyStatus}; +use patchbay_core::run_bounded; use crate::render::{self, Styles}; @@ -37,8 +38,17 @@ const COL_ENV_MAX: usize = 22; const COL_LABEL_MAX: usize = 18; /// Width of the field-name column in `pb key edit`'s report of what changed. const FIELD_COL: usize = 9; +/// Width of the verdict column in a `pb key verify` sweep: `inconclusive`, the +/// longest label there is. +const COL_VERDICT: usize = 12; const DASH: &str = "—"; +/// How many issuers a sweep talks to at once. Sixty round trips one after +/// another is a minute of watching a cursor; the ceiling is there because a +/// burst of parallel requests from one machine is what rate limiters exist to +/// notice, and it matches the one the version check already settled on. +const VERIFY_THREADS: usize = 8; + #[derive(Subcommand, Debug)] pub enum Command { /// Register a key patchbay should know about. @@ -75,19 +85,16 @@ pub enum Command { #[arg(last = true, required = true, value_name = "CMD")] command: Vec, }, - /// Ask the issuer whether a key still works. + /// Ask the issuers whether keys still work. /// - /// Exit codes: 0 verified (or nothing patchbay can check), 1 the provider - /// says the key is dead, 2 the provider could not be reached. - Verify { - id: String, - #[arg(long)] - json: bool, - /// Report only: do not write the issuer's expiry and scopes back into - /// the registry. - #[arg(long)] - no_update: bool, - }, + /// Exit codes: 1 a provider says one of them is dead, 2 nothing is dead but + /// a provider could not be reached, 0 everything else — including the keys + /// patchbay has no way to check. + #[command(after_help = "Examples:\n \ + pb key verify cf-r2-token-sonarqube-backups\n \ + pb key verify cf-api gh-pat neon-api-key\n \ + pb key verify --all")] + Verify(VerifyArgs), /// Unregister a key: metadata entry and keychain item both. Rm { id: String, @@ -97,6 +104,28 @@ pub enum Command { }, } +/// Everything `pb key verify` takes. +/// +/// Ids are plural because they have to be: a vault of sixty keys and a checker +/// that answers one question per invocation is a checker nobody runs. +#[derive(Args, Debug)] +pub struct VerifyArgs { + /// Keys to check. Leave empty only with `--all`. + #[arg(value_name = "ID", required_unless_present = "all")] + ids: Vec, + /// Check every registered key. + #[arg(long, conflicts_with = "ids")] + all: bool, + /// The verdicts as JSON: a list when several keys were asked about, the + /// bare object when it was one. + #[arg(long)] + json: bool, + /// Report only: do not write the issuer's expiry and scopes back into + /// the registry. + #[arg(long)] + no_update: bool, +} + /// Everything `pb key add` takes. The secret is deliberately absent: it is /// read from stdin or a hidden prompt, never from argv. #[derive(Args, Debug)] @@ -213,11 +242,7 @@ pub fn run(command: Command, styles: &Styles) -> Result { aliases, command, } => run_command(®istry, &keys, &aliases, &command), - Command::Verify { - id, - json, - no_update, - } => verify(®istry, &id, json, no_update, styles), + Command::Verify(args) => verify(®istry, args, styles), Command::Rm { id, yes } => rm(®istry, &id, yes), } } @@ -370,46 +395,198 @@ fn rm(registry: &KeyRegistry, id: &str, yes: bool) -> Result { // verify // --------------------------------------------------------------------------- -/// `pb key verify` — ask the issuer, then report (and usually record) what it -/// said. Returns the exit code the subcommand's own docs promise. -fn verify( - registry: &KeyRegistry, - id: &str, - json: bool, - no_update: bool, - styles: &Styles, -) -> Result { - let entry = registry - .get(id)? - .ok_or_else(|| anyhow::anyhow!("no key registered as `{id}`"))?; - // The secret lives for exactly this call and is never printed. - let secret = registry.get_secret(id)?; - let outcome = verify_key(&entry, &secret); - drop(secret); +/// `pb key verify` — ask the issuers, then report (and usually record) what +/// they said. +fn verify(registry: &KeyRegistry, args: VerifyArgs, styles: &Styles) -> Result { + let VerifyArgs { + ids, + all, + json, + no_update, + } = args; - let updated = if no_update { - Vec::new() + let entries: Vec = if all { + registry.list()? } else { - absorb(registry, &entry, &outcome)? + ids.iter() + .map(|id| { + registry + .get(id)? + .ok_or_else(|| anyhow::anyhow!("no key registered as `{id}`")) + }) + .collect::>()? }; + if entries.is_empty() { + // Only reachable through `--all`. An id nobody registered is an error; + // an empty vault is not. + println!("no keys registered yet"); + return Ok(0); + } + // Each secret is read inside the closure and dropped there, so even a sweep + // of the whole vault never holds more than VERIFY_THREADS of them at once, + // and none of them outlives its own request. + let outcomes = run_bounded(&entries, VERIFY_THREADS, |entry| { + let secret = registry.get_secret(&entry.id)?; + let outcome = verify_key(entry, &secret); + drop(secret); + Ok(outcome) + }); + // A value patchbay registered but cannot read back is a broken vault, not a + // verdict about a key, so it stops the sweep instead of being filed as one. + let outcomes = outcomes.into_iter().collect::>>()?; + + // Write-backs stay on this thread: they rewrite one metadata file, and + // eight threads doing that would be eight chances to lose an entry. + let mut updated: Vec> = Vec::new(); + for (entry, outcome) in entries.iter().zip(&outcomes) { + updated.push(if no_update { + Vec::new() + } else { + absorb(registry, entry, outcome)? + }); + } + + // One key asked about by name keeps the answer it has always had: a full + // block, and a bare object under `--json` that existing readers can still + // index into. `--all` is a sweep and always answers as a list. + let sweep = all || entries.len() > 1; if json { - let mut value = serde_json::to_value(&outcome)?; - if let Some(map) = value.as_object_mut() { - map.insert("id".into(), entry.id.clone().into()); - map.insert("provider".into(), entry.provider.clone().into()); - map.insert("metadata_updated".into(), updated.clone().into()); + let mut values = Vec::new(); + for ((entry, outcome), updated) in entries.iter().zip(&outcomes).zip(&updated) { + values.push(verify_json(entry, outcome, updated)?); } + let value = if sweep { + serde_json::Value::Array(values) + } else { + values.remove(0) + }; println!("{}", serde_json::to_string_pretty(&value)?); + } else if sweep { + let id_w = entries + .iter() + .map(|e| e.id.chars().count()) + .max() + .unwrap_or_default(); + for ((entry, outcome), updated) in entries.iter().zip(&outcomes).zip(&updated) { + println!("{}", verify_line(entry, outcome, id_w, styles)); + // The one thing a sweep must not do quietly. Rare, because it only + // fires when the issuer knows something the registry did not. + if !updated.is_empty() { + println!( + " updated the registry from the provider: {}", + updated.join(", ") + ); + } + } + println!("{}", verify_summary(&outcomes)); } else { - print_verify(&entry, &outcome, &updated, styles); - } - Ok(match outcome.status { - KeyVerifyStatus::Valid | KeyVerifyStatus::Unsupported => 0, - KeyVerifyStatus::Invalid | KeyVerifyStatus::Expired => 1, - // Distinct from 1: nothing was learned about the key. - KeyVerifyStatus::Unreachable => 2, - }) + print_verify(&entries[0], &outcomes[0], &updated[0], styles); + } + Ok(verify_exit_code(&outcomes)) +} + +/// Three answers, because a script gating on this needs three. +/// +/// `1` is the only one that means a key is dead, and it outranks everything: a +/// sweep that found one revoked token and lost the wifi halfway through is +/// still a sweep that found a revoked token. `2` is the honest answer when the +/// worst thing that happened was not being able to ask, which is neither a +/// clean bill of health nor a reason to rotate anything. +/// +/// `inconclusive` and `unsupported` leave `0`, because both are patchbay +/// declining to answer about a key that gave it no reason for concern — and a +/// vault that is mostly providers patchbay cannot interrogate would otherwise +/// never exit clean. +fn verify_exit_code(outcomes: &[KeyVerifyOutcome]) -> i32 { + if outcomes.iter().any(|o| o.status.is_bad_news()) { + 1 + } else if outcomes + .iter() + .any(|o| o.status == KeyVerifyStatus::Unreachable) + { + 2 + } else { + 0 + } +} + +/// One verdict as `--json`: the outcome, plus who it is about. +fn verify_json( + entry: &KeyEntry, + outcome: &KeyVerifyOutcome, + updated: &[String], +) -> Result { + let mut value = serde_json::to_value(outcome)?; + if let Some(map) = value.as_object_mut() { + map.insert("id".into(), entry.id.clone().into()); + map.insert("provider".into(), entry.provider.clone().into()); + map.insert("metadata_updated".into(), updated.to_vec().into()); + } + Ok(value) +} + +/// One key's row in a sweep: the same verdict voice as the single-key report, +/// with the provider's message trimmed to whatever the line has left. +/// +/// The id is never truncated, however wide it makes the column: the next thing +/// anyone does with a bad row is paste that id into another `pb key` command. +fn verify_line( + entry: &KeyEntry, + outcome: &KeyVerifyOutcome, + id_w: usize, + styles: &Styles, +) -> String { + let verdict = styles.paint( + verdict_style(outcome.status), + &pad(outcome.status.label(), COL_VERDICT), + ); + let detail_w = TABLE_WIDTH + .saturating_sub(id_w + COL_VERDICT + GAP * 2) + .max(20); + let detail = render::truncate(&one_line(&outcome.detail), detail_w); + let gap = " ".repeat(GAP); + format!("{}{gap}{verdict}{gap}{detail}", pad(&entry.id, id_w)) + .trim_end() + .to_string() +} + +/// The tally that closes a sweep. Worst news first, and silent about the +/// verdicts nothing came back as. +fn verify_summary(outcomes: &[KeyVerifyOutcome]) -> String { + const WORST_FIRST: [KeyVerifyStatus; 6] = [ + KeyVerifyStatus::Invalid, + KeyVerifyStatus::Expired, + KeyVerifyStatus::Inconclusive, + KeyVerifyStatus::Unreachable, + KeyVerifyStatus::Unsupported, + KeyVerifyStatus::Valid, + ]; + let tally: Vec = WORST_FIRST + .iter() + .filter_map(|status| { + let n = outcomes.iter().filter(|o| o.status == *status).count(); + (n > 0).then(|| format!("{n} {}", status.label())) + }) + .collect(); + format!( + "checked {} {}: {}", + outcomes.len(), + if outcomes.len() == 1 { "key" } else { "keys" }, + tally.join(", ") + ) +} + +/// The colour a verdict draws in: green for good news, red for bad, and dim +/// for the answers that are not about the key at all. +fn verdict_style(status: KeyVerifyStatus) -> anstyle::Style { + match status { + KeyVerifyStatus::Valid => green(), + KeyVerifyStatus::Invalid | KeyVerifyStatus::Expired => red(), + KeyVerifyStatus::Inconclusive + | KeyVerifyStatus::Unsupported + | KeyVerifyStatus::Unreachable => dim(), + } } /// Write back what the issuer just told us, and report what changed. @@ -446,16 +623,11 @@ fn absorb( } fn print_verify(entry: &KeyEntry, outcome: &KeyVerifyOutcome, updated: &[String], styles: &Styles) { - let style = match outcome.status { - KeyVerifyStatus::Valid => green(), - KeyVerifyStatus::Invalid | KeyVerifyStatus::Expired => red(), - KeyVerifyStatus::Unsupported | KeyVerifyStatus::Unreachable => dim(), - }; println!( "{} (…{}) — {}", entry.id, entry.last4, - styles.paint(style, outcome.status.label()) + styles.paint(verdict_style(outcome.status), outcome.status.label()) ); println!(" {}", one_line(&outcome.detail)); @@ -475,7 +647,14 @@ fn print_verify(entry: &KeyEntry, outcome: &KeyVerifyOutcome, updated: &[String] updated.join(", ") ); } - if outcome.status == KeyVerifyStatus::Unsupported && entry.endpoint.is_none() { + // `unsupported` covers two different situations: an issuer patchbay cannot + // interrogate at all, and one it could if it had an address. Only the + // second wants an endpoint, and the way to tell them apart is that the + // provider's own message asked for one. + if outcome.status == KeyVerifyStatus::Unsupported + && entry.endpoint.is_none() + && outcome.detail.contains("--endpoint") + { println!( " set one with: pb key add {} --provider {} --endpoint --overwrite", entry.id, entry.provider @@ -484,6 +663,9 @@ fn print_verify(entry: &KeyEntry, outcome: &KeyVerifyOutcome, updated: &[String] if outcome.status == KeyVerifyStatus::Unreachable { println!(" the key was not tested — this is a connection problem, not a verdict"); } + if outcome.status == KeyVerifyStatus::Inconclusive { + println!(" patchbay is not saying the key is dead — it is saying it cannot tell"); + } } // --------------------------------------------------------------------------- @@ -1166,6 +1348,7 @@ mod tests { for status in [ KeyVerifyStatus::Unreachable, KeyVerifyStatus::Unsupported, + KeyVerifyStatus::Inconclusive, KeyVerifyStatus::Invalid, KeyVerifyStatus::Expired, ] { @@ -1205,6 +1388,138 @@ mod tests { ); } + /// `pb key …` as clap sees it, without standing up the whole `pb` parser. + #[derive(clap::Parser, Debug)] + struct KeyCli { + #[command(subcommand)] + command: Command, + } + + fn parse_verify(argv: &[&str]) -> Result { + use clap::Parser; + match KeyCli::try_parse_from(argv)?.command { + Command::Verify(args) => Ok(args), + other => panic!("expected verify, parsed {other:?}"), + } + } + + #[test] + fn test_verify_takes_a_list_of_ids_and_all_takes_none() { + // What this replaces: `pb key verify a b c` was "unexpected argument + // 'b' found", which made a 64-key vault 64 invocations. + let args = parse_verify(&["pb", "verify", "a", "b", "c"]).unwrap(); + assert_eq!(args.ids, vec!["a", "b", "c"]); + assert!(!args.all); + + let args = parse_verify(&["pb", "verify", "--all"]).unwrap(); + assert!(args.all && args.ids.is_empty()); + + // Naming nothing is a mistake, not a sweep, and naming ids alongside + // `--all` is two different questions at once. + assert!(parse_verify(&["pb", "verify"]).is_err()); + assert!(parse_verify(&["pb", "verify", "--all", "a"]).is_err()); + } + + #[test] + fn test_a_sweep_line_keeps_the_whole_id_and_spends_what_is_left_on_the_detail() { + let long = "cf-r2-token-sonarqube-backups"; + let id_w = long.chars().count(); + let mut wordy = outcome(KeyVerifyStatus::Inconclusive); + wordy.detail = "Cloudflare said `Invalid API Token` to both checks patchbay can make, \ + and a token scoped to one product answers exactly like a revoked one" + .to_string(); + + let line = verify_line(&entry(long, None), &wordy, id_w, &Styles::new(false)); + assert!(line.starts_with(long), "the id must survive intact: {line}"); + assert!(line.contains("inconclusive"), "{line}"); + assert!(line.ends_with('…'), "a long detail is trimmed, not wrapped"); + assert!(line.chars().count() <= TABLE_WIDTH, "{line}"); + + // A shorter id in the same sweep still lines its verdict up with the + // widest one's. + let short = verify_line( + &entry("cf-api", None), + &outcome(KeyVerifyStatus::Valid), + id_w, + &Styles::new(false), + ); + assert_eq!( + short.find("valid").unwrap(), + line.find("inconclusive").unwrap(), + "{short}\n{line}" + ); + } + + #[test] + fn test_the_sweep_summary_leads_with_the_bad_news_and_names_only_what_came_back() { + let outcomes = vec![ + outcome(KeyVerifyStatus::Valid), + outcome(KeyVerifyStatus::Valid), + outcome(KeyVerifyStatus::Unsupported), + outcome(KeyVerifyStatus::Inconclusive), + outcome(KeyVerifyStatus::Invalid), + ]; + assert_eq!( + verify_summary(&outcomes), + "checked 5 keys: 1 invalid, 1 inconclusive, 1 unsupported, 2 valid" + ); + assert_eq!( + verify_summary(&[outcome(KeyVerifyStatus::Valid)]), + "checked 1 key: 1 valid" + ); + } + + #[test] + fn test_only_a_provider_saying_a_key_is_dead_fails_the_command() { + for status in [ + KeyVerifyStatus::Valid, + KeyVerifyStatus::Unsupported, + KeyVerifyStatus::Inconclusive, + ] { + assert_eq!(verify_exit_code(&[outcome(status)]), 0, "{status:?}"); + } + for status in [KeyVerifyStatus::Invalid, KeyVerifyStatus::Expired] { + assert_eq!(verify_exit_code(&[outcome(status)]), 1, "{status:?}"); + } + // One dead key does not get lost in a sweep of good ones. + assert_eq!( + verify_exit_code(&[ + outcome(KeyVerifyStatus::Valid), + outcome(KeyVerifyStatus::Expired), + outcome(KeyVerifyStatus::Unsupported), + ]), + 1 + ); + } + + #[test] + fn test_a_provider_that_could_not_be_reached_is_its_own_exit_code() { + // Neither a clean bill of health nor a reason to rotate: a script that + // gates on this needs to be able to tell "nothing wrong" from "could + // not ask". + assert_eq!( + verify_exit_code(&[outcome(KeyVerifyStatus::Unreachable)]), + 2 + ); + assert_eq!( + verify_exit_code(&[ + outcome(KeyVerifyStatus::Valid), + outcome(KeyVerifyStatus::Unreachable), + outcome(KeyVerifyStatus::Inconclusive), + ]), + 2 + ); + // Bad news outranks it. A sweep that found a revoked token and then + // lost the wifi still found a revoked token. + assert_eq!( + verify_exit_code(&[ + outcome(KeyVerifyStatus::Unreachable), + outcome(KeyVerifyStatus::Invalid), + ]), + 1 + ); + } + #[test] fn test_parse_aliases_splits_on_the_first_equals_and_validates_the_name() { let raw = |v: &[&str]| -> Vec { v.iter().map(|s| s.to_string()).collect() }; diff --git a/crates/patchbay-core/src/keys_verify.rs b/crates/patchbay-core/src/keys_verify.rs index 02aac75..f31f882 100644 --- a/crates/patchbay-core/src/keys_verify.rs +++ b/crates/patchbay-core/src/keys_verify.rs @@ -6,9 +6,9 @@ //! updates itself from it. //! //! **Secret handling.** The value is pulled from the keystore, put in exactly -//! one place — the `Authorization` header of one outbound request — and -//! dropped. It is never in a URL, a query string, a log line, an error, or the -//! returned outcome. [`KeyVerifyOutcome`] carries a verdict and nothing else, +//! one place — the `Authorization` header of the outbound request, or of the +//! second one where a provider needs asking twice — and dropped. It is never +//! in a URL, a query string, a log line, an error, or the returned outcome. [`KeyVerifyOutcome`] carries a verdict and nothing else, //! which is why the MCP `verify_key` tool is *not* gated behind //! `PATCHBAY_ALLOW_SECRET_READ`: there is nothing in it to leak. //! @@ -48,6 +48,10 @@ pub enum KeyVerifyStatus { Invalid, /// The issuer knows the key but its lifetime is over. Expired, + /// The provider answered, and its answer does not separate a dead key from + /// a live one whose permissions are too narrow to prove it. Every check + /// patchbay could make came back the same for both. + Inconclusive, /// patchbay has no verification path for this provider. Unsupported, /// The provider could not be reached. Says nothing about the key. @@ -55,9 +59,9 @@ pub enum KeyVerifyStatus { } impl KeyVerifyStatus { - /// Whether this verdict is bad news about the key itself. `Unsupported` and - /// `Unreachable` are not: they are patchbay failing to answer, not the key - /// failing to work. + /// Whether this verdict is bad news about the key itself. `Unsupported`, + /// `Inconclusive` and `Unreachable` are not: they are patchbay failing to + /// answer, not the key failing to work. pub fn is_bad_news(&self) -> bool { matches!(self, Self::Invalid | Self::Expired) } @@ -67,6 +71,7 @@ impl KeyVerifyStatus { Self::Valid => "valid", Self::Invalid => "invalid", Self::Expired => "expired", + Self::Inconclusive => "inconclusive", Self::Unsupported => "unsupported", Self::Unreachable => "unreachable", } @@ -211,6 +216,7 @@ type SeenRequest = (String, Vec<(String, String)>); #[derive(Debug, Default)] pub struct StubHttp { response: Option, + queue: std::sync::Mutex>, failure: Option, seen: std::sync::Mutex>, } @@ -223,6 +229,17 @@ impl StubHttp { } } + /// Answer each call from a queue, in order. For the verifiers that ask a + /// second question when the first answer is not a verdict: the responses + /// have to differ per call, and by URL is not enough — the same URL can be + /// asked twice. + pub fn responding_in_turn(responses: Vec) -> Self { + Self { + queue: std::sync::Mutex::new(responses.into()), + ..Self::default() + } + } + pub fn failing(detail: impl Into) -> Self { Self { failure: Some(detail.into()), @@ -235,6 +252,16 @@ impl StubHttp { self.seen.lock().unwrap().last().map(|(u, _)| u.clone()) } + /// Every URL requested, in order. + pub fn urls(&self) -> Vec { + self.seen + .lock() + .unwrap() + .iter() + .map(|(u, _)| u.clone()) + .collect() + } + /// The last request's headers. pub fn last_headers(&self) -> Vec<(String, String)> { self.seen @@ -259,6 +286,9 @@ impl HttpClient for StubHttp { .map(|(k, v)| (k.to_string(), v.to_string())) .collect(), )); + if let Some(response) = self.queue.lock().unwrap().pop_front() { + return Ok(response); + } match (&self.failure, &self.response) { (Some(detail), _) => Err(detail.clone()), (None, Some(response)) => Ok(response.clone()), @@ -317,6 +347,11 @@ fn normalize_provider(provider: &str) -> Option { const CLOUDFLARE_VERIFY: &str = "https://api.cloudflare.com/client/v4/user/tokens/verify"; +/// The second question, asked when the first one refuses to answer. One page of +/// one account is the smallest authenticated read on the API, and the point is +/// the HTTP status, not the list. +const CLOUDFLARE_ACCOUNTS: &str = "https://api.cloudflare.com/client/v4/accounts?per_page=1"; + #[derive(Debug, Deserialize)] struct CfEnvelope { #[serde(default)] @@ -343,6 +378,14 @@ struct CfMessage { message: String, } +/// The envelope reduced to its verdict flag, for the calls where the payload +/// shape is beside the point. +#[derive(Debug, Deserialize)] +struct CfSuccess { + #[serde(default)] + success: bool, +} + fn cloudflare(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { let bearer = format!("Bearer {secret}"); let response = match http.get( @@ -393,9 +436,10 @@ fn cloudflare(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { let detail = first(&envelope.errors) .unwrap_or_else(|| format!("Cloudflare rejected the token (HTTP {})", response.status)); match response.status { - // 400 and 401 are both how Cloudflare says "no": a bad token is a - // malformed request to it. - 400 | 401 | 403 => KeyVerifyOutcome::new(KeyVerifyStatus::Invalid, detail), + // 400 and 401 are both how Cloudflare says "no" — a bad token is a + // malformed request to it — but "no" here is not the same as "dead": + // the endpoint only speaks for user-owned tokens, so ask again. + 400 | 401 | 403 => cloudflare_scoped(secret, http, &detail), 429 => KeyVerifyOutcome::new( KeyVerifyStatus::Unreachable, format!("Cloudflare rate-limited the check ({detail}); try again shortly"), @@ -408,6 +452,72 @@ fn cloudflare(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { } } +/// Re-ask the question a token-verify rejection cannot answer. +/// +/// `/user/tokens/verify` only speaks for tokens owned by a *user*. Hand it an +/// account-owned or scoped token — which is what the Cloudflare dashboard +/// mostly issues now, and what an R2 or Workers token is — and it returns +/// `success: false` with error 1000 "Invalid API Token" while the token is +/// happily deploying in production. Byte for byte the same body a revoked token +/// gets, so the first answer has to be re-asked as something a scoped token can +/// answer: can it read anything at all? +/// +/// A 200 is proof of life. Anything else leaves the two cases genuinely +/// indistinguishable — a token scoped to, say, R2 alone cannot list accounts +/// either — and `Inconclusive` says that instead of picking the answer that +/// gets a working key rotated. +fn cloudflare_scoped(secret: &str, http: &dyn HttpClient, rejection: &str) -> KeyVerifyOutcome { + let bearer = format!("Bearer {secret}"); + let response = match http.get( + CLOUDFLARE_ACCOUNTS, + &[("Authorization", &bearer), ("Accept", "application/json")], + ) { + Ok(response) => response, + Err(detail) => return unreachable_outcome("Cloudflare", &detail), + }; + + match response.status { + // Cloudflare wraps every answer of its own; a 200 carrying anything + // else came from something in the path, not from the API, and proves + // nothing about the token. + 200 if cloudflare_succeeded(&response.body) => KeyVerifyOutcome::new( + KeyVerifyStatus::Valid, + "confirmed by listing accounts with it — Cloudflare's token-verify endpoint \ + only answers for user-owned tokens, and this one is account-owned or scoped" + .to_string(), + ), + 200 => KeyVerifyOutcome::new( + KeyVerifyStatus::Unreachable, + "Cloudflare answered HTTP 200 with something that is not its own JSON envelope" + .to_string(), + ), + 429 => KeyVerifyOutcome::new( + KeyVerifyStatus::Unreachable, + format!("Cloudflare rate-limited the check ({rejection}); try again shortly"), + ), + 500..=599 => KeyVerifyOutcome::new( + KeyVerifyStatus::Unreachable, + format!("Cloudflare returned HTTP {} ({rejection})", response.status), + ), + _ => KeyVerifyOutcome::new( + KeyVerifyStatus::Inconclusive, + format!( + "Cloudflare said `{rejection}` to both checks patchbay can make, and a token \ + scoped to one product answers exactly like a revoked one. Open the token in \ + the dashboard, or run the command that uses it — patchbay cannot tell these \ + two apart from here" + ), + ), + } +} + +/// Whether a body is Cloudflare's own envelope reporting success. Read through +/// its own struct because `result` is a list here and an object on the verify +/// endpoint, and only the flag matters. +fn cloudflare_succeeded(body: &str) -> bool { + serde_json::from_str::(body).is_ok_and(|envelope| envelope.success) +} + // --------------------------------------------------------------------------- // github // --------------------------------------------------------------------------- @@ -427,6 +537,21 @@ struct GhError { } fn github(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { + // A GitHub App's private key is a PEM, not a bearer token: `/user` can only + // ever answer "Bad credentials" to it, which reads as a dead key when the + // key is fine. There is no request worth spending here — verifying an App + // means signing a JWT with this PEM and presenting it as the app, and the + // registry has no app id to sign for. + if is_pem(secret) { + return KeyVerifyOutcome::new( + KeyVerifyStatus::Unsupported, + "this is a PEM private key, not a token GitHub's API will authenticate. A GitHub \ + App key is verified by minting a JWT with it, which needs the app id patchbay \ + does not have — so patchbay has no way to ask. Check it under the App's settings." + .to_string(), + ); + } + let bearer = format!("Bearer {secret}"); let response = match http.get( GITHUB_USER, @@ -491,6 +616,15 @@ fn github(secret: &str, http: &dyn HttpClient) -> KeyVerifyOutcome { } } +/// Whether a value is PEM armour rather than a token. +/// +/// Shape only — the value is read, never reported. RFC 7468 armour always opens +/// with `-----BEGIN`, and no issuer hands out a token that starts that way, so +/// there is nothing to be clever about here. +fn is_pem(secret: &str) -> bool { + secret.trim_start().starts_with("-----BEGIN") +} + /// `"repo, workflow, read:org"` -> three scopes. An empty header is an empty /// list, not a failure. fn parse_scopes(raw: &str) -> Vec { @@ -674,12 +808,21 @@ mod tests { "messages": [{ "code": 10000, "message": "This API Token is valid and active" }] }"#; - /// What a wrong token really gets back: HTTP 400, success false. + /// What a wrong token really gets back: HTTP 400, success false. Also, + /// exactly, what a live account-owned token gets back from `/user/tokens/ + /// verify` — which is the whole reason the fallback exists. const CF_BAD: &str = r#"{ "result": null, "success": false, "errors": [{ "code": 1000, "message": "Invalid API Token" }], "messages": [] }"#; + /// One page of one account, trimmed. Only `success` is read. + const CF_ACCOUNTS: &str = r#"{ + "result": [{ "id": "01a7362d577a6c3019a474fd6f485823", "name": "Pathors" }], + "success": true, "errors": [], "messages": [], + "result_info": { "page": 1, "per_page": 1, "count": 1, "total_count": 1 } + }"#; + #[test] fn test_cloudflare_active_token_is_valid_and_carries_its_expiry() { let http = StubHttp::responding(HttpResponse::new(200, CF_ACTIVE)); @@ -695,6 +838,11 @@ mod tests { out.scopes.is_empty(), "the verify endpoint lists no policies" ); + assert_eq!( + http.call_count(), + 1, + "a definitive answer must not be second-guessed" + ); } #[test] @@ -714,12 +862,93 @@ mod tests { } #[test] - fn test_cloudflare_rejected_token_is_invalid_with_the_providers_own_message() { - let http = StubHttp::responding(HttpResponse::new(400, CF_BAD)); + fn test_cloudflare_account_scoped_token_rejected_by_verify_is_valid_if_it_can_list_accounts() { + // Observed on real keys: an R2 token and an account API token, both in + // daily use, both told "Invalid API Token" by /user/tokens/verify. + let http = StubHttp::responding_in_turn(vec![ + HttpResponse::new(400, CF_BAD), + HttpResponse::new(200, CF_ACCOUNTS), + ]); + let out = verify_key_with(&entry("cloudflare"), "cf-secret", &http); + + assert_eq!(out.status, KeyVerifyStatus::Valid); + assert!(!out.status.is_bad_news()); + assert!( + out.detail.contains("user-owned"), + "the detail has to explain why the first check said otherwise: {}", + out.detail + ); + assert_eq!(http.urls(), vec![CLOUDFLARE_VERIFY, CLOUDFLARE_ACCOUNTS]); + // Same secret, same one place, on the second request too. + let headers = http.last_headers(); + let auth = headers.iter().find(|(k, _)| k == "Authorization").unwrap(); + assert_eq!(auth.1, "Bearer cf-secret"); + assert!(!http.last_url().unwrap().contains("cf-secret")); + } + + #[test] + fn test_cloudflare_rejected_by_both_checks_is_inconclusive_because_a_revoked_token_and_a_narrowly_scoped_one_are_the_same_response( + ) { + // Deliberately undecidable: this is the response a revoked token gets + // AND the response a live R2-only token gets. Calling it `invalid` + // would tell someone to rotate a key that is working. + let http = StubHttp::responding_in_turn(vec![ + HttpResponse::new(400, CF_BAD), + HttpResponse::new(403, CF_BAD), + ]); let out = verify_key_with(&entry("cloudflare"), "nope", &http); - assert_eq!(out.status, KeyVerifyStatus::Invalid); - assert_eq!(out.detail, "Invalid API Token"); - assert!(out.status.is_bad_news()); + + assert_eq!(out.status, KeyVerifyStatus::Inconclusive); + assert!(!out.status.is_bad_news()); + assert_eq!(out.status.label(), "inconclusive"); + assert!( + out.detail.contains("cannot tell these two apart"), + "{}", + out.detail + ); + assert_eq!(http.call_count(), 2); + } + + #[test] + fn test_cloudflare_a_rate_limited_fallback_is_unreachable_not_a_verdict() { + let http = StubHttp::responding_in_turn(vec![ + HttpResponse::new(401, CF_BAD), + HttpResponse::new(429, "{}"), + ]); + let out = verify_key_with(&entry("cloudflare"), "s", &http); + assert_eq!(out.status, KeyVerifyStatus::Unreachable); + assert!(!out.status.is_bad_news()); + } + + #[test] + fn test_cloudflare_a_200_from_the_fallback_still_has_to_be_cloudflares_own_json() { + // A captive portal or a proxy answering 200 with HTML proves nothing; + // reading it as success would report a dead token as live. + let http = StubHttp::responding_in_turn(vec![ + HttpResponse::new(400, CF_BAD), + HttpResponse::new(200, "proxy says hello"), + ]); + let out = verify_key_with(&entry("cloudflare"), "s", &http); + assert_eq!(out.status, KeyVerifyStatus::Unreachable); + assert!(!out.status.is_bad_news()); + } + + #[test] + fn test_cloudflare_reports_a_user_owned_tokens_own_status_without_a_fallback() { + // The token's own reported status is definitive in both directions; + // only a rejection is ambiguous. + for (body, expected) in [ + (CF_ACTIVE, KeyVerifyStatus::Valid), + ( + r#"{"result":{"status":"disabled"},"success":true,"errors":[],"messages":[]}"#, + KeyVerifyStatus::Invalid, + ), + ] { + let http = StubHttp::responding(HttpResponse::new(200, body)); + let out = verify_key_with(&entry("cloudflare"), "s", &http); + assert_eq!(out.status, expected); + assert_eq!(http.call_count(), 1); + } } #[test] @@ -813,6 +1042,34 @@ mod tests { assert_eq!(out.expires_at, None); } + #[test] + fn test_github_app_private_key_is_unsupported_and_never_asks() { + // Observed on a real key: a GitHub App's PEM registered under + // `github`, reported `invalid` — "Bad credentials" — while the App was + // signing CI tokens all day. `/user` has no answer for a PEM, so the + // request is not worth making. + let http = StubHttp::failing("should not be called"); + let pem = "-----BEGIN RSA PRIVATE KEY-----\nMIIEow…\n-----END RSA PRIVATE KEY-----\n"; + let out = verify_key_with(&entry("github"), pem, &http); + + assert_eq!(out.status, KeyVerifyStatus::Unsupported); + assert!(!out.status.is_bad_news()); + assert!(out.detail.contains("JWT"), "{}", out.detail); + assert_eq!(http.call_count(), 0, "a PEM must not reach the network"); + assert!(!serde_json::to_string(&out).unwrap().contains("MIIEow")); + + // Leading whitespace from a heredoc or a copy-paste does not disguise + // it, and an ordinary token still takes the request path. + let out = verify_key_with(&entry("gh"), &format!("\n {pem}"), &http); + assert_eq!(out.status, KeyVerifyStatus::Unsupported); + assert_eq!(http.call_count(), 0); + + let http = StubHttp::responding(HttpResponse::new(200, r#"{"login":"octocat"}"#)); + let out = verify_key_with(&entry("github"), "ghp_ordinary_token", &http); + assert_eq!(out.status, KeyVerifyStatus::Valid); + assert_eq!(http.call_count(), 1); + } + #[test] fn test_github_401_is_invalid_with_githubs_message() { let http = StubHttp::responding(HttpResponse::new( @@ -1081,6 +1338,8 @@ mod tests { assert!(!KeyVerifyStatus::Valid.is_bad_news()); assert!(!KeyVerifyStatus::Unsupported.is_bad_news()); assert!(!KeyVerifyStatus::Unreachable.is_bad_news()); + assert!(!KeyVerifyStatus::Inconclusive.is_bad_news()); assert_eq!(KeyVerifyStatus::Unreachable.label(), "unreachable"); + assert_eq!(KeyVerifyStatus::Inconclusive.label(), "inconclusive"); } } diff --git a/crates/patchbay-core/src/lib.rs b/crates/patchbay-core/src/lib.rs index 7660d8c..e86eba2 100644 --- a/crates/patchbay-core/src/lib.rs +++ b/crates/patchbay-core/src/lib.rs @@ -78,4 +78,4 @@ pub use types::{ ActiveConcept, ConnectionState, Expiry, KeyRef, Note, NoteKind, PermissionScope, PermissionsReport, Profile, SwitchOutcome, ToolCategory, ToolStatus, VerifyOutcome, }; -pub use versions::{CheckOptions, CheckReport, Source, VersionCache, VersionInfo}; +pub use versions::{run_bounded, CheckOptions, CheckReport, Source, VersionCache, VersionInfo}; diff --git a/crates/patchbay-core/src/versions.rs b/crates/patchbay-core/src/versions.rs index c4fb9e9..47e8615 100644 --- a/crates/patchbay-core/src/versions.rs +++ b/crates/patchbay-core/src/versions.rs @@ -1522,8 +1522,14 @@ fn lookup_latest( /// Run `f` over `items` on at most `limit` scoped threads, preserving order. /// /// Scoped threads rather than a pool: everything borrowed here lives on the -/// stack of the caller, and the work is a fixed, small batch. -fn run_bounded(items: &[T], limit: usize, f: impl Fn(&T) -> R + Sync) -> Vec { +/// stack of the caller, and the work is a fixed, small batch. Public because +/// the version check is not the only sweep patchbay makes over a list of +/// network calls, and a second bounded runner would be a second set of bugs. +pub fn run_bounded( + items: &[T], + limit: usize, + f: impl Fn(&T) -> R + Sync, +) -> Vec { if items.is_empty() { return Vec::new(); } diff --git a/crates/patchbay-mcp/src/keys.rs b/crates/patchbay-mcp/src/keys.rs index fdbd261..c301e67 100644 --- a/crates/patchbay-mcp/src/keys.rs +++ b/crates/patchbay-mcp/src/keys.rs @@ -844,9 +844,10 @@ to the user about. A 'suggested' name is in neither: it is waiting on a human to ASK THE ISSUER whether a registered key still works. NOT gated: this returns a verdict, never \ the value, so it is safe to call whenever the answer would change what you do. -Makes one outbound HTTPS request to the provider using the stored secret, which patchbay reads \ -internally and never returns to you. Seconds, not milliseconds. Providers patchbay can \ -interrogate today: cloudflare and github. Anything else comes back 'unsupported'. +Makes an outbound HTTPS request to the provider using the stored secret, which patchbay reads \ +internally and never returns to you — sometimes a second one, where the first answer does not \ +settle it. Seconds, not milliseconds. Providers patchbay can interrogate today: cloudflare and \ +github. Anything else comes back 'unsupported'. Call it when the user asks whether a key is still good, before relying on a key for something \ expensive or destructive, or when an operation failed with something that smells like a bad \ @@ -862,6 +863,10 @@ expiry to the user when it is close. - 'invalid' — the issuer rejected it: revoked, deleted, disabled, or never real. Say so \ plainly; the user needs to rotate it, and then re-register with store_key + overwrite. - 'expired' — the issuer knows it, its lifetime is over. Same action: rotate and re-store. +- 'inconclusive' — the provider answered, and its answer fits a live key as well as a dead one. \ +A Cloudflare token scoped to one product is rejected by the same response a revoked token gets, \ +so patchbay declines to guess. Do NOT report this as a dead credential and do NOT advise \ +rotating; say patchbay could not tell, and that the token's own dashboard page can. - 'unsupported' — patchbay has no verification path for this provider. A normal answer, not a \ failure. Do not retry; point the user at the provider's dashboard. - 'unreachable' — the provider could not be reached (DNS, timeout, rate limit, 5xx). This says \ diff --git a/docs/key-vault.md b/docs/key-vault.md index 2bddbc9..7b72329 100644 --- a/docs/key-vault.md +++ b/docs/key-vault.md @@ -27,6 +27,8 @@ pb key edit cf-gh-actions-deploy --env CLOUDFLARE_API_TOKEN # metadata only pb key copy cf-gh-actions-deploy # to the clipboard, never to your terminal pb key run cf-gh-actions-deploy -- wrangler deploy # into a child process pb key verify cf-gh-actions-deploy # ask Cloudflare whether it still works +pb key verify cf-api gh-pat neon-api-key # several issuers, at once +pb key verify --all # sweep the whole vault pb key rm cf-gh-actions-deploy # metadata and Keychain item, both ``` @@ -166,10 +168,23 @@ back into the registry, so the vault converges on the truth instead of drifting from it. Every other provider answers `unsupported`, which is a normal answer and not a failure. +Ask about several keys, or about all of them. A sweep is one line per key and a +tally, and it runs the checks eight at a time rather than sixty round trips end +to end: + +```console +$ pb key verify --all +cf-r2-token-sonarqube-backups valid confirmed by listing accounts with it — Cloudflar… +gh-pat-release valid GitHub accepts it as YJack0000 +neon-api-key unsupported patchbay cannot verify `neon` keys yet — it knows… +openai-personal unsupported patchbay cannot verify `openai` keys yet — it know… +checked 4 keys: 2 unsupported, 2 valid +``` + | `--provider` | What patchbay asks | What comes back | |---|---|---| -| `cloudflare` (`cf`) | `GET /client/v4/user/tokens/verify` | The token's own status — `active`, `expired` or `disabled` — plus `expires_on` when the token has one, and Cloudflare's own message. The endpoint reports liveness, not policies, so scopes stay empty: an account API token's *reach* is not something this call will tell you, which is exactly why it is worth registering next to `wrangler`. | -| `github` (`gh`) | `GET /user` | The login it authenticates as, the classic-PAT scope list from `X-OAuth-Scopes`, and the expiry from `github-authentication-token-expiration`. A fine-grained PAT sends an empty scope header — that is a real answer, not a missing one; its permissions are per-repository and not enumerable here. | +| `cloudflare` (`cf`) | `GET /client/v4/user/tokens/verify`, then `GET /client/v4/accounts?per_page=1` if that rejects it | The token's own status — `active`, `expired` or `disabled` — plus `expires_on` when the token has one, and Cloudflare's own message. The endpoint reports liveness, not policies, so scopes stay empty: an account API token's *reach* is not something this call will tell you, which is exactly why it is worth registering next to `wrangler`. | +| `github` (`gh`) | `GET /user`, unless the value is a PEM | The login it authenticates as, the classic-PAT scope list from `X-OAuth-Scopes`, and the expiry from `github-authentication-token-expiration`. A fine-grained PAT sends an empty scope header — that is a real answer, not a missing one; its permissions are per-repository and not enumerable here. A GitHub **App private key** is a PEM rather than a bearer token, so it answers `unsupported` without a request: verifying an App means minting a JWT with the key, which needs an app id the registry does not hold. `/user` would say "Bad credentials" to a perfectly good App key, which is why patchbay does not ask. | | `grafana` | `GET {endpoint}/api/org` | The org the token belongs to. **Needs `--endpoint`** — a Grafana token is only meaningful against the instance that issued it, and there is no one address to ask. Service-account tokens carry a role rather than a scope list, so scopes stay empty. | ```sh @@ -183,11 +198,28 @@ URL and Grafana Cloud answers `/api/org` with its single-page app — HTML, HTTP 200 — which patchbay reports as `unreachable` rather than reading a dead token as live. +Cloudflare's token-verify endpoint only speaks for tokens owned by a *user*. +Hand it an account-owned or scoped token — an R2 token, a Workers token, most +of what the dashboard issues now — and it answers `success: false`, error 1000, +"Invalid API Token", while the token is deploying in production. That is why +patchbay asks a second question when the first one says no: can the token read +one page of one account? A `200` there is proof of life, and the verdict says +where it came from. If that is rejected too, the answer is `inconclusive` — +because a token scoped to R2 alone cannot list accounts either, and the two +cases are the same HTTP response. patchbay will not turn that into advice to +rotate a key. + The verdicts are deliberately more than a boolean. `unreachable` (DNS, timeout, -rate limit, 5xx) means patchbay could not ask; it says **nothing** about the -key, and it never overwrites what you already had. Exit codes follow: `0` -verified or unsupported, `1` the provider says the key is dead, `2` the provider -could not be reached. +rate limit, 5xx), `inconclusive` and `unsupported` all mean patchbay could not +get an answer; none of them says **anything** about the key, and none of them +overwrites what you already had. + +Exit codes keep the same three-way split, over however many keys were asked +about: `1` a provider says one of them is dead, `2` nothing is dead but a +provider could not be reached, `0` everything else — `inconclusive` and +`unsupported` included, because neither is a fact about a key. `1` outranks +`2`: a sweep that found a revoked token and then lost the wifi still found a +revoked token. Agents get the same check over MCP as `verify_key`, and it is **not** gated behind `PATCHBAY_ALLOW_SECRET_READ` — a verdict carries nothing to leak. From fae0b6ae6f78c4a189eaa33cdee95e8f5da755a0 Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 10 Sep 2026 16:08:40 +0800 Subject: [PATCH 4/6] [docs] changelog for 0.8.0 --- CHANGELOG.md | 93 ++++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 93 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index ad1e857..15fdbed 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,99 @@ All notable changes to this project are documented here. The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +## [0.8.0] - 2026-09-10 + +### Fixed + +- **An import could finish with every credential file in place and not one + secret in it, and exit 0.** A real move of 64 keys onto a second Mac wrote all + 53 credential files, then had every one of the 64 keychain writes refused + (macOS will not let a session with no desktop login write to a keychain), and + reported success. The reason was recorded per key and then thrown away by the + printer, so what you saw was a column of bare `skip` and no fact that + explained it. + + Three changes, because the failure had three halves. `pb import` now probes + whether the keystore will take a write **before** it copies a file, and + refuses the whole import if it will not — 53 files followed by 64 dropped + secrets was the worst reachable outcome and is no longer reachable. A key that + is refused prints its reason like a file always did, plus one summary line + saying the bundle still holds the values, so nothing is lost. And an import + that dropped secrets exits 1: `pb import && ./something` was the one caller + that could not tell. + +- **The install instructions could not reach the machine that needed them.** A + bundle's `SETUP.md` explains how to install patchbay on a machine that does + not have it, and lived inside the encrypted payload — so reading the + instructions for installing `pb` required a `pb`. On the move above the + receiving Mac had neither and the command was looked up on the releases page + by hand. `pb export` now also writes a cleartext `-SETUP.md` beside + the bundle: the install step and the `pb import` line, and nothing else. It + takes no manifest at all, which is the structural reason no inventory can + leak into it. + +- **A Cloudflare token that works was reported `invalid`.** + `/user/tokens/verify` only answers for user-owned tokens; an account-owned or + scoped token gets the same "Invalid API Token" rejection whether it is live or + revoked. patchbay believed the rejection and told you to replace working + keys — the exact mistake the verdict enum's own doc comment warns about. A + rejection now falls back to listing accounts with the token: if that works the + token demonstrably does, and if it does not, the answer is the new + `inconclusive` verdict rather than a death sentence, because a revoked token + and a narrowly scoped one are the same HTTP response. Nine keys in a real + vault moved from red to honest. + +- **A GitHub App private key was reported `invalid` for the same reason.** A PEM + is not a token `GET /user` can ever authenticate, so the request could only + ever say "Bad credentials". A PEM-shaped value is now `unsupported` before any + request is made, with the note that an App key is verified by minting a JWT + and patchbay has no app id to mint one with. + +- **The panel's refresh button did nothing on two of its three pages.** It only + ever re-fetched the tool board, so on the key vault and the MCP matrix it + spun, moved the timestamp, and left the table that was loaded on mount. A + header that already names the view you are in has to refresh that view. + Alongside it: a failed re-read of the MCP matrix now leaves the matrix on + screen with the error above it, instead of replacing a working board with a + banner. + +- **`pb plan` lost the one fix an import knew.** Several kubeconfigs land in one + directory and the import tells you the `KUBECONFIG` line to set; by the time + you ran `pb plan` the item said only "nothing is logged in". The item now + carries the real `export KUBECONFIG=…` line, re-derived from the files + themselves so nothing has to be persisted to stay true. + +- **`pb key verify` offered Grafana's `--endpoint` advice to every unsupported + provider**, including the 52 in a real vault that have no endpoint to set. + +### Added + +- **`--passphrase-file ` and `--passphrase-fd ` on `pb export` and + `pb import`.** The old rule refused a passphrase from anything but a + terminal. Its reason — argv is visible to `ps` and lands in shell history — is + right and is kept, but a TTY test is the wrong instrument for it: it stops no + attacker and it stopped every legitimate automation, including a real move + that worked around it with a pty wrapper whose echo wrote the passphrase into + a log. That is the leak the rule exists to prevent, caused by the rule. So + patchbay now lets automation name its source the way gpg, restic, borg and age + do. A passphrase file that the group or the world can read is refused, because + with that flag the file *is* the secret. Still no passphrase as an argument. + +- **`pb import --keys-only`** — restore just the vault half, for + finishing a move whose keychain writes were refused. + +- **`pb key verify ...` and `pb key verify --all`.** It took exactly one id, + which in a vault of 64 keys means nobody checks. A sweep runs the network + calls bounded-concurrent and prints a tally. Exit codes are now three: 1 if a + provider says a key is dead, 2 if a provider could not be reached, 0 + otherwise — and `inconclusive` and `unsupported` are in that 0, because + neither is a fact about the key. + +- **Frontend tests.** The panel had none, and the refresh bug above is exactly + the kind nothing else could catch. vitest + Testing Library, mocked at + `invoke` — the one thing genuinely absent from a test process — with the four + regressions that fail if the fix is reverted. + ## [0.7.0] - 2026-09-08 ### Added From 757075296006e68c5a315d70fa4476832dde890e Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 10 Sep 2026 16:17:22 +0800 Subject: [PATCH 5/6] [refactor] split the three functions this PR pushed past Sonar's complexity ceiling MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Sonar failed the gate on five new violations. Two were the panel props not being marked read-only. The other three were functions this PR grew past a cognitive complexity of 15, each along a seam that was already visible: `verify` served two jobs that print differently — one named key and an --all sweep — with resolution, the bounded-concurrent run, the write-back and the exit code tangled between them; those are five helpers now and it is a dispatcher (21 -> 5). `print_import` was a run of independent sections, so it is a run of section printers (17 -> 0). The refused-key count is returned by the only section that can count it rather than declared at the top and threaded through. `tool_items` asked four separate questions about one tool — is it installed, is it logged in, what did the old machine have, is it logged in as somebody else — and each is a builder now (23 -> 2), following the precedent the kubeconfig helpers already set. No behaviour changed anywhere: same output bytes, same item ids and order, same exit codes. Nothing was suppressed with #[allow] and sonar-project.properties is untouched. --- app/src/components/KeysView.tsx | 2 +- app/src/components/McpView.tsx | 2 +- crates/patchbay-cli/src/keys.rs | 164 ++++++++++++------- crates/patchbay-cli/src/migrate.rs | 73 +++++++-- crates/patchbay-core/src/migrate/plan.rs | 192 +++++++++++++++-------- 5 files changed, 288 insertions(+), 145 deletions(-) diff --git a/app/src/components/KeysView.tsx b/app/src/components/KeysView.tsx index 20999fb..e078124 100644 --- a/app/src/components/KeysView.tsx +++ b/app/src/components/KeysView.tsx @@ -20,7 +20,7 @@ import { KEY_EXPIRY_LABEL, KEY_EXPIRY_LEVEL, type KeyRow } from "../types"; * so the rule was protecting nothing here and cost the panel the one action a * key vault is for. */ -export function KeysView({ reload }: { reload: number }) { +export function KeysView({ reload }: Readonly<{ reload: number }>) { const [rows, setRows] = useState(null); const [error, setError] = useState(null); const [adding, setAdding] = useState(false); diff --git a/app/src/components/McpView.tsx b/app/src/components/McpView.tsx index ee3b283..0995a4a 100644 --- a/app/src/components/McpView.tsx +++ b/app/src/components/McpView.tsx @@ -34,7 +34,7 @@ type Open = { mode: "add" } | { mode: "edit"; name: string }; * edit that client's copy, copy it to the clients that are missing it, or take * it out. `pb mcp add/copy/rm` still does the same work from a terminal. */ -export function McpView({ reload }: { reload: number }) { +export function McpView({ reload }: Readonly<{ reload: number }>) { const [clients, setClients] = useState(null); const [error, setError] = useState(null); const [open, setOpen] = useState(null); diff --git a/crates/patchbay-cli/src/keys.rs b/crates/patchbay-cli/src/keys.rs index 065c3c4..944b96e 100644 --- a/crates/patchbay-cli/src/keys.rs +++ b/crates/patchbay-cli/src/keys.rs @@ -405,17 +405,7 @@ fn verify(registry: &KeyRegistry, args: VerifyArgs, styles: &Styles) -> Result = if all { - registry.list()? - } else { - ids.iter() - .map(|id| { - registry - .get(id)? - .ok_or_else(|| anyhow::anyhow!("no key registered as `{id}`")) - }) - .collect::>()? - }; + let entries = verify_targets(registry, &ids, all)?; if entries.is_empty() { // Only reachable through `--all`. An id nobody registered is an error; // an empty vault is not. @@ -423,67 +413,127 @@ fn verify(registry: &KeyRegistry, args: VerifyArgs, styles: &Styles) -> Result 1; + if json { + print_verify_json(&entries, &outcomes, &updated, sweep)?; + } else if sweep { + print_verify_sweep(&entries, &outcomes, &updated, styles); + } else { + print_verify(&entries[0], &outcomes[0], &updated[0], styles); + } + Ok(verify_exit_code(&outcomes)) +} + +/// The keys a run is about: everything registered under `--all`, otherwise the +/// ones named on the command line. +/// +/// A named id nobody registered stops the run here rather than being filed as a +/// verdict, because it is a question patchbay cannot answer, not an answer. +fn verify_targets(registry: &KeyRegistry, ids: &[String], all: bool) -> Result> { + if all { + return registry.list(); + } + ids.iter() + .map(|id| { + registry + .get(id)? + .ok_or_else(|| anyhow::anyhow!("no key registered as `{id}`")) + }) + .collect() +} + +/// Ask every issuer, a bounded number of them at a time. +/// +/// Each secret is read inside the closure and dropped there, so even a sweep of +/// the whole vault never holds more than VERIFY_THREADS of them at once, and +/// none of them outlives its own request. +/// +/// A value patchbay registered but cannot read back is a broken vault, not a +/// verdict about a key, so it stops the sweep instead of being filed as one. +fn verify_outcomes(registry: &KeyRegistry, entries: &[KeyEntry]) -> Result> { + run_bounded(entries, VERIFY_THREADS, |entry| { let secret = registry.get_secret(&entry.id)?; let outcome = verify_key(entry, &secret); drop(secret); Ok(outcome) - }); - // A value patchbay registered but cannot read back is a broken vault, not a - // verdict about a key, so it stops the sweep instead of being filed as one. - let outcomes = outcomes.into_iter().collect::>>()?; + }) + .into_iter() + .collect() +} - // Write-backs stay on this thread: they rewrite one metadata file, and - // eight threads doing that would be eight chances to lose an entry. +/// What [`absorb`] wrote back for each key, in step with `entries`. `--no-update` +/// is a report-only run, so nothing is written and every key reports nothing. +/// +/// Write-backs stay on this thread: they rewrite one metadata file, and eight +/// threads doing that would be eight chances to lose an entry. +fn absorb_all( + registry: &KeyRegistry, + entries: &[KeyEntry], + outcomes: &[KeyVerifyOutcome], + no_update: bool, +) -> Result>> { let mut updated: Vec> = Vec::new(); - for (entry, outcome) in entries.iter().zip(&outcomes) { + for (entry, outcome) in entries.iter().zip(outcomes) { updated.push(if no_update { Vec::new() } else { absorb(registry, entry, outcome)? }); } + Ok(updated) +} - // One key asked about by name keeps the answer it has always had: a full - // block, and a bare object under `--json` that existing readers can still - // index into. `--all` is a sweep and always answers as a list. - let sweep = all || entries.len() > 1; - if json { - let mut values = Vec::new(); - for ((entry, outcome), updated) in entries.iter().zip(&outcomes).zip(&updated) { - values.push(verify_json(entry, outcome, updated)?); - } - let value = if sweep { - serde_json::Value::Array(values) - } else { - values.remove(0) - }; - println!("{}", serde_json::to_string_pretty(&value)?); - } else if sweep { - let id_w = entries - .iter() - .map(|e| e.id.chars().count()) - .max() - .unwrap_or_default(); - for ((entry, outcome), updated) in entries.iter().zip(&outcomes).zip(&updated) { - println!("{}", verify_line(entry, outcome, id_w, styles)); - // The one thing a sweep must not do quietly. Rare, because it only - // fires when the issuer knows something the registry did not. - if !updated.is_empty() { - println!( - " updated the registry from the provider: {}", - updated.join(", ") - ); - } - } - println!("{}", verify_summary(&outcomes)); +/// The verdicts as `--json`: a list for a sweep, the bare object for the one key +/// that was asked about by name. +fn print_verify_json( + entries: &[KeyEntry], + outcomes: &[KeyVerifyOutcome], + updated: &[Vec], + sweep: bool, +) -> Result<()> { + let mut values = Vec::new(); + for ((entry, outcome), updated) in entries.iter().zip(outcomes).zip(updated) { + values.push(verify_json(entry, outcome, updated)?); + } + let value = if sweep { + serde_json::Value::Array(values) } else { - print_verify(&entries[0], &outcomes[0], &updated[0], styles); + values.remove(0) + }; + println!("{}", serde_json::to_string_pretty(&value)?); + Ok(()) +} + +/// A sweep: one line per key, then the tally. +fn print_verify_sweep( + entries: &[KeyEntry], + outcomes: &[KeyVerifyOutcome], + updated: &[Vec], + styles: &Styles, +) { + let id_w = entries + .iter() + .map(|e| e.id.chars().count()) + .max() + .unwrap_or_default(); + for ((entry, outcome), updated) in entries.iter().zip(outcomes).zip(updated) { + println!("{}", verify_line(entry, outcome, id_w, styles)); + // The one thing a sweep must not do quietly. Rare, because it only + // fires when the issuer knows something the registry did not. + if !updated.is_empty() { + println!( + " updated the registry from the provider: {}", + updated.join(", ") + ); + } } - Ok(verify_exit_code(&outcomes)) + println!("{}", verify_summary(outcomes)); } /// Three answers, because a script gating on this needs three. diff --git a/crates/patchbay-cli/src/migrate.rs b/crates/patchbay-cli/src/migrate.rs index 307f1c0..c26bc55 100644 --- a/crates/patchbay-cli/src/migrate.rs +++ b/crates/patchbay-cli/src/migrate.rs @@ -562,13 +562,34 @@ fn file_name(path: &std::path::Path) -> String { .unwrap_or_else(|| path.display().to_string()) } +/// An import report is a run of independent sections, so this is a run of +/// independent printers. Nothing passes between them except the number of keys +/// the keychain refused, which the section that prints the keys is the only one +/// in a position to count and so is the one that returns it — the alternative, +/// a counter declared up here and mutated further down, is a variable whose +/// value depends on how far the reader has got. fn print_import(report: &import::ImportReport, styles: &Styles) { + print_dry_run_banner(report, styles); + print_file_results(report); + let keys_refused = print_key_results(report); + print_mcp_results(report); + print_env_project_results(report); + println!(); + print_refused_key_warning(keys_refused, report.keys.len(), styles); + print_notes(report, styles); + print_remaining_plan(report, styles); +} + +fn print_dry_run_banner(report: &import::ImportReport, styles: &Styles) { if report.dry_run { println!( "{}\n", styles.paint(warn_style(), "DRY RUN — nothing was written") ); } +} + +fn print_file_results(report: &import::ImportReport) { for file in &report.files { println!(" {:<10} {}", file.outcome.label(), file.path.display()); if let import::FileOutcome::Replaced { backup: Some(at) } = &file.outcome { @@ -578,10 +599,15 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { println!(" {reason}"); } } - // A key carries its reason the same way a file does. `restore_keys` puts - // the keystore's error in there and this loop used to drop it, which turns - // "every secret in the bundle was refused" into a column of bare `skip` - // and leaves the one fact that explains it unprinted. +} + +/// Returns how many key values the keystore refused, for the warning below. +/// +/// A key carries its reason the same way a file does. `restore_keys` puts +/// the keystore's error in there and this loop used to drop it, which turns +/// "every secret in the bundle was refused" into a column of bare `skip` +/// and leaves the one fact that explains it unprinted. +fn print_key_results(report: &import::ImportReport) -> usize { let mut keys_refused = 0usize; for key in &report.keys { println!(" {:<10} key {}", key.outcome.label(), key.id); @@ -590,6 +616,10 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { keys_refused += 1; } } + keys_refused +} + +fn print_mcp_results(report: &import::ImportReport) { for server in &report.mcp { println!( " {:<10} mcp {}/{}", @@ -598,6 +628,9 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { server.name ); } +} + +fn print_env_project_results(report: &import::ImportReport) { for project in &report.env_projects { println!( " {:<10} env project {}", @@ -608,33 +641,39 @@ fn print_import(report: &import::ImportReport, styles: &Styles) { println!(" {reason}"); } } - println!(); - // The file half of an import can succeed while every secret in it is - // refused, and per-key lines scroll away. A keychain that will not take a - // write is nearly always a session without a desktop login — over ssh, in - // a cron job — which is a property of how the command was started and not - // of the bundle, so it says how to start it differently. +} + +/// The file half of an import can succeed while every secret in it is +/// refused, and per-key lines scroll away. A keychain that will not take a +/// write is nearly always a session without a desktop login — over ssh, in +/// a cron job — which is a property of how the command was started and not +/// of the bundle, so it says how to start it differently. +fn print_refused_key_warning(keys_refused: usize, keys_total: usize, styles: &Styles) { if keys_refused > 0 { println!( "{}", styles.paint( warn_style(), &format!( - "! {keys_refused} of {} key value(s) never reached the keychain — the bundle \ - still holds them, so nothing is lost, but the vault on this machine is \ - incomplete. On macOS a keychain refuses every write from a session with no \ - desktop login (ssh, cron): re-run this import from a Terminal in your own \ - desktop session, and the file half above is idempotent — it will report \ - `unchanged` rather than write anything twice.", - report.keys.len() + "! {keys_refused} of {keys_total} key value(s) never reached the keychain — \ + the bundle still holds them, so nothing is lost, but the vault on this \ + machine is incomplete. On macOS a keychain refuses every write from a \ + session with no desktop login (ssh, cron): re-run this import from a \ + Terminal in your own desktop session, and the file half above is idempotent \ + — it will report `unchanged` rather than write anything twice." ) ) ); } +} + +fn print_notes(report: &import::ImportReport, styles: &Styles) { for note in &report.notes { println!("{}", styles.paint(warn_style(), &format!("! {note}"))); } +} +fn print_remaining_plan(report: &import::ImportReport, styles: &Styles) { let open: Vec<&SetupItem> = report.open_items().collect(); if open.is_empty() { println!("\nnothing left to do."); diff --git a/crates/patchbay-core/src/migrate/plan.rs b/crates/patchbay-core/src/migrate/plan.rs index 5609611..b9d223b 100644 --- a/crates/patchbay-core/src/migrate/plan.rs +++ b/crates/patchbay-core/src/migrate/plan.rs @@ -20,7 +20,7 @@ //! does with no arguments. use super::manifest::{Manifest, SetupItem, SetupStatus, ToolRecord}; -use super::policy::{policy_for, Portability}; +use super::policy::{policy_for, Portability, ToolPolicy}; use crate::envs::EnvRegistry; use crate::keys::KeyRegistry; use crate::mcp_clients::McpClientRegistry; @@ -84,6 +84,10 @@ fn source_had_something(expected: Option<&ToolRecord>, status: &ToolStatus) -> b } } +/// The questions this machine still owes an answer to for one tool, in the +/// order a person would work them: get the CLI, log in, then be the right +/// person. Each one is a separate builder because each has its own reason to +/// exist, and the reasons are what the items carry. fn tool_items(paths: &Paths, status: &ToolStatus, expected: Option<&ToolRecord>) -> Vec { let Some(policy) = policy_for(&status.tool) else { return Vec::new(); @@ -92,42 +96,89 @@ fn tool_items(paths: &Paths, status: &ToolStatus, expected: Option<&ToolRecord>) return Vec::new(); } - let mut items = Vec::new(); let state = status.connection_state(); + let mut items = Vec::new(); + items.extend(install_item(policy, status, state)); + items.push(login_item(paths, policy, status, expected, state)); + items.extend(switch_item(status, expected, state)); + items +} - // 1. Is the CLI even here? Nothing else about this tool can be checked - // until it is, so this item comes first and the login item goes - // `Unknown` behind it rather than claiming a verdict it cannot have. - if state == ConnectionState::NotInstalled { - items.push( - SetupItem::new( - format!("install:{}", status.tool), - &status.tool, - format!("`{}` is not installed on this machine", status.tool), - ) - .command(policy.install, false), - ); +/// 1. Is the CLI even here? Nothing else about this tool can be checked until +/// it is, so this item comes first and the login item goes `Unknown` behind +/// it rather than claiming a verdict it cannot have. +fn install_item( + policy: &ToolPolicy, + status: &ToolStatus, + state: ConnectionState, +) -> Option { + if state != ConnectionState::NotInstalled { + return None; } + Some( + SetupItem::new( + format!("install:{}", status.tool), + &status.tool, + format!("`{}` is not installed on this machine", status.tool), + ) + .command(policy.install, false), + ) +} - // 2. Is it logged in? - // - // Two deliberate departures from the status board here, both because a - // checklist is a different thing from a warning light: - // - // * `Attention` is Open only when the credential has ACTUALLY expired. The - // board is right to flag a gcloud access token with 40 minutes left — - // but gcloud refreshes that itself, and a setup list that can never - // reach zero is a setup list people stop working. - // * a `concurrent` tool with profiles is Done. docker, rclone, ssh and npm - // have no active profile by design, so `Disconnected` there means - // "healthy", not "logged out". +/// 2. Is it logged in? +/// +/// Always an item, even when the answer is yes: a checklist people can watch +/// shrink is the point, so a closed question stays on the list as `Done`. +fn login_item( + paths: &Paths, + policy: &ToolPolicy, + status: &ToolStatus, + expected: Option<&ToolRecord>, + state: ConnectionState, +) -> SetupItem { + let (login_status, what) = login_verdict(policy, status, state); + let mut login = SetupItem::new(format!("tool:{}", status.tool), &status.tool, what) + .command(policy.fix, policy.needs_browser) + .status(login_status); + // Why the copy could not have brought this login with it: a keychain-held + // or device-identifying credential has to be re-made here, and the user + // deserves that reason next to the command. + if !matches!(policy.portability, Portability::Portable { .. }) { + login = login.detail(policy.portability.reason()); + } + if let Some(record) = expected { + login = source_history(login, record); + } + if status.tool == "kubectl" && login_status == SetupStatus::Open { + login = kubeconfig_fix(paths, login); + } + login +} + +/// The login verdict and the sentence that explains it. +/// +/// Two deliberate departures from the status board here, both because a +/// checklist is a different thing from a warning light: +/// +/// * `Attention` is Open only when the credential has ACTUALLY expired. The +/// board is right to flag a gcloud access token with 40 minutes left — +/// but gcloud refreshes that itself, and a setup list that can never +/// reach zero is a setup list people stop working. +/// * a `concurrent` tool with profiles is Done. docker, rclone, ssh and npm +/// have no active profile by design, so `Disconnected` there means +/// "healthy", not "logged out". +fn login_verdict( + policy: &ToolPolicy, + status: &ToolStatus, + state: ConnectionState, +) -> (SetupStatus, String) { let now = chrono::Utc::now(); let expired = status .active_expiry() .or_else(|| status.soonest_expiry()) .is_some_and(|at| at <= now); - let (login_status, what) = match state { + match state { ConnectionState::Connected => { (SetupStatus::Done, format!("`{}` is logged in", status.tool)) } @@ -161,53 +212,56 @@ fn tool_items(paths: &Paths, status: &ToolStatus, expected: Option<&ToolRecord>) status.tool ), ), - }; - let mut login = SetupItem::new(format!("tool:{}", status.tool), &status.tool, what) - .command(policy.fix, policy.needs_browser) - .status(login_status); - if !matches!(policy.portability, Portability::Portable { .. }) { - login = login.detail(policy.portability.reason()); } - if let Some(record) = expected { - if let Some(active) = &record.active { - login = login.detail(format!("the old machine was `{active}` here")); - } - if !record.scopes.is_empty() { - login = login.detail(format!( - "it had these scopes, which the new login has to match: {}", - record.scopes.join(", ") - )); - } +} + +/// What the old machine had here, so the new login can be made to match rather +/// than guessed at. +fn source_history(mut login: SetupItem, record: &ToolRecord) -> SetupItem { + if let Some(active) = &record.active { + login = login.detail(format!("the old machine was `{active}` here")); } - if status.tool == "kubectl" && login_status == SetupStatus::Open { - login = kubeconfig_fix(paths, login); + if !record.scopes.is_empty() { + login = login.detail(format!( + "it had these scopes, which the new login has to match: {}", + record.scopes.join(", ") + )); } - items.push(login); + login +} - // 3. Logged in, but as somebody else. patchbay can fix this one itself. - if state != ConnectionState::NotInstalled { - if let Some(record) = expected { - if let (Some(want), here) = (record.active.as_ref(), status.active.as_ref()) { - let known = status.profiles.iter().any(|p| &p.id == want); - if here != Some(want) && known { - items.push( - SetupItem::new( - format!("switch:{}", status.tool), - &status.tool, - format!( - "`{}` is on `{}`; the old machine was on `{want}`", - status.tool, - here.map(String::as_str).unwrap_or("nothing") - ), - ) - .command(format!("pb use {} {want}", status.tool), false) - .auto(true), - ); - } - } - } +/// 3. Logged in, but as somebody else. patchbay can fix this one itself, so +/// this is the rare `auto` item. +/// +/// Only when the wanted profile is already known here — switching to a profile +/// this machine has never seen is a login, not a switch. +fn switch_item( + status: &ToolStatus, + expected: Option<&ToolRecord>, + state: ConnectionState, +) -> Option { + if state == ConnectionState::NotInstalled { + return None; } - items + let want = expected?.active.as_ref()?; + let here = status.active.as_ref(); + let known = status.profiles.iter().any(|p| &p.id == want); + if here == Some(want) || !known { + return None; + } + Some( + SetupItem::new( + format!("switch:{}", status.tool), + &status.tool, + format!( + "`{}` is on `{}`; the old machine was on `{want}`", + status.tool, + here.map(String::as_str).unwrap_or("nothing") + ), + ) + .command(format!("pb use {} {want}", status.tool), false) + .auto(true), + ) } /// Replace kubectl's login command with the `KUBECONFIG` line the files on this From 98949f63510058dba4adbc188bcf8af2f9fdfd6d Mon Sep 17 00:00:00 2001 From: YJack0000 Date: Thu, 10 Sep 2026 16:24:37 +0800 Subject: [PATCH 6/6] [fix] the github rate-limit test asserted more than the code promises MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It went red on the Linux runner, and it turns out to fail 15 times in 40 runs locally. Nothing in this PR touches it: the assertion was always racy. `lookup_latest` reads the rate-limit gate when a lookup starts, and the run dispatches up to four at once, so three GitHub tools can all pass the gate before the first 403 arms it. "Total calls < 3" is therefore not a property the code has — the number depends on what was already in flight. The old test just usually got lucky. So it now asserts what is actually guaranteed and is deterministic: no tool asks twice, every entry explains the rate limit, and — in a new test at the `lookup_latest` level — a lookup that starts after the limit was seen makes no request at all and says it was skipped. That last one is the half that matters anyway, since it is what stops the next run from spending a budget that is already gone. Renamed the old test, whose name claimed the guarantee that moved to the new one. Confirmed 60/60 green after the change. --- crates/patchbay-core/src/versions.rs | 65 ++++++++++++++++++++++++++-- 1 file changed, 62 insertions(+), 3 deletions(-) diff --git a/crates/patchbay-core/src/versions.rs b/crates/patchbay-core/src/versions.rs index 47e8615..d344d89 100644 --- a/crates/patchbay-core/src/versions.rs +++ b/crates/patchbay-core/src/versions.rs @@ -2662,7 +2662,7 @@ mod tests { } #[test] - fn test_github_rate_limiting_stops_after_the_first_hit_instead_of_spamming() { + fn test_a_spent_github_rate_limit_is_reported_on_every_tool_and_never_retried() { let dir = tempfile::tempdir().unwrap(); // Three GitHub-sourced tools, all installed somewhere unclassifiable so // they fall through to the GitHub route. @@ -2697,9 +2697,17 @@ mod tests { &deps(&runner, &brew, &http, &bins), ); + // Three GitHub tools, so three requests is the ceiling: what this + // asserts is that nothing retries a spent limit. It cannot assert + // fewer. The gate is read when a lookup *starts* and the run dispatches + // up to four at once, so how many requests a spent limit costs depends + // on how many were already in flight — which made the old `< 3` here + // fail 15 times in 40 local runs and go red on CI once the timing + // shifted. The guarantee the code does make has its own test below. assert!( - http.call_count() < 3, - "a spent rate limit must stop the run's other GitHub lookups, not be hit three times" + http.call_count() <= 3, + "no tool may ask GitHub twice; got {} calls for three tools", + http.call_count() ); for entry in &report.entries { assert_eq!(entry.latest, None); @@ -2712,6 +2720,57 @@ mod tests { } } + /// The promise in `lookup_latest`'s GitHub arm — one spent limit stops the + /// rest — is about lookups that have not started yet, and that is the half + /// worth pinning down: it is deterministic, and it is what keeps a second + /// `pb check-updates` from spending a budget that is already gone. + #[test] + fn test_a_github_lookup_that_starts_after_the_rate_limit_was_seen_never_asks() { + let http = StubHttp::responding( + HttpResponse::new(403, r#"{"message":"API rate limit exceeded"}"#) + .with_header("x-ratelimit-remaining", "0"), + ); + let calls = AtomicUsize::new(0); + let rate_limited = Mutex::new(false); + let spec = spec_for("doctl").expect("doctl is a GitHub-sourced tool"); + let started = Instant::now(); + + let first = lookup_latest( + spec, + None, + Source::Github, + &http, + &calls, + &rate_limited, + started, + ); + assert!(first.is_err(), "a 403 with no remaining quota is an error"); + assert!( + *rate_limited.lock().unwrap(), + "the first rate-limited answer has to arm the gate" + ); + assert_eq!(http.call_count(), 1); + + let second = lookup_latest( + spec, + None, + Source::Github, + &http, + &calls, + &rate_limited, + started, + ); + assert!( + second.unwrap_err().contains("skipped"), + "the second lookup must say it was skipped, not invent a failure" + ); + assert_eq!( + http.call_count(), + 1, + "a lookup starting after the limit was seen must not reach the network" + ); + } + #[test] fn test_run_bounded_preserves_order_and_runs_everything() { let items: Vec = (0..50).collect();