diff --git a/package-lock.json b/package-lock.json index d98c1e29f..f8553f15c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1531,6 +1531,240 @@ } } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "freebsd" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "cpu": [ + "ia32" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + } + }, "node_modules/@napi-rs/wasm-runtime": { "version": "1.1.5", "resolved": "https://registry.npmjs.org/@napi-rs/wasm-runtime/-/wasm-runtime-1.1.5.tgz", @@ -5572,6 +5806,7 @@ "@earendil-works/diffex-harness-state": "^0.2.0", "@earendil-works/diffex-protocol": "^0.2.0", "@earendil-works/diffex-tui": "^0.2.0", + "@napi-rs/keyring": "1.3.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/coding-agent/install-lock/package-lock.json b/packages/coding-agent/install-lock/package-lock.json index 5044a2800..d0ea613e0 100644 --- a/packages/coding-agent/install-lock/package-lock.json +++ b/packages/coding-agent/install-lock/package-lock.json @@ -512,6 +512,7 @@ "@earendil-works/diffex-harness-state": "^0.2.0", "@earendil-works/diffex-protocol": "^0.2.0", "@earendil-works/diffex-tui": "^0.2.0", + "@napi-rs/keyring": "1.3.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -805,6 +806,240 @@ } } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "win32" + ], + "cpu": [ + "ia32" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "optional": true + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", diff --git a/packages/coding-agent/npm-shrinkwrap.json b/packages/coding-agent/npm-shrinkwrap.json index 67565d914..c4c5f1700 100644 --- a/packages/coding-agent/npm-shrinkwrap.json +++ b/packages/coding-agent/npm-shrinkwrap.json @@ -15,6 +15,7 @@ "@earendil-works/diffex-harness-state": "^0.2.0", "@earendil-works/diffex-protocol": "^0.2.0", "@earendil-works/diffex-tui": "^0.2.0", + "@napi-rs/keyring": "1.3.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", @@ -795,6 +796,240 @@ } } }, + "node_modules/@napi-rs/keyring": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring/-/keyring-1.3.0.tgz", + "integrity": "sha512-WrOw/bcXm0f9qHkumlT1QlArXSTWqaY9sunsDpOk+yCCorCKMxvWT/a3xko4EYHVdeZoh00yI2TydXn6eyICDA==", + "license": "MIT", + "optionalDependencies": { + "@napi-rs/keyring-darwin-arm64": "1.3.0", + "@napi-rs/keyring-darwin-x64": "1.3.0", + "@napi-rs/keyring-freebsd-x64": "1.3.0", + "@napi-rs/keyring-linux-arm-gnueabihf": "1.3.0", + "@napi-rs/keyring-linux-arm64-gnu": "1.3.0", + "@napi-rs/keyring-linux-arm64-musl": "1.3.0", + "@napi-rs/keyring-linux-riscv64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-gnu": "1.3.0", + "@napi-rs/keyring-linux-x64-musl": "1.3.0", + "@napi-rs/keyring-win32-arm64-msvc": "1.3.0", + "@napi-rs/keyring-win32-ia32-msvc": "1.3.0", + "@napi-rs/keyring-win32-x64-msvc": "1.3.0" + }, + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/keyring-darwin-arm64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-arm64/-/keyring-darwin-arm64-1.3.0.tgz", + "integrity": "sha512-pl76hJvdYUBn6I24bXiOBMA9nbDapo3I5B+f3OorjDU4dUMSypXeKbOVehJe8fhgTiH24flMyTS3aAIy43xegQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "darwin" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-darwin-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-darwin-x64/-/keyring-darwin-x64-1.3.0.tgz", + "integrity": "sha512-YcJtEV5LA3cvA4z3BurgxH5IhTsW1JfIvcAAcqcecwk06Si9F9NqkxbZVIfDwQ8oRHgaBmT3zZJnLAotCrVahw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "darwin" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-freebsd-x64": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-freebsd-x64/-/keyring-freebsd-x64-1.3.0.tgz", + "integrity": "sha512-vlLf31TGhfRAaxLDBhg8b89ss0HHD/lyNmL5F3UjSaz5CUXElsJmKYq9fqA/B+cZKUEUcLHHGhF0I/CqcFdaVw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "freebsd" + ], + "cpu": [ + "x64" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-arm-gnueabihf": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm-gnueabihf/-/keyring-linux-arm-gnueabihf-1.3.0.tgz", + "integrity": "sha512-KiWdMMu/Inz/bHHIAGrnF7r54FZDYXuHO6UFF/rhIrshUsxbMG1Rl9lEymNtqqsVo927G0VYcb02FzWQ3iBQRQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-arm64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-gnu/-/keyring-linux-arm64-gnu-1.3.0.tgz", + "integrity": "sha512-eyKGpY40lm9Jvs1aD294XRH4y7+TlJM0YVAryZeXA6TX0mb4gMkxVXwSQv7MCwgah7raeUd0dKUb4BPAYIgcMg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-arm64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-arm64-musl/-/keyring-linux-arm64-musl-1.3.0.tgz", + "integrity": "sha512-iIK6JWHXAJqDrEyLY3TmswwloVyt2vj+04TZnew+uSJ9gnDO8EwRbp3/iw3LpWaXiDO7VomGO6y8I0Id8uBZSw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-riscv64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-riscv64-gnu/-/keyring-linux-riscv64-gnu-1.3.0.tgz", + "integrity": "sha512-/PGqrwn6EwgtK6vccASSXJRfOSP4vN1F4ASsIQ+7MdrK6hNvAJ1FZPrIuD5gGGdxezo3F++To2Wq7DbuGIeuNQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-x64-gnu": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-gnu/-/keyring-linux-x64-gnu-1.3.0.tgz", + "integrity": "sha512-2PDK1WKWTu9lBGq9VvNEkSlQD3O7YwVpmnyN2M3cy4v7NJ/8gDMd9GXv3G+FVXN13uhp4gnnPBS+ScefmEeD2A==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-linux-x64-musl": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-linux-x64-musl/-/keyring-linux-x64-musl-1.3.0.tgz", + "integrity": "sha512-oJ2HkX8YUo46QBkn0pG+HuIKQNqr523q6vBobCn+P95s4C4K6/kLBqHY/1bg5J4ap31DzsznhnFKcfBNBsjCnw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "linux" + ], + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-win32-arm64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-arm64-msvc/-/keyring-win32-arm64-msvc-1.3.0.tgz", + "integrity": "sha512-tOd3c/uAaeoE4ycVlmAdSvygz0Zt3zdca6Y7gokBeIbaRDWpjDIUOpU3MvML59XAaqyuKGsVVu0F/DZb1lHPmw==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "win32" + ], + "cpu": [ + "arm64" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-win32-ia32-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-ia32-msvc/-/keyring-win32-ia32-msvc-1.3.0.tgz", + "integrity": "sha512-sPSqeAFZMGqP1R++M2JTza7GQJJ/TpCo6JU6Vcd4jnebvOaEDs9b7eipakU1PJdSvhpC2yXMCNRk9gXfrhuwHQ==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "win32" + ], + "cpu": [ + "ia32" + ], + "optional": true + }, + "node_modules/@napi-rs/keyring-win32-x64-msvc": { + "version": "1.3.0", + "resolved": "https://registry.npmjs.org/@napi-rs/keyring-win32-x64-msvc/-/keyring-win32-x64-msvc-1.3.0.tgz", + "integrity": "sha512-4DnCWXwDc0HRKwyRlG5y0VhKZW2tNRQfKKfyj6IX/KWfDNyq9hn4n+GL1auyDcOO/v8PwnhmYo2+rOOqCkvvOg==", + "license": "MIT", + "engines": { + "node": ">= 10" + }, + "os": [ + "win32" + ], + "cpu": [ + "x64" + ], + "optional": true + }, "node_modules/@nodable/entities": { "version": "2.1.0", "resolved": "https://registry.npmjs.org/@nodable/entities/-/entities-2.1.0.tgz", diff --git a/packages/coding-agent/package.json b/packages/coding-agent/package.json index a7b9621f8..a0983b40a 100644 --- a/packages/coding-agent/package.json +++ b/packages/coding-agent/package.json @@ -53,6 +53,7 @@ "@earendil-works/diffex-harness-state": "^0.2.0", "@earendil-works/diffex-protocol": "^0.2.0", "@earendil-works/diffex-tui": "^0.2.0", + "@napi-rs/keyring": "1.3.0", "@silvia-odwyer/photon-node": "0.3.4", "chalk": "5.6.2", "cross-spawn": "7.0.6", diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts index 133e41502..c1dec0207 100644 --- a/packages/coding-agent/src/cli/auth-command.ts +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -38,9 +38,9 @@ export function isAuthCommandHelp(args: string[]): boolean { export function printAuthCommandHelp(): void { console.log(`Usage: - ${APP_NAME} auth print-api-key [--provider ] [--model ] - ${APP_NAME} auth print-bearer-token [--provider ] [--model ] [--min-expiry ] - ${APP_NAME} auth check [--provider ] [--model ] [--json] [--credentials] [--no-refresh] + ${APP_NAME} auth print-api-key [--provider ] [--model ] + ${APP_NAME} auth print-bearer-token [--provider ] [--model ] [--min-expiry ] + ${APP_NAME} auth check [--provider ] [--model ] [--json] [--credentials] [--no-refresh] Auth commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the credential, or includes it in JSON output.`); } diff --git a/packages/coding-agent/src/core/account-auth-storage.ts b/packages/coding-agent/src/core/account-auth-storage.ts new file mode 100644 index 000000000..57bff25bf --- /dev/null +++ b/packages/coding-agent/src/core/account-auth-storage.ts @@ -0,0 +1,138 @@ +const KEYRING_SERVICE = "ai.diffex.cli"; + +export interface DiffexAccountSession { + version: 1; + apiOrigin: string; + accessToken: string; + refreshToken: string; + tokenType: "Bearer"; + expiresAt: number; + context?: Record; +} + +export interface DiffexAccountAuthStorage { + read(apiOrigin: string, signal?: AbortSignal): Promise; + write(session: DiffexAccountSession, signal?: AbortSignal): Promise; + delete(apiOrigin: string, signal?: AbortSignal): Promise; +} + +export interface KeyringEntry { + setPassword(password: string, signal?: AbortSignal): Promise; + getPassword(signal?: AbortSignal): Promise; + deleteCredential(signal?: AbortSignal): Promise; +} + +export type KeyringEntryFactory = (service: string, account: string) => KeyringEntry | Promise; + +export class DiffexAccountStorageError extends Error { + constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "DiffexAccountStorageError"; + } +} + +export class KeyringDiffexAccountAuthStorage implements DiffexAccountAuthStorage { + readonly #createEntry: KeyringEntryFactory; + + constructor(createEntry: KeyringEntryFactory = createNativeKeyringEntry) { + this.#createEntry = createEntry; + } + + async read(apiOrigin: string, signal?: AbortSignal): Promise { + try { + const entry = await this.#entry(apiOrigin); + const stored = await entry.getPassword(signal); + return stored === undefined ? undefined : parseStoredSession(stored, apiOrigin); + } catch (error) { + if (isMissingCredentialError(error)) return undefined; + if (error instanceof DiffexAccountStorageError) throw error; + throw new DiffexAccountStorageError("Unable to read Diffex credentials from the OS keychain", error); + } + } + + async write(session: DiffexAccountSession, signal?: AbortSignal): Promise { + validateSession(session, session.apiOrigin); + try { + const entry = await this.#entry(session.apiOrigin); + await entry.setPassword(JSON.stringify(session), signal); + } catch (error) { + if (error instanceof DiffexAccountStorageError) throw error; + throw new DiffexAccountStorageError("Unable to save Diffex credentials in the OS keychain", error); + } + } + + async delete(apiOrigin: string, signal?: AbortSignal): Promise { + try { + const entry = await this.#entry(apiOrigin); + await entry.deleteCredential(signal); + } catch (error) { + if (isMissingCredentialError(error)) return; + if (error instanceof DiffexAccountStorageError) throw error; + throw new DiffexAccountStorageError("Unable to remove Diffex credentials from the OS keychain", error); + } + } + + async #entry(apiOrigin: string): Promise { + return this.#createEntry(KEYRING_SERVICE, apiOrigin); + } +} + +async function createNativeKeyringEntry(service: string, account: string): Promise { + try { + const { AsyncEntry } = await import("@napi-rs/keyring"); + return new AsyncEntry(service, account); + } catch (error) { + throw new DiffexAccountStorageError( + "OS keychain support is unavailable for Diffex account authentication", + error, + ); + } +} + +function parseStoredSession(value: string, apiOrigin: string): DiffexAccountSession { + let parsed: unknown; + try { + parsed = JSON.parse(value); + } catch (error) { + throw new DiffexAccountStorageError("Stored Diffex credentials are invalid", error); + } + return validateSession(parsed, apiOrigin); +} + +function validateSession(value: unknown, apiOrigin: string): DiffexAccountSession { + if (!isRecord(value)) throw new DiffexAccountStorageError("Stored Diffex credentials are invalid"); + if ( + value.version !== 1 || + value.apiOrigin !== apiOrigin || + typeof value.accessToken !== "string" || + value.accessToken.length === 0 || + typeof value.refreshToken !== "string" || + value.refreshToken.length === 0 || + value.tokenType !== "Bearer" || + typeof value.expiresAt !== "number" || + !Number.isFinite(value.expiresAt) || + (value.context !== undefined && !isRecord(value.context)) + ) { + throw new DiffexAccountStorageError("Stored Diffex credentials are invalid"); + } + + return { + version: 1, + apiOrigin, + accessToken: value.accessToken, + refreshToken: value.refreshToken, + tokenType: "Bearer", + expiresAt: value.expiresAt, + ...(value.context === undefined ? {} : { context: value.context }), + }; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} + +function isMissingCredentialError(error: unknown): boolean { + if (!(error instanceof Error)) return false; + const normalized = error.message.toLowerCase(); + return normalized.includes("noentry") || normalized.includes("no entry"); +} diff --git a/packages/coding-agent/src/core/diffex-account-auth.ts b/packages/coding-agent/src/core/diffex-account-auth.ts new file mode 100644 index 000000000..1616c5c24 --- /dev/null +++ b/packages/coding-agent/src/core/diffex-account-auth.ts @@ -0,0 +1,464 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { + type DiffexAccountAuthStorage, + type DiffexAccountSession, + DiffexAccountStorageError, + KeyringDiffexAccountAuthStorage, +} from "./account-auth-storage.ts"; + +const DEFAULT_API_ORIGIN = "https://api.diffex.ai"; +const REQUEST_TIMEOUT_MS = 15_000; +const REFRESH_WINDOW_MS = 60_000; +const MAX_POLL_INTERVAL_MS = 30_000; + +export interface DiffexDeviceAuthorizationPrompt { + verificationUri: string; + userCode: string; +} + +export interface DiffexAccountLoginOptions { + signal?: AbortSignal; + onDeviceAuthorization: (prompt: DiffexDeviceAuthorizationPrompt) => void; +} + +export interface DiffexAccountLogoutResult { + hadSession: boolean; + remoteConfirmed: boolean; +} + +export interface DiffexAccountAuth { + readonly apiOrigin: string; + hasSession(signal?: AbortSignal): Promise; + login(options: DiffexAccountLoginOptions): Promise; + logout(signal?: AbortSignal): Promise; +} + +export interface DiffexAccountAuthDependencies { + fetch: (input: string | URL, init?: RequestInit) => Promise; + storage: DiffexAccountAuthStorage; + now: () => number; + sleep: (milliseconds: number, signal?: AbortSignal) => Promise; +} + +export interface HostedDiffexAccountAuthOptions { + apiOrigin?: string; + env?: Record; + dependencies?: DiffexAccountAuthDependencies; +} + +type DeviceAuthorization = { + deviceCode: string; + userCode: string; + verificationUri: string; + verificationUriComplete?: string; + expiresIn: number; + interval: number; +}; + +type TokenResponse = { + accessToken: string; + refreshToken: string; + tokenType: "Bearer"; + expiresIn: number; + context?: Record; +}; + +type JsonResponse = { + response: Response; + body: unknown; +}; + +export class DiffexAccountAuthError extends Error { + constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "DiffexAccountAuthError"; + } +} + +export class HostedDiffexAccountAuth implements DiffexAccountAuth { + readonly apiOrigin: string; + readonly #dependencies: DiffexAccountAuthDependencies; + + constructor(options: HostedDiffexAccountAuthOptions = {}) { + this.apiOrigin = normalizeApiOrigin( + options.apiOrigin ?? options.env?.DIFFEX_API_URL ?? process.env.DIFFEX_API_URL ?? DEFAULT_API_ORIGIN, + ); + this.#dependencies = options.dependencies ?? createDefaultDependencies(); + } + + async hasSession(signal?: AbortSignal): Promise { + try { + return (await this.#dependencies.storage.read(this.apiOrigin, signal)) !== undefined; + } catch (error) { + throw normalizeStorageError(error); + } + } + + async login(options: DiffexAccountLoginOptions): Promise { + try { + throwIfLoginCancelled(options.signal); + const started = await postJson( + this.#dependencies, + this.apiOrigin, + "/v1/auth/cli/start", + {}, + {}, + options.signal, + ); + if (!started.response.ok) throw responseError(started, "Unable to start Diffex sign in"); + const authorization = parseDeviceAuthorization(started.body, this.apiOrigin); + options.onDeviceAuthorization({ + verificationUri: authorization.verificationUriComplete ?? authorization.verificationUri, + userCode: authorization.userCode, + }); + + const token = await pollForToken(this.apiOrigin, authorization, this.#dependencies, options.signal); + const session = toStoredSession(this.apiOrigin, token, this.#dependencies.now()); + await this.#dependencies.storage.write(session, options.signal); + } catch (error) { + if (options.signal?.aborted) throw loginCancelled(error); + throw normalizeStorageError(error); + } + } + + async logout(signal?: AbortSignal): Promise { + let stored: DiffexAccountSession | undefined; + try { + stored = await this.#dependencies.storage.read(this.apiOrigin, signal); + } catch (error) { + throw normalizeStorageError(error); + } + if (!stored) return { hadSession: false, remoteConfirmed: true }; + + let remoteConfirmed = true; + try { + const session = + stored.expiresAt <= this.#dependencies.now() + REFRESH_WINDOW_MS + ? await refreshSession(stored, this.#dependencies, signal) + : stored; + const result = await postJson( + this.#dependencies, + this.apiOrigin, + "/v1/auth/logout", + {}, + { authorization: `Bearer ${session.accessToken}` }, + signal, + ); + remoteConfirmed = result.response.ok || result.response.status === 401; + } catch { + remoteConfirmed = false; + } + + try { + await this.#dependencies.storage.delete(this.apiOrigin); + } catch (error) { + throw normalizeStorageError(error); + } + return { hadSession: true, remoteConfirmed }; + } +} + +function createDefaultDependencies(): DiffexAccountAuthDependencies { + return { + fetch: (input, init) => globalThis.fetch(input, init), + storage: new KeyringDiffexAccountAuthStorage(), + now: () => Date.now(), + sleep: async (milliseconds, signal) => { + await sleep(milliseconds, undefined, signal ? { signal } : undefined); + }, + }; +} + +async function pollForToken( + apiOrigin: string, + authorization: DeviceAuthorization, + dependencies: DiffexAccountAuthDependencies, + signal?: AbortSignal, +): Promise { + const deadline = dependencies.now() + authorization.expiresIn * 1_000; + let intervalMs = authorization.interval * 1_000; + + while (dependencies.now() < deadline) { + throwIfLoginCancelled(signal); + const remaining = deadline - dependencies.now(); + try { + await dependencies.sleep(Math.min(intervalMs, remaining), signal); + } catch (error) { + if (signal?.aborted) throw loginCancelled(error); + throw error; + } + throwIfLoginCancelled(signal); + if (dependencies.now() >= deadline) break; + + let result: JsonResponse; + try { + result = await postJson( + dependencies, + apiOrigin, + "/v1/auth/cli/poll", + { + device_code: authorization.deviceCode, + interval: Math.ceil(intervalMs / 1_000), + }, + {}, + signal, + ); + } catch (error) { + if (signal?.aborted) throw loginCancelled(error); + intervalMs = Math.min(intervalMs * 2, MAX_POLL_INTERVAL_MS); + continue; + } + + if (result.response.status === 202) continue; + if (result.response.ok) return parseTokenResponse(result.body); + if (result.response.status === 429) { + const retryAfter = parseRetryAfter(result.response.headers.get("retry-after")); + intervalMs = + retryAfter === undefined + ? Math.min(intervalMs + 5_000, MAX_POLL_INTERVAL_MS) + : Math.min(retryAfter * 1_000, MAX_POLL_INTERVAL_MS); + continue; + } + if (result.response.status >= 500) { + intervalMs = Math.min(intervalMs * 2, MAX_POLL_INTERVAL_MS); + continue; + } + throw responseError(result, "Diffex sign in was not completed"); + } + + throw new DiffexAccountAuthError("The sign-in code expired. Use /login to try again."); +} + +async function refreshSession( + session: DiffexAccountSession, + dependencies: DiffexAccountAuthDependencies, + signal?: AbortSignal, +): Promise { + const result = await postJson( + dependencies, + session.apiOrigin, + "/v1/auth/token/refresh", + { refresh_token: session.refreshToken }, + {}, + signal, + ); + if (!result.response.ok) { + const code = responseErrorCode(result.body); + if (code === "invalid_grant" || code === "unauthenticated" || result.response.status === 401) { + await dependencies.storage.delete(session.apiOrigin); + throw new DiffexAccountAuthError("Your Diffex session expired. Use /login to sign in again."); + } + throw responseError(result, "Unable to refresh the Diffex session"); + } + + const token = parseTokenResponse(result.body); + const refreshed = toStoredSession(session.apiOrigin, token, dependencies.now(), session.context); + await dependencies.storage.write(refreshed, signal); + return refreshed; +} + +async function postJson( + dependencies: DiffexAccountAuthDependencies, + apiOrigin: string, + path: string, + body: Record, + extraHeaders: Record = {}, + signal?: AbortSignal, +): Promise { + let response: Response; + try { + const timeoutSignal = AbortSignal.timeout(REQUEST_TIMEOUT_MS); + response = await dependencies.fetch(new URL(path, apiOrigin), { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify(body), + signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, + }); + } catch (error) { + if (signal?.aborted) throw loginCancelled(error); + throw new DiffexAccountAuthError("Unable to reach the Diffex authentication service", error); + } + + let responseBody: unknown = {}; + try { + responseBody = await response.json(); + } catch { + responseBody = {}; + } + return { response, body: responseBody }; +} + +function parseDeviceAuthorization(value: unknown, apiOrigin: string): DeviceAuthorization { + if (!isRecord(value)) throw invalidServerResponse(); + if ( + typeof value.device_code !== "string" || + value.device_code.length === 0 || + typeof value.user_code !== "string" || + value.user_code.length === 0 || + typeof value.verification_uri !== "string" || + !isPositiveNumber(value.expires_in) || + !isPositiveNumber(value.interval) + ) { + throw invalidServerResponse(); + } + const verificationUri = validateVerificationUrl(value.verification_uri, apiOrigin); + const verificationUriComplete = + typeof value.verification_uri_complete === "string" && value.verification_uri_complete.length > 0 + ? validateVerificationUrl(value.verification_uri_complete, apiOrigin) + : undefined; + if ( + verificationUriComplete !== undefined && + new URL(verificationUriComplete).origin !== new URL(verificationUri).origin + ) { + throw invalidServerResponse(); + } + + return { + deviceCode: value.device_code, + userCode: value.user_code, + verificationUri, + ...(verificationUriComplete === undefined ? {} : { verificationUriComplete }), + expiresIn: Math.min(Math.floor(value.expires_in), 3_600), + interval: Math.max(1, Math.floor(value.interval)), + }; +} + +function parseTokenResponse(value: unknown): TokenResponse { + if ( + !isRecord(value) || + typeof value.access_token !== "string" || + value.access_token.length === 0 || + typeof value.refresh_token !== "string" || + value.refresh_token.length === 0 || + value.token_type !== "Bearer" || + !isPositiveNumber(value.expires_in) || + (value.context !== undefined && !isRecord(value.context)) + ) { + throw invalidServerResponse(); + } + return { + accessToken: value.access_token, + refreshToken: value.refresh_token, + tokenType: "Bearer", + expiresIn: Math.floor(value.expires_in), + ...(value.context === undefined ? {} : { context: value.context }), + }; +} + +function toStoredSession( + apiOrigin: string, + token: TokenResponse, + now: number, + previousContext?: Record, +): DiffexAccountSession { + const context = token.context ?? previousContext; + return { + version: 1, + apiOrigin, + accessToken: token.accessToken, + refreshToken: token.refreshToken, + tokenType: token.tokenType, + expiresAt: now + token.expiresIn * 1_000, + ...(context === undefined ? {} : { context }), + }; +} + +function responseError(result: JsonResponse, fallback: string): DiffexAccountAuthError { + const code = responseErrorCode(result.body); + const message = + code === "access_denied" + ? "Diffex sign in was denied." + : code === "expired_token" + ? "The sign-in code expired. Use /login to try again." + : code === "rate_limited" || result.response.status === 429 + ? "Too many authentication attempts. Try again shortly." + : code === "auth_temporarily_unavailable" || result.response.status >= 500 + ? "Diffex authentication is temporarily unavailable." + : `${fallback} (${result.response.status})`; + return new DiffexAccountAuthError(message); +} + +function responseErrorCode(value: unknown): string | undefined { + if (!isRecord(value) || !isRecord(value.error)) return undefined; + return typeof value.error.code === "string" ? value.error.code : undefined; +} + +function normalizeApiOrigin(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw new DiffexAccountAuthError("Diffex API URL must be a valid origin", error); + } + const localHttp = url.protocol === "http:" && isLocalApiHostname(url.hostname); + if ( + (url.protocol !== "https:" && !localHttp) || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + throw new DiffexAccountAuthError("Diffex API URL must be an HTTPS origin without credentials or a path"); + } + return url.origin; +} + +function validateVerificationUrl(value: string, apiOrigin: string): string { + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw invalidServerResponse(error); + } + const apiUrl = new URL(apiOrigin); + const localHttp = + apiUrl.protocol === "http:" && + isLocalApiHostname(apiUrl.hostname) && + url.protocol === "http:" && + isLocalApiHostname(url.hostname); + if ((url.protocol !== "https:" && !localHttp) || url.username !== "" || url.password !== "") { + throw invalidServerResponse(); + } + return url.toString(); +} + +function isLocalApiHostname(hostname: string): boolean { + return ( + hostname === "localhost" || hostname === "127.0.0.1" || hostname === "[::1]" || hostname === "api.diffex.test" + ); +} + +function parseRetryAfter(value: string | null): number | undefined { + if (!value) return undefined; + const seconds = Number(value); + return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : undefined; +} + +function invalidServerResponse(cause?: unknown): DiffexAccountAuthError { + return new DiffexAccountAuthError("The Diffex authentication service returned an invalid response", cause); +} + +function loginCancelled(cause?: unknown): DiffexAccountAuthError { + return new DiffexAccountAuthError("Sign in cancelled", cause); +} + +function throwIfLoginCancelled(signal?: AbortSignal): void { + if (signal?.aborted) throw loginCancelled(signal.reason); +} + +function normalizeStorageError(error: unknown): unknown { + return error instanceof DiffexAccountStorageError ? new DiffexAccountAuthError(error.message, error) : error; +} + +function isPositiveNumber(value: unknown): value is number { + return typeof value === "number" && Number.isFinite(value) && value > 0; +} + +function isRecord(value: unknown): value is Record { + return typeof value === "object" && value !== null && !Array.isArray(value); +} diff --git a/packages/coding-agent/src/core/slash-commands.ts b/packages/coding-agent/src/core/slash-commands.ts index c2d75c790..74ad08f1e 100644 --- a/packages/coding-agent/src/core/slash-commands.ts +++ b/packages/coding-agent/src/core/slash-commands.ts @@ -36,8 +36,12 @@ export const BUILTIN_SLASH_COMMANDS: ReadonlyArray = [ { name: "clone", description: "Duplicate the current session at the current position" }, { name: "tree", description: "Navigate session tree (switch branches)" }, { name: "trust", description: "Save project trust decision for future sessions" }, - { name: "login", description: "Configure provider authentication", argumentHint: "" }, - { name: "logout", description: "Remove provider authentication" }, + { + name: "login", + description: "Configure Diffex account or provider authentication", + argumentHint: "", + }, + { name: "logout", description: "Remove Diffex account or provider authentication" }, { name: "clear", description: "Clear current session" }, { name: "compact", description: "Manually compact the session context" }, { name: "resume", description: "Resume a different session" }, diff --git a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts index 37c67738e..03bfe1e3c 100644 --- a/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts +++ b/packages/coding-agent/src/modes/interactive/components/oauth-selector.ts @@ -17,6 +17,7 @@ export type AuthSelectorProvider = { id: string; name: string; authType: "oauth" | "api_key"; + kind?: "provider" | "diffex_account"; method?: ApiKeyAuth | OAuthAuth; status?: AuthCheck; }; @@ -131,7 +132,10 @@ export class OAuthSelectorComponent extends Container implements Focusable { const statusIndicator = this.formatStatusIndicator(provider); const authTypeLabel = this.showAuthTypeLabels - ? theme.fg("muted", ` [${formatAuthSelectorProviderType(provider.authType)}]`) + ? theme.fg( + "muted", + ` [${provider.kind === "diffex_account" ? "Diffex account" : formatAuthSelectorProviderType(provider.authType)}]`, + ) : ""; let line = ""; if (isSelected) { diff --git a/packages/coding-agent/src/modes/interactive/interactive-mode.ts b/packages/coding-agent/src/modes/interactive/interactive-mode.ts index afed74e7a..77135ee28 100644 --- a/packages/coding-agent/src/modes/interactive/interactive-mode.ts +++ b/packages/coding-agent/src/modes/interactive/interactive-mode.ts @@ -67,6 +67,7 @@ import { computeCacheWaste, detectCacheMiss, } from "../../core/cache-stats.ts"; +import { type DiffexAccountAuth, HostedDiffexAccountAuth } from "../../core/diffex-account-auth.ts"; import type { AutocompleteProviderFactory, EditorFactory, @@ -284,9 +285,13 @@ type LoginProviderCompletionOption = { id: string; name: string; authTypes: AuthSelectorProvider["authType"][]; + kind?: "provider" | "diffex_account"; }; const AUTH_TYPE_ORDER = { oauth: 0, api_key: 1 } satisfies Record; +const DIFFEX_ACCOUNT_LOGIN_ID = "diffex"; +const DIFFEX_ACCOUNT_SELECTOR_ID = "diffex-account"; +const DIFFEX_ACCOUNT_LOGIN_LABEL = "Sign in to Diffex"; const DIFFEX_UPDATE_AVAILABLE_NOTIFICATION_ENABLED = false; function createFuzzyAutocompleteItems( @@ -319,10 +324,14 @@ function getLoginProviderCompletionOptions( authTypes: [provider.authType], }); } - return Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)); + return [ + { id: DIFFEX_ACCOUNT_LOGIN_ID, name: "Diffex", authTypes: [], kind: "diffex_account" }, + ...Array.from(byId.values()).sort((a, b) => a.name.localeCompare(b.name)), + ]; } function getLoginProviderSearchText(provider: LoginProviderCompletionOption): string { + if (provider.kind === "diffex_account") return "diffex account"; const authTypes = provider.authTypes .map((authType) => `${authType} ${formatAuthSelectorProviderType(authType)}`) .join(" "); @@ -330,6 +339,7 @@ function getLoginProviderSearchText(provider: LoginProviderCompletionOption): st } function formatLoginProviderCompletionDescription(provider: LoginProviderCompletionOption): string { + if (provider.kind === "diffex_account") return "Diffex account"; const authTypes = provider.authTypes.map(formatAuthSelectorProviderType).join("/"); return provider.name === provider.id ? authTypes : `${provider.name} · ${authTypes}`; } @@ -338,6 +348,8 @@ function formatLoginProviderCompletionDescription(provider: LoginProviderComplet * Options for InteractiveMode initialization. */ export interface InteractiveModeOptions { + /** Diffex account authentication client, injectable for controlled hosts and tests. */ + diffexAccountAuth?: DiffexAccountAuth; /** Process-owned evolution controls for the current repository runtime. */ evolution?: EvolutionMenuClient; /** Providers that were migrated to auth.json (shows warning) */ @@ -546,6 +558,7 @@ export class InteractiveMode { private customHeader: (Component & { dispose?(): void }) | undefined = undefined; private options: InteractiveModeOptions; + private readonly diffexAccountAuth: DiffexAccountAuth; private readonly onRightClickPaste = (): void => { void this.handleRightClickPaste(); }; @@ -568,6 +581,7 @@ export class InteractiveMode { constructor(runtimeHost: AgentSessionRuntime, options: InteractiveModeOptions = {}) { this.runtimeHost = runtimeHost; + this.diffexAccountAuth = options.diffexAccountAuth ?? new HostedDiffexAccountAuth(); const tuiMode = options.tuiMode ?? this.settingsManager.getTuiMode(); this.options = { ...options, tuiMode }; this.autoTrustOnReloadCwd = options.autoTrustOnReloadCwd; @@ -5375,7 +5389,11 @@ export class InteractiveMode { } private async getLogoutProviderOptions(): Promise { - return (await this.session.modelRuntime.listCredentials({ signal: AbortSignal.timeout(15_000) })) + const [credentials, hasDiffexAccount] = await Promise.all([ + this.session.modelRuntime.listCredentials({ signal: AbortSignal.timeout(15_000) }), + this.diffexAccountAuth.hasSession(AbortSignal.timeout(15_000)), + ]); + const providerOptions = credentials .map(({ providerId, type }) => ({ id: providerId, name: this.session.modelRuntime.getProvider(providerId)?.name ?? providerId, @@ -5383,6 +5401,18 @@ export class InteractiveMode { status: { type, source: "stored credential" }, })) .sort((a, b) => a.name.localeCompare(b.name)); + return hasDiffexAccount + ? [ + { + id: DIFFEX_ACCOUNT_SELECTOR_ID, + name: "Diffex account", + authType: "oauth", + kind: "diffex_account", + status: { type: "oauth", source: "stored credential" }, + } satisfies AuthSelectorProvider, + ...providerOptions, + ] + : providerOptions; } private findLoginProviderOptions(providerRef: string): AuthSelectorProvider[] { @@ -5403,6 +5433,10 @@ export class InteractiveMode { this.showLoginAuthTypeSelector(); return; } + if (providerRef.trim().toLowerCase() === DIFFEX_ACCOUNT_LOGIN_ID) { + await this.showDiffexAccountLoginDialog(); + return; + } const providerOptions = this.findLoginProviderOptions(providerRef); if (providerOptions.length === 1) { @@ -5440,7 +5474,7 @@ export class InteractiveMode { const availableAuthTypes = providerOptions ? new Set(providerOptions.map((provider) => provider.authType)) : new Set(["oauth", "api_key"]); - const options: string[] = []; + const options: string[] = providerOptions ? [] : [DIFFEX_ACCOUNT_LOGIN_LABEL]; if (availableAuthTypes.has("oauth")) { options.push(subscriptionLabel); } @@ -5470,6 +5504,10 @@ export class InteractiveMode { options, (option) => { done(); + if (option === DIFFEX_ACCOUNT_LOGIN_LABEL) { + void this.showDiffexAccountLoginDialog(); + return; + } const authType = option === subscriptionLabel ? "oauth" : "api_key"; if (providerOptions) { const providerOption = providerOptions.find((provider) => provider.authType === authType); @@ -5547,7 +5585,7 @@ export class InteractiveMode { } if (providerOptions.length === 0) { this.showStatus( - "No stored credentials to remove. /logout only removes credentials saved by /login; environment variables and models.json config are unchanged.", + "No stored Diffex account or provider credentials to remove. /logout leaves environment variables and models.json config unchanged.", ); return; } @@ -5563,6 +5601,21 @@ export class InteractiveMode { if (!providerOption) { return; } + if (providerOption.kind === "diffex_account") { + try { + const result = await this.diffexAccountAuth.logout(AbortSignal.timeout(15_000)); + if (!result.hadSession) { + this.showStatus("Already signed out of Diffex."); + } else if (result.remoteConfirmed) { + this.showStatus("Signed out of Diffex."); + } else { + this.showWarning("Signed out of Diffex locally, but server logout could not be confirmed."); + } + } catch (error) { + this.showError(`Diffex logout failed: ${error instanceof Error ? error.message : String(error)}`); + } + return; + } try { await this.session.modelRuntime.logout(providerOption.id, { @@ -5592,6 +5645,45 @@ export class InteractiveMode { }); } + private async showDiffexAccountLoginDialog(): Promise { + const dialog = new LoginDialogComponent( + this.ui, + DIFFEX_ACCOUNT_LOGIN_ID, + (_success, _message) => {}, + "Diffex", + "Sign in to Diffex", + ); + this.editorContainer.clear(); + this.editorContainer.addChild(dialog); + this.ui.setFocus(dialog); + this.ui.requestRender(); + + const restoreEditor = () => { + this.editorContainer.clear(); + this.editorContainer.addChild(this.editor); + this.ui.setFocus(this.editor); + this.ui.requestRender(); + }; + + try { + await this.diffexAccountAuth.login({ + signal: dialog.signal, + onDeviceAuthorization: (prompt) => { + dialog.showDeviceCode(prompt); + dialog.showWaiting("Waiting for Diffex authentication..."); + }, + }); + restoreEditor(); + this.showStatus("Signed in to Diffex."); + } catch (error) { + restoreEditor(); + const message = error instanceof Error ? error.message : String(error); + if (message !== "Sign in cancelled") { + this.showError(`Failed to sign in to Diffex: ${message}`); + } + } + } + private async completeProviderAuthentication( providerId: string, providerName: string, diff --git a/packages/coding-agent/test/account-auth-storage.test.ts b/packages/coding-agent/test/account-auth-storage.test.ts new file mode 100644 index 000000000..f9095ebee --- /dev/null +++ b/packages/coding-agent/test/account-auth-storage.test.ts @@ -0,0 +1,64 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type DiffexAccountSession, + KeyringDiffexAccountAuthStorage, + type KeyringEntry, +} from "../src/core/account-auth-storage.ts"; + +const apiOrigin = "https://api.diffex.ai"; +const session: DiffexAccountSession = { + version: 1, + apiOrigin, + accessToken: "access_secret", + refreshToken: "refresh_secret", + tokenType: "Bearer", + expiresAt: 1_800_000, + context: { user: { id: "user-diffex-1" } }, +}; + +function createFakeKeyring(initial?: string) { + let value = initial; + const entry: KeyringEntry = { + setPassword: vi.fn(async (password) => { + value = password; + }), + getPassword: vi.fn(async () => value), + deleteCredential: vi.fn(async () => { + const existed = value !== undefined; + value = undefined; + return existed; + }), + }; + return { entry, value: () => value }; +} + +describe("Diffex account keyring storage", () => { + it("stores, reads, and deletes the complete session as one keyring credential", async () => { + const fake = createFakeKeyring(); + const createEntry = vi.fn(async () => fake.entry); + const storage = new KeyringDiffexAccountAuthStorage(createEntry); + + expect(createEntry).not.toHaveBeenCalled(); + await storage.write(session); + expect(JSON.parse(fake.value() ?? "{}")).toEqual(session); + await expect(storage.read(apiOrigin)).resolves.toEqual(session); + await storage.delete(apiOrigin); + await expect(storage.read(apiOrigin)).resolves.toBeUndefined(); + expect(createEntry).toHaveBeenCalledWith("ai.diffex.cli", apiOrigin); + }); + + it("rejects malformed keyring data without returning partial credentials", async () => { + const fake = createFakeKeyring(JSON.stringify({ accessToken: "partial_secret" })); + const storage = new KeyringDiffexAccountAuthStorage(() => fake.entry); + + await expect(storage.read(apiOrigin)).rejects.toThrow("Stored Diffex credentials are invalid"); + }); + + it("reports a safe error when the native keyring cannot be loaded", async () => { + const storage = new KeyringDiffexAccountAuthStorage(async () => { + throw new Error("native loader detail"); + }); + + await expect(storage.read(apiOrigin)).rejects.toThrow("Unable to read Diffex credentials from the OS keychain"); + }); +}); diff --git a/packages/coding-agent/test/credential-print.test.ts b/packages/coding-agent/test/credential-print.test.ts index 44e0f3ebe..3f8d68f7e 100644 --- a/packages/coding-agent/test/credential-print.test.ts +++ b/packages/coding-agent/test/credential-print.test.ts @@ -120,6 +120,9 @@ describe("credential print commands", () => { expect(isAuthCommandHelp(["auth", "print-bearer-token", "-h"])).toBe(true); expect(isAuthCommandHelp(["auth", "check", "--help"])).toBe(true); expect(() => parseAuthCommand(["auth", "unknown"])).toThrow(AuthCommandError); + expect(() => parseAuthCommand(["auth", "login"])).toThrow('Unknown auth command "login"'); + expect(() => parseAuthCommand(["auth", "status"])).toThrow('Unknown auth command "status"'); + expect(() => parseAuthCommand(["auth", "logout"])).toThrow('Unknown auth command "logout"'); await expect(resolveCredentialForPrint(parseArgs([]), runtime, "api_key")).rejects.toThrow( "requires --provider or --model ", ); diff --git a/packages/coding-agent/test/diffex-account-auth.test.ts b/packages/coding-agent/test/diffex-account-auth.test.ts new file mode 100644 index 000000000..36af85a2c --- /dev/null +++ b/packages/coding-agent/test/diffex-account-auth.test.ts @@ -0,0 +1,222 @@ +import { describe, expect, it, vi } from "vitest"; +import type { DiffexAccountAuthStorage, DiffexAccountSession } from "../src/core/account-auth-storage.ts"; +import { type DiffexAccountAuthDependencies, HostedDiffexAccountAuth } from "../src/core/diffex-account-auth.ts"; + +const apiOrigin = "https://api.diffex.ai"; + +class MemoryAccountStorage implements DiffexAccountAuthStorage { + session: DiffexAccountSession | undefined; + + constructor(session?: DiffexAccountSession) { + this.session = session; + } + + async read(requestedOrigin: string): Promise { + return this.session?.apiOrigin === requestedOrigin ? structuredClone(this.session) : undefined; + } + + async write(session: DiffexAccountSession): Promise { + this.session = structuredClone(session); + } + + async delete(requestedOrigin: string): Promise { + if (this.session?.apiOrigin === requestedOrigin) this.session = undefined; + } +} + +function jsonResponse(body: unknown, status = 200, headers?: Record): Response { + return new Response(status === 204 ? null : JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +function createHarness(input: { responses?: Response[]; storage?: MemoryAccountStorage; now?: { value: number } }) { + const responses = [...(input.responses ?? [])]; + const storage = input.storage ?? new MemoryAccountStorage(); + const now = input.now ?? { value: 0 }; + const fetch = vi.fn(async () => { + const response = responses.shift(); + if (!response) throw new Error("Unexpected request"); + return response; + }); + const dependencies: DiffexAccountAuthDependencies = { + fetch, + storage, + now: () => now.value, + sleep: vi.fn(async (milliseconds) => { + now.value += milliseconds; + }), + }; + const auth = new HostedDiffexAccountAuth({ apiOrigin, dependencies }); + return { auth, dependencies, fetch, now, storage }; +} + +describe("Diffex account authentication", () => { + it("completes device auth without exposing device or token secrets", async () => { + const harness = createHarness({ + responses: [ + jsonResponse({ + device_code: "device_secret", + user_code: "ABCD-EFGH", + verification_uri: "https://auth.diffex.ai/device", + verification_uri_complete: "https://auth.diffex.ai/device?user_code=ABCD-EFGH", + expires_in: 300, + interval: 1, + }), + jsonResponse({ status: "authorization_pending", interval: 1 }, 202), + jsonResponse({ error: { code: "slow_down" } }, 429, { "retry-after": "2" }), + jsonResponse({ + access_token: "access_secret", + refresh_token: "refresh_secret", + token_type: "Bearer", + expires_in: 3_600, + context: { user: { id: "user-diffex-1" } }, + }), + ], + }); + const prompts: unknown[] = []; + + await harness.auth.login({ onDeviceAuthorization: (prompt) => prompts.push(prompt) }); + + expect(prompts).toEqual([ + { + verificationUri: "https://auth.diffex.ai/device?user_code=ABCD-EFGH", + userCode: "ABCD-EFGH", + }, + ]); + expect(harness.storage.session).toMatchObject({ + apiOrigin, + accessToken: "access_secret", + refreshToken: "refresh_secret", + tokenType: "Bearer", + }); + expect(JSON.stringify(prompts)).not.toMatch(/device_secret|access_secret|refresh_secret/u); + expect(harness.fetch).toHaveBeenCalledTimes(4); + }); + + it.each([ + { + name: "plaintext verification URLs from a deployed API", + verificationUri: "http://auth.diffex.ai/device", + verificationUriComplete: "http://auth.diffex.ai/device?user_code=ABCD-EFGH", + }, + { + name: "verification URLs with embedded credentials", + verificationUri: "https://user:password@auth.diffex.ai/device", + verificationUriComplete: undefined, + }, + { + name: "complete verification URLs on another origin", + verificationUri: "https://auth.diffex.ai/device", + verificationUriComplete: "https://attacker.example/device?user_code=ABCD-EFGH", + }, + ])("rejects $name before notifying the UI", async ({ verificationUri, verificationUriComplete }) => { + const harness = createHarness({ + responses: [ + jsonResponse({ + device_code: "device_secret", + user_code: "ABCD-EFGH", + verification_uri: verificationUri, + ...(verificationUriComplete === undefined ? {} : { verification_uri_complete: verificationUriComplete }), + expires_in: 300, + interval: 1, + }), + ], + }); + const onDeviceAuthorization = vi.fn(); + + await expect(harness.auth.login({ onDeviceAuthorization })).rejects.toThrow("invalid response"); + expect(onDeviceAuthorization).not.toHaveBeenCalled(); + expect(harness.storage.session).toBeUndefined(); + }); + + it("permits local HTTP verification URLs only with a local API", async () => { + const harness = createHarness({ + responses: [ + jsonResponse({ + device_code: "device_secret", + user_code: "ABCD-EFGH", + verification_uri: "http://api.diffex.test:4175/device", + verification_uri_complete: "http://api.diffex.test:4175/device?user_code=ABCD-EFGH", + expires_in: 300, + interval: 1, + }), + jsonResponse({ + access_token: "access_secret", + refresh_token: "refresh_secret", + token_type: "Bearer", + expires_in: 3_600, + }), + ], + }); + const localAuth = new HostedDiffexAccountAuth({ + apiOrigin: "http://api.diffex.test:4175", + dependencies: harness.dependencies, + }); + + await localAuth.login({ onDeviceAuthorization: vi.fn() }); + expect(harness.storage.session?.apiOrigin).toBe("http://api.diffex.test:4175"); + }); + + it("reports whether a Diffex account session is stored", async () => { + const storage = new MemoryAccountStorage({ + version: 1, + apiOrigin, + accessToken: "access_secret", + refreshToken: "refresh_secret", + tokenType: "Bearer", + expiresAt: 3_600_000, + }); + const harness = createHarness({ storage }); + + await expect(harness.auth.hasSession()).resolves.toBe(true); + await storage.delete(apiOrigin); + await expect(harness.auth.hasSession()).resolves.toBe(false); + }); + + it("refreshes an expiring session before remote logout", async () => { + const storage = new MemoryAccountStorage({ + version: 1, + apiOrigin, + accessToken: "old_access", + refreshToken: "old_refresh", + tokenType: "Bearer", + expiresAt: 10, + }); + const harness = createHarness({ + storage, + responses: [ + jsonResponse({ + access_token: "rotated_access", + refresh_token: "rotated_refresh", + token_type: "Bearer", + expires_in: 1_800, + }), + jsonResponse({}, 204), + ], + }); + + await expect(harness.auth.logout()).resolves.toEqual({ hadSession: true, remoteConfirmed: true }); + expect(storage.session).toBeUndefined(); + expect(harness.fetch).toHaveBeenCalledTimes(2); + }); + + it("removes local credentials when remote logout is unavailable", async () => { + const storage = new MemoryAccountStorage({ + version: 1, + apiOrigin, + accessToken: "access_secret", + refreshToken: "refresh_secret", + tokenType: "Bearer", + expiresAt: 3_600_000, + }); + const harness = createHarness({ + storage, + responses: [jsonResponse({ error: { code: "temporarily_unavailable" } }, 503)], + }); + + await expect(harness.auth.logout()).resolves.toEqual({ hadSession: true, remoteConfirmed: false }); + expect(storage.session).toBeUndefined(); + }); +}); diff --git a/packages/coding-agent/test/interactive-mode-status.test.ts b/packages/coding-agent/test/interactive-mode-status.test.ts index 1c6494345..3bcda740a 100644 --- a/packages/coding-agent/test/interactive-mode-status.test.ts +++ b/packages/coding-agent/test/interactive-mode-status.test.ts @@ -570,6 +570,12 @@ describe("InteractiveMode.createBaseAutocompleteProvider", () => { description: "Anthropic · subscription/API key", }, ]); + + const accountLine = "/login diff"; + const accountSuggestions = await provider.getSuggestions([accountLine], 0, accountLine.length, { + signal: new AbortController().signal, + }); + expect(accountSuggestions?.items).toEqual([{ value: "diffex", label: "diffex", description: "Diffex account" }]); }); }); describe("InteractiveMode.showLoadedResources", () => { diff --git a/packages/coding-agent/test/oauth-selector.test.ts b/packages/coding-agent/test/oauth-selector.test.ts index 79458b794..3cd88fb58 100644 --- a/packages/coding-agent/test/oauth-selector.test.ts +++ b/packages/coding-agent/test/oauth-selector.test.ts @@ -86,6 +86,74 @@ describe("OAuthSelectorComponent", () => { expect(output).not.toContain("✓ configured"); }); + it("labels a Diffex account separately from provider authentication", () => { + const selector = new OAuthSelectorComponent( + "logout", + [ + { + id: "diffex-account", + name: "Diffex account", + authType: "oauth", + kind: "diffex_account", + status: { type: "oauth", source: "stored credential" }, + }, + { id: "openai", name: "OpenAI", authType: "api_key", status: { type: "api_key" } }, + ], + () => {}, + () => {}, + ); + + const output = stripAnsi(selector.render(120).join("\n")); + expect(output).toContain("Diffex account [Diffex account]"); + expect(output).not.toContain("Diffex account [subscription]"); + }); + + it("routes the diffex login argument to account authentication", async () => { + const handleLoginCommand = ( + InteractiveMode as unknown as { + prototype: { + handleLoginCommand( + this: { showDiffexAccountLoginDialog: () => Promise }, + ref?: string, + ): Promise; + }; + } + ).prototype.handleLoginCommand; + let accountLoginStarted = false; + + await handleLoginCommand.call( + { + showDiffexAccountLoginDialog: async () => { + accountLoginStarted = true; + }, + }, + "diffex", + ); + + expect(accountLoginStarted).toBe(true); + }); + + it("includes a stored Diffex account in the logout selector", async () => { + const getLogoutProviderOptions = ( + InteractiveMode as unknown as { + prototype: { + getLogoutProviderOptions(this: object): Promise>; + }; + } + ).prototype.getLogoutProviderOptions; + const options = await getLogoutProviderOptions.call({ + diffexAccountAuth: { hasSession: async () => true }, + session: { + modelRuntime: { + listCredentials: async () => [], + getProvider: () => undefined, + }, + }, + }); + + expect(options).toMatchObject([{ id: "diffex-account", kind: "diffex_account" }]); + }); + it("shows OAuth auth distinctly in the API key selector", () => { const selector = new OAuthSelectorComponent( "login",