From 5c702670fd8bd92010e165d33a46c48da3bd0536 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 29 Aug 2026 11:38:16 -0400 Subject: [PATCH 1/3] feat(auth): add CLI device sign-in Add WorkOS device authorization commands for login, status, and logout with bounded polling, refresh-token rotation, secret-safe output, and native OS keyring storage without a plaintext fallback. Include exact native dependencies, generated install locks, and focused behavior and storage tests. --- package-lock.json | 235 +++++++++ .../install-lock/package-lock.json | 235 +++++++++ packages/coding-agent/npm-shrinkwrap.json | 235 +++++++++ packages/coding-agent/package.json | 1 + .../src/cli/account-auth-command.ts | 457 ++++++++++++++++++ packages/coding-agent/src/cli/auth-command.ts | 9 +- .../src/core/account-auth-storage.ts | 123 +++++ packages/coding-agent/src/main.ts | 18 + .../test/account-auth-command.test.ts | 170 +++++++ .../test/account-auth-storage.test.ts | 55 +++ 10 files changed, 1536 insertions(+), 2 deletions(-) create mode 100644 packages/coding-agent/src/cli/account-auth-command.ts create mode 100644 packages/coding-agent/src/core/account-auth-storage.ts create mode 100644 packages/coding-agent/test/account-auth-command.test.ts create mode 100644 packages/coding-agent/test/account-auth-storage.test.ts 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/account-auth-command.ts b/packages/coding-agent/src/cli/account-auth-command.ts new file mode 100644 index 000000000..62b4e28c2 --- /dev/null +++ b/packages/coding-agent/src/cli/account-auth-command.ts @@ -0,0 +1,457 @@ +import { setTimeout as sleep } from "node:timers/promises"; +import { + type DiffexAccountAuthStorage, + type DiffexAccountSession, + KeyringDiffexAccountAuthStorage, +} from "../core/account-auth-storage.ts"; +import { openBrowser } from "../utils/open-browser.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 type AccountAuthCommandKind = "login" | "status" | "logout"; + +export interface AccountAuthCommand { + kind: AccountAuthCommandKind; + apiOrigin: string; + noBrowser: boolean; + json: boolean; +} + +export interface AccountAuthCommandDependencies { + fetch: (input: string | URL, init?: RequestInit) => Promise; + storage: DiffexAccountAuthStorage; + openBrowser: (target: string) => void; + writeLine: (line: string) => void; + now: () => number; + sleep: (milliseconds: number) => Promise; +} + +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 AccountAuthCommandError extends Error { + constructor(message: string, cause?: unknown) { + super(message, cause === undefined ? undefined : { cause }); + this.name = "AccountAuthCommandError"; + } +} + +export function parseAccountAuthCommand( + args: string[], + env: Record = process.env, +): AccountAuthCommand | undefined { + if (args[0] !== "auth") return undefined; + const kind = args[1]; + if (kind !== "login" && kind !== "status" && kind !== "logout") return undefined; + + let apiOrigin = normalizeApiOrigin(env.DIFFEX_API_URL ?? DEFAULT_API_ORIGIN); + let noBrowser = false; + let json = false; + + for (let index = 2; index < args.length; index++) { + const argument = args[index]; + if (argument === "--api-url") { + const value = args[++index]; + if (!value) throw new AccountAuthCommandError("--api-url requires an origin"); + apiOrigin = normalizeApiOrigin(value); + continue; + } + if (argument?.startsWith("--api-url=")) { + apiOrigin = normalizeApiOrigin(argument.slice("--api-url=".length)); + continue; + } + if (argument === "--no-browser") { + if (kind !== "login") throw new AccountAuthCommandError("--no-browser is only supported by auth login"); + noBrowser = true; + continue; + } + if (argument === "--json") { + if (kind !== "status") throw new AccountAuthCommandError("--json is only supported by auth status"); + json = true; + continue; + } + throw new AccountAuthCommandError(`Unknown option for auth ${kind}: ${argument ?? ""}`); + } + + return { kind, apiOrigin, noBrowser, json }; +} + +export async function executeAccountAuthCommand( + command: AccountAuthCommand, + dependencies: AccountAuthCommandDependencies = createDefaultDependencies(), +): Promise { + switch (command.kind) { + case "login": + return login(command, dependencies); + case "status": + return status(command, dependencies); + case "logout": + return logout(command, dependencies); + } +} + +function createDefaultDependencies(): AccountAuthCommandDependencies { + return { + fetch: (input, init) => globalThis.fetch(input, init), + storage: new KeyringDiffexAccountAuthStorage(), + openBrowser, + writeLine: (line) => console.log(line), + now: () => Date.now(), + sleep: async (milliseconds) => { + await sleep(milliseconds); + }, + }; +} + +async function login(command: AccountAuthCommand, dependencies: AccountAuthCommandDependencies): Promise { + const started = await postJson(dependencies, command.apiOrigin, "/v1/auth/cli/start", {}); + if (!started.response.ok) throw responseError(started, "Unable to start Diffex sign in"); + const authorization = parseDeviceAuthorization(started.body); + const browserUrl = authorization.verificationUriComplete ?? authorization.verificationUri; + + dependencies.writeLine("Open this URL to sign in:"); + dependencies.writeLine(browserUrl); + dependencies.writeLine(`Confirmation code: ${authorization.userCode}`); + if (!command.noBrowser) dependencies.openBrowser(browserUrl); + + const token = await pollForToken(command.apiOrigin, authorization, dependencies); + const session = toStoredSession(command.apiOrigin, token, dependencies.now()); + await dependencies.storage.write(session); + dependencies.writeLine("Signed in to Diffex."); + return 0; +} + +async function status(command: AccountAuthCommand, dependencies: AccountAuthCommandDependencies): Promise { + let session = await dependencies.storage.read(command.apiOrigin); + if (!session) { + writeStatus(command, dependencies, "signed_out"); + return 1; + } + + if (session.expiresAt <= dependencies.now() + REFRESH_WINDOW_MS) { + session = await refreshSession(session, dependencies); + } + + writeStatus(command, dependencies, "signed_in", session); + return 0; +} + +async function logout(command: AccountAuthCommand, dependencies: AccountAuthCommandDependencies): Promise { + const stored = await dependencies.storage.read(command.apiOrigin); + if (!stored) { + dependencies.writeLine("Already signed out."); + return 0; + } + + let remoteFailure: unknown; + try { + const session = + stored.expiresAt <= dependencies.now() + REFRESH_WINDOW_MS + ? await refreshSession(stored, dependencies) + : stored; + const result = await postJson( + dependencies, + command.apiOrigin, + "/v1/auth/logout", + {}, + { authorization: `Bearer ${session.accessToken}` }, + ); + if (!result.response.ok && result.response.status !== 401) { + remoteFailure = responseError(result, "Unable to confirm server logout"); + } + } catch (error) { + remoteFailure = error; + } + + await dependencies.storage.delete(command.apiOrigin); + dependencies.writeLine("Signed out of Diffex."); + if (remoteFailure) { + throw new AccountAuthCommandError( + "Local credentials were removed, but server logout could not be confirmed", + remoteFailure, + ); + } + return 0; +} + +async function pollForToken( + apiOrigin: string, + authorization: DeviceAuthorization, + dependencies: AccountAuthCommandDependencies, +): Promise { + const deadline = dependencies.now() + authorization.expiresIn * 1_000; + let intervalMs = authorization.interval * 1_000; + + while (dependencies.now() < deadline) { + const remaining = deadline - dependencies.now(); + await dependencies.sleep(Math.min(intervalMs, remaining)); + 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), + }); + } catch { + 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 AccountAuthCommandError("The sign-in code expired. Run diffex auth login again."); +} + +async function refreshSession( + session: DiffexAccountSession, + dependencies: AccountAuthCommandDependencies, +): Promise { + const result = await postJson(dependencies, session.apiOrigin, "/v1/auth/token/refresh", { + refresh_token: session.refreshToken, + }); + 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 AccountAuthCommandError("Your Diffex session expired. Run diffex auth login 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); + return refreshed; +} + +async function postJson( + dependencies: AccountAuthCommandDependencies, + apiOrigin: string, + path: string, + body: Record, + extraHeaders: Record = {}, +): Promise { + let response: Response; + try { + response = await dependencies.fetch(new URL(path, apiOrigin), { + method: "POST", + headers: { + accept: "application/json", + "content-type": "application/json", + ...extraHeaders, + }, + body: JSON.stringify(body), + signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + }); + } catch (error) { + throw new AccountAuthCommandError("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): DeviceAuthorization { + if (!isRecord(value)) throw invalidServerResponse(); + const verificationUriComplete = + typeof value.verification_uri_complete === "string" && value.verification_uri_complete.length > 0 + ? validateVerificationUrl(value.verification_uri_complete) + : undefined; + 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(); + } + + return { + deviceCode: value.device_code, + userCode: value.user_code, + verificationUri: validateVerificationUrl(value.verification_uri), + ...(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 writeStatus( + command: AccountAuthCommand, + dependencies: AccountAuthCommandDependencies, + statusValue: "signed_in" | "signed_out", + session?: DiffexAccountSession, +): void { + if (command.json) { + dependencies.writeLine( + JSON.stringify({ + status: statusValue, + api_url: command.apiOrigin, + ...(session === undefined ? {} : { expires_at: new Date(session.expiresAt).toISOString() }), + }), + ); + return; + } + dependencies.writeLine(statusValue === "signed_in" ? "Signed in to Diffex." : "Not signed in to Diffex."); +} + +function responseError(result: JsonResponse, fallback: string): AccountAuthCommandError { + const code = responseErrorCode(result.body); + const message = + code === "access_denied" + ? "Diffex sign in was denied." + : code === "expired_token" + ? "The sign-in code expired. Run diffex auth login 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 AccountAuthCommandError(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 AccountAuthCommandError("Diffex API URL must be a valid origin", error); + } + const localHttp = + url.protocol === "http:" && + (url.hostname === "localhost" || + url.hostname === "127.0.0.1" || + url.hostname === "[::1]" || + url.hostname === "api.diffex.test"); + if ( + (url.protocol !== "https:" && !localHttp) || + url.username !== "" || + url.password !== "" || + url.pathname !== "/" || + url.search !== "" || + url.hash !== "" + ) { + throw new AccountAuthCommandError("Diffex API URL must be an HTTPS origin without credentials or a path"); + } + return url.origin; +} + +function validateVerificationUrl(value: string): string { + let url: URL; + try { + url = new URL(value); + } catch (error) { + throw invalidServerResponse(error); + } + if (url.protocol !== "https:" && url.protocol !== "http:") throw invalidServerResponse(); + return url.toString(); +} + +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): AccountAuthCommandError { + return new AccountAuthCommandError("The Diffex authentication service returned an invalid response", cause); +} + +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/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts index 133e41502..28e55cd00 100644 --- a/packages/coding-agent/src/cli/auth-command.ts +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -38,11 +38,16 @@ export function isAuthCommandHelp(args: string[]): boolean { export function printAuthCommandHelp(): void { console.log(`Usage: + ${APP_NAME} auth login [--api-url ] [--no-browser] + ${APP_NAME} auth status [--api-url ] [--json] + ${APP_NAME} auth logout [--api-url ] ${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.`); +Diffex account commands use secure OS credential storage and default to https://api.diffex.ai. Set DIFFEX_API_URL or use --api-url to select another deployment. + +Provider credential commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the provider credential, or includes it in JSON output.`); } export function parseAuthCommand(args: string[]): AuthCommand | undefined { @@ -58,7 +63,7 @@ export function parseAuthCommand(args: string[]): AuthCommand | undefined { : undefined; if (!kind) { throw new AuthCommandError( - `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, + `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth login", "${APP_NAME} auth status", "${APP_NAME} auth logout", "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, ); } 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..df41f8f67 --- /dev/null +++ b/packages/coding-agent/src/core/account-auth-storage.ts @@ -0,0 +1,123 @@ +import { AsyncEntry } from "@napi-rs/keyring"; + +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; + +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 = (service, account) => new AsyncEntry(service, account)) { + this.#createEntry = createEntry; + } + + async read(apiOrigin: string, signal?: AbortSignal): Promise { + try { + const stored = await this.#entry(apiOrigin).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 { + await this.#entry(session.apiOrigin).setPassword(JSON.stringify(session), signal); + } catch (error) { + throw new DiffexAccountStorageError("Unable to save Diffex credentials in the OS keychain", error); + } + } + + async delete(apiOrigin: string, signal?: AbortSignal): Promise { + try { + await this.#entry(apiOrigin).deleteCredential(signal); + } catch (error) { + if (isMissingCredentialError(error)) return; + throw new DiffexAccountStorageError("Unable to remove Diffex credentials from the OS keychain", error); + } + } + + #entry(apiOrigin: string): KeyringEntry { + return this.#createEntry(KEYRING_SERVICE, apiOrigin); + } +} + +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/main.ts b/packages/coding-agent/src/main.ts index 897f0b56f..4d561e7dd 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -8,6 +8,11 @@ import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/diffex-ai"; import chalk from "chalk"; +import { + AccountAuthCommandError, + executeAccountAuthCommand, + parseAccountAuthCommand, +} from "./cli/account-auth-command.ts"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; import { type AuthCheckResult, @@ -158,6 +163,19 @@ async function runAuthCommand(args: string[]): Promise { return true; } + try { + const accountCommand = parseAccountAuthCommand(args); + if (accountCommand) { + process.exitCode = await executeAccountAuthCommand(accountCommand); + return true; + } + } catch (error) { + const message = error instanceof AccountAuthCommandError ? error.message : "Diffex account authentication failed"; + console.error(chalk.red(`Error: ${message}`)); + process.exitCode = 1; + return true; + } + let command: AuthCommand | undefined; try { command = parseAuthCommand(args); diff --git a/packages/coding-agent/test/account-auth-command.test.ts b/packages/coding-agent/test/account-auth-command.test.ts new file mode 100644 index 000000000..99100b64f --- /dev/null +++ b/packages/coding-agent/test/account-auth-command.test.ts @@ -0,0 +1,170 @@ +import { describe, expect, it, vi } from "vitest"; +import { + type AccountAuthCommand, + type AccountAuthCommandDependencies, + executeAccountAuthCommand, + parseAccountAuthCommand, +} from "../src/cli/account-auth-command.ts"; +import type { DiffexAccountAuthStorage, DiffexAccountSession } from "../src/core/account-auth-storage.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(JSON.stringify(body), { + status, + headers: { "content-type": "application/json", ...headers }, + }); +} + +function command(kind: AccountAuthCommand["kind"]): AccountAuthCommand { + return { kind, apiOrigin, noBrowser: false, json: false }; +} + +function dependencies(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 output: string[] = []; + const fetch = vi.fn(async () => { + const response = responses.shift(); + if (!response) throw new Error("Unexpected request"); + return response; + }); + const result: AccountAuthCommandDependencies = { + fetch, + storage, + openBrowser: vi.fn(), + writeLine: (line) => output.push(line), + now: () => now.value, + sleep: vi.fn(async (milliseconds) => { + now.value += milliseconds; + }), + }; + return { dependencies: result, fetch, now, output, storage }; +} + +describe("Diffex account auth commands", () => { + it("parses account commands without changing provider auth commands", () => { + expect( + parseAccountAuthCommand(["auth", "login", "--api-url", "https://api.staging.diffex.ai", "--no-browser"], {}), + ).toEqual({ + kind: "login", + apiOrigin: "https://api.staging.diffex.ai", + noBrowser: true, + json: false, + }); + expect(parseAccountAuthCommand(["auth", "check", "--provider", "anthropic"], {})).toBeUndefined(); + expect(() => parseAccountAuthCommand(["auth", "login", "--api-url", "http://attacker.example"], {})).toThrow( + "HTTPS origin", + ); + }); + + it("completes device auth while keeping device and token secrets out of terminal output", async () => { + const harness = dependencies({ + 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" } }, + }), + ], + }); + + await expect(executeAccountAuthCommand(command("login"), harness.dependencies)).resolves.toBe(0); + expect(harness.dependencies.openBrowser).toHaveBeenCalledWith( + "https://auth.diffex.ai/device?user_code=ABCD-EFGH", + ); + expect(harness.storage.session).toMatchObject({ + apiOrigin, + accessToken: "access_secret", + refreshToken: "refresh_secret", + tokenType: "Bearer", + }); + const terminal = harness.output.join("\n"); + expect(terminal).toContain("ABCD-EFGH"); + expect(terminal).not.toMatch(/device_secret|access_secret|refresh_secret/u); + expect(harness.fetch).toHaveBeenCalledTimes(4); + }); + + it("rotates an expiring refresh token before reporting signed-in status", async () => { + const storage = new MemoryAccountStorage({ + version: 1, + apiOrigin, + accessToken: "old_access", + refreshToken: "old_refresh", + tokenType: "Bearer", + expiresAt: 10, + }); + const harness = dependencies({ + storage, + responses: [ + jsonResponse({ + access_token: "rotated_access", + refresh_token: "rotated_refresh", + token_type: "Bearer", + expires_in: 1_800, + }), + ], + }); + const statusCommand = { ...command("status"), json: true }; + + await expect(executeAccountAuthCommand(statusCommand, harness.dependencies)).resolves.toBe(0); + expect(storage.session?.refreshToken).toBe("rotated_refresh"); + expect(harness.output).toHaveLength(1); + expect(harness.output[0]).toContain('"status":"signed_in"'); + expect(harness.output[0]).not.toMatch(/old_|rotated_/u); + }); + + it("removes local credentials even 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 = dependencies({ + storage, + responses: [jsonResponse({ error: { code: "temporarily_unavailable" } }, 503)], + }); + + await expect(executeAccountAuthCommand(command("logout"), harness.dependencies)).rejects.toThrow( + "Local credentials were removed", + ); + expect(storage.session).toBeUndefined(); + expect(harness.output).toEqual(["Signed out of Diffex."]); + }); +}); 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..3876cdacf --- /dev/null +++ b/packages/coding-agent/test/account-auth-storage.test.ts @@ -0,0 +1,55 @@ +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(() => fake.entry); + const storage = new KeyringDiffexAccountAuthStorage(createEntry); + + 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"); + }); +}); From 0babacdfc34d1595edc61edbc278db41aaf6c6e0 Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 29 Aug 2026 12:17:11 -0400 Subject: [PATCH 2/3] fix(auth): harden CLI account sign-in Load native keyring support only when account credentials are accessed and report safe failures. Restrict verification URLs to trusted transports and same-origin completion links before opening a browser. --- .../src/cli/account-auth-command.ts | 68 +++++++++++++------ .../src/core/account-auth-storage.ts | 31 ++++++--- .../test/account-auth-command.test.ts | 67 ++++++++++++++++++ .../test/account-auth-storage.test.ts | 11 ++- 4 files changed, 146 insertions(+), 31 deletions(-) diff --git a/packages/coding-agent/src/cli/account-auth-command.ts b/packages/coding-agent/src/cli/account-auth-command.ts index 62b4e28c2..852d04265 100644 --- a/packages/coding-agent/src/cli/account-auth-command.ts +++ b/packages/coding-agent/src/cli/account-auth-command.ts @@ -2,6 +2,7 @@ import { setTimeout as sleep } from "node:timers/promises"; import { type DiffexAccountAuthStorage, type DiffexAccountSession, + DiffexAccountStorageError, KeyringDiffexAccountAuthStorage, } from "../core/account-auth-storage.ts"; import { openBrowser } from "../utils/open-browser.ts"; @@ -102,13 +103,20 @@ export async function executeAccountAuthCommand( command: AccountAuthCommand, dependencies: AccountAuthCommandDependencies = createDefaultDependencies(), ): Promise { - switch (command.kind) { - case "login": - return login(command, dependencies); - case "status": - return status(command, dependencies); - case "logout": - return logout(command, dependencies); + try { + switch (command.kind) { + case "login": + return await login(command, dependencies); + case "status": + return await status(command, dependencies); + case "logout": + return await logout(command, dependencies); + } + } catch (error) { + if (error instanceof DiffexAccountStorageError) { + throw new AccountAuthCommandError(error.message, error); + } + throw error; } } @@ -128,7 +136,7 @@ function createDefaultDependencies(): AccountAuthCommandDependencies { async function login(command: AccountAuthCommand, dependencies: AccountAuthCommandDependencies): Promise { const started = await postJson(dependencies, command.apiOrigin, "/v1/auth/cli/start", {}); if (!started.response.ok) throw responseError(started, "Unable to start Diffex sign in"); - const authorization = parseDeviceAuthorization(started.body); + const authorization = parseDeviceAuthorization(started.body, command.apiOrigin); const browserUrl = authorization.verificationUriComplete ?? authorization.verificationUri; dependencies.writeLine("Open this URL to sign in:"); @@ -294,12 +302,8 @@ async function postJson( return { response, body: responseBody }; } -function parseDeviceAuthorization(value: unknown): DeviceAuthorization { +function parseDeviceAuthorization(value: unknown, apiOrigin: string): DeviceAuthorization { if (!isRecord(value)) throw invalidServerResponse(); - const verificationUriComplete = - typeof value.verification_uri_complete === "string" && value.verification_uri_complete.length > 0 - ? validateVerificationUrl(value.verification_uri_complete) - : undefined; if ( typeof value.device_code !== "string" || value.device_code.length === 0 || @@ -311,11 +315,22 @@ function parseDeviceAuthorization(value: unknown): DeviceAuthorization { ) { 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: validateVerificationUrl(value.verification_uri), + verificationUri, ...(verificationUriComplete === undefined ? {} : { verificationUriComplete }), expiresIn: Math.min(Math.floor(value.expires_in), 3_600), interval: Math.max(1, Math.floor(value.interval)), @@ -408,12 +423,7 @@ function normalizeApiOrigin(value: string): string { } catch (error) { throw new AccountAuthCommandError("Diffex API URL must be a valid origin", error); } - const localHttp = - url.protocol === "http:" && - (url.hostname === "localhost" || - url.hostname === "127.0.0.1" || - url.hostname === "[::1]" || - url.hostname === "api.diffex.test"); + const localHttp = url.protocol === "http:" && isLocalApiHostname(url.hostname); if ( (url.protocol !== "https:" && !localHttp) || url.username !== "" || @@ -427,17 +437,31 @@ function normalizeApiOrigin(value: string): string { return url.origin; } -function validateVerificationUrl(value: string): string { +function validateVerificationUrl(value: string, apiOrigin: string): string { let url: URL; try { url = new URL(value); } catch (error) { throw invalidServerResponse(error); } - if (url.protocol !== "https:" && url.protocol !== "http:") throw invalidServerResponse(); + 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); diff --git a/packages/coding-agent/src/core/account-auth-storage.ts b/packages/coding-agent/src/core/account-auth-storage.ts index df41f8f67..57bff25bf 100644 --- a/packages/coding-agent/src/core/account-auth-storage.ts +++ b/packages/coding-agent/src/core/account-auth-storage.ts @@ -1,5 +1,3 @@ -import { AsyncEntry } from "@napi-rs/keyring"; - const KEYRING_SERVICE = "ai.diffex.cli"; export interface DiffexAccountSession { @@ -24,7 +22,7 @@ export interface KeyringEntry { deleteCredential(signal?: AbortSignal): Promise; } -export type KeyringEntryFactory = (service: string, account: string) => KeyringEntry; +export type KeyringEntryFactory = (service: string, account: string) => KeyringEntry | Promise; export class DiffexAccountStorageError extends Error { constructor(message: string, cause?: unknown) { @@ -36,13 +34,14 @@ export class DiffexAccountStorageError extends Error { export class KeyringDiffexAccountAuthStorage implements DiffexAccountAuthStorage { readonly #createEntry: KeyringEntryFactory; - constructor(createEntry: KeyringEntryFactory = (service, account) => new AsyncEntry(service, account)) { + constructor(createEntry: KeyringEntryFactory = createNativeKeyringEntry) { this.#createEntry = createEntry; } async read(apiOrigin: string, signal?: AbortSignal): Promise { try { - const stored = await this.#entry(apiOrigin).getPassword(signal); + 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; @@ -54,26 +53,42 @@ export class KeyringDiffexAccountAuthStorage implements DiffexAccountAuthStorage async write(session: DiffexAccountSession, signal?: AbortSignal): Promise { validateSession(session, session.apiOrigin); try { - await this.#entry(session.apiOrigin).setPassword(JSON.stringify(session), signal); + 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 { - await this.#entry(apiOrigin).deleteCredential(signal); + 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); } } - #entry(apiOrigin: string): KeyringEntry { + 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 { diff --git a/packages/coding-agent/test/account-auth-command.test.ts b/packages/coding-agent/test/account-auth-command.test.ts index 99100b64f..42f66f332 100644 --- a/packages/coding-agent/test/account-auth-command.test.ts +++ b/packages/coding-agent/test/account-auth-command.test.ts @@ -118,6 +118,73 @@ describe("Diffex account auth commands", () => { 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 opening a browser", async ({ verificationUri, verificationUriComplete }) => { + const harness = dependencies({ + responses: [ + jsonResponse({ + device_code: "device_secret", + user_code: "ABCD-EFGH", + verification_uri: verificationUri, + ...(verificationUriComplete === undefined ? {} : { verification_uri_complete: verificationUriComplete }), + expires_in: 300, + interval: 1, + }), + ], + }); + + await expect(executeAccountAuthCommand(command("login"), harness.dependencies)).rejects.toThrow( + "invalid response", + ); + expect(harness.dependencies.openBrowser).not.toHaveBeenCalled(); + expect(harness.storage.session).toBeUndefined(); + }); + + it("permits local HTTP verification URLs only with a local API", async () => { + const harness = dependencies({ + 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 localCommand = { + ...command("login"), + apiOrigin: "http://api.diffex.test:4175", + noBrowser: true, + }; + + await expect(executeAccountAuthCommand(localCommand, harness.dependencies)).resolves.toBe(0); + expect(harness.dependencies.openBrowser).not.toHaveBeenCalled(); + expect(harness.storage.session?.apiOrigin).toBe("http://api.diffex.test:4175"); + }); + it("rotates an expiring refresh token before reporting signed-in status", async () => { const storage = new MemoryAccountStorage({ version: 1, diff --git a/packages/coding-agent/test/account-auth-storage.test.ts b/packages/coding-agent/test/account-auth-storage.test.ts index 3876cdacf..f9095ebee 100644 --- a/packages/coding-agent/test/account-auth-storage.test.ts +++ b/packages/coding-agent/test/account-auth-storage.test.ts @@ -35,9 +35,10 @@ function createFakeKeyring(initial?: string) { 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(() => fake.entry); + 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); @@ -52,4 +53,12 @@ describe("Diffex account keyring storage", () => { 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"); + }); }); From 18c1c669de36ff16f531d405be617b5b4c12a5ed Mon Sep 17 00:00:00 2001 From: Jeffrey Tseng Date: Sat, 29 Aug 2026 20:47:47 -0400 Subject: [PATCH 3/3] fix(auth): use slash commands for account auth Remove the standalone account login, status, and logout routes, and integrate secure Diffex device sign-in and sign-out into the existing /login and /logout flows while preserving provider credential management. --- packages/coding-agent/src/cli/auth-command.ts | 15 +- .../diffex-account-auth.ts} | 351 +++++++++--------- .../coding-agent/src/core/slash-commands.ts | 8 +- packages/coding-agent/src/main.ts | 18 - .../interactive/components/oauth-selector.ts | 6 +- .../src/modes/interactive/interactive-mode.ts | 100 ++++- .../test/credential-print.test.ts | 3 + ...nd.test.ts => diffex-account-auth.test.ts} | 125 +++---- .../test/interactive-mode-status.test.ts | 6 + .../coding-agent/test/oauth-selector.test.ts | 68 ++++ 10 files changed, 411 insertions(+), 289 deletions(-) rename packages/coding-agent/src/{cli/account-auth-command.ts => core/diffex-account-auth.ts} (53%) rename packages/coding-agent/test/{account-auth-command.test.ts => diffex-account-auth.test.ts} (59%) diff --git a/packages/coding-agent/src/cli/auth-command.ts b/packages/coding-agent/src/cli/auth-command.ts index 28e55cd00..c1dec0207 100644 --- a/packages/coding-agent/src/cli/auth-command.ts +++ b/packages/coding-agent/src/cli/auth-command.ts @@ -38,16 +38,11 @@ export function isAuthCommandHelp(args: string[]): boolean { export function printAuthCommandHelp(): void { console.log(`Usage: - ${APP_NAME} auth login [--api-url ] [--no-browser] - ${APP_NAME} auth status [--api-url ] [--json] - ${APP_NAME} auth logout [--api-url ] - ${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] -Diffex account commands use secure OS credential storage and default to https://api.diffex.ai. Set DIFFEX_API_URL or use --api-url to select another deployment. - -Provider credential commands require at least one of --provider or --model. Checks refresh expired OAuth credentials by default; --no-refresh prevents this. --credentials emits the provider credential, or includes it in JSON output.`); +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.`); } export function parseAuthCommand(args: string[]): AuthCommand | undefined { @@ -63,7 +58,7 @@ export function parseAuthCommand(args: string[]): AuthCommand | undefined { : undefined; if (!kind) { throw new AuthCommandError( - `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth login", "${APP_NAME} auth status", "${APP_NAME} auth logout", "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, + `Unknown auth command "${args[1] ?? ""}". Use "${APP_NAME} auth print-api-key", "${APP_NAME} auth print-bearer-token", or "${APP_NAME} auth check".`, ); } diff --git a/packages/coding-agent/src/cli/account-auth-command.ts b/packages/coding-agent/src/core/diffex-account-auth.ts similarity index 53% rename from packages/coding-agent/src/cli/account-auth-command.ts rename to packages/coding-agent/src/core/diffex-account-auth.ts index 852d04265..1616c5c24 100644 --- a/packages/coding-agent/src/cli/account-auth-command.ts +++ b/packages/coding-agent/src/core/diffex-account-auth.ts @@ -4,30 +4,46 @@ import { type DiffexAccountSession, DiffexAccountStorageError, KeyringDiffexAccountAuthStorage, -} from "../core/account-auth-storage.ts"; -import { openBrowser } from "../utils/open-browser.ts"; +} 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 type AccountAuthCommandKind = "login" | "status" | "logout"; +export interface DiffexDeviceAuthorizationPrompt { + verificationUri: string; + userCode: string; +} + +export interface DiffexAccountLoginOptions { + signal?: AbortSignal; + onDeviceAuthorization: (prompt: DiffexDeviceAuthorizationPrompt) => void; +} -export interface AccountAuthCommand { - kind: AccountAuthCommandKind; - apiOrigin: string; - noBrowser: boolean; - json: boolean; +export interface DiffexAccountLogoutResult { + hadSession: boolean; + remoteConfirmed: boolean; } -export interface AccountAuthCommandDependencies { +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; - openBrowser: (target: string) => void; - writeLine: (line: string) => void; now: () => number; - sleep: (milliseconds: number) => Promise; + sleep: (milliseconds: number, signal?: AbortSignal) => Promise; +} + +export interface HostedDiffexAccountAuthOptions { + apiOrigin?: string; + env?: Record; + dependencies?: DiffexAccountAuthDependencies; } type DeviceAuthorization = { @@ -52,178 +68,143 @@ type JsonResponse = { body: unknown; }; -export class AccountAuthCommandError extends Error { +export class DiffexAccountAuthError extends Error { constructor(message: string, cause?: unknown) { super(message, cause === undefined ? undefined : { cause }); - this.name = "AccountAuthCommandError"; + this.name = "DiffexAccountAuthError"; } } -export function parseAccountAuthCommand( - args: string[], - env: Record = process.env, -): AccountAuthCommand | undefined { - if (args[0] !== "auth") return undefined; - const kind = args[1]; - if (kind !== "login" && kind !== "status" && kind !== "logout") return undefined; - - let apiOrigin = normalizeApiOrigin(env.DIFFEX_API_URL ?? DEFAULT_API_ORIGIN); - let noBrowser = false; - let json = false; - - for (let index = 2; index < args.length; index++) { - const argument = args[index]; - if (argument === "--api-url") { - const value = args[++index]; - if (!value) throw new AccountAuthCommandError("--api-url requires an origin"); - apiOrigin = normalizeApiOrigin(value); - continue; - } - if (argument?.startsWith("--api-url=")) { - apiOrigin = normalizeApiOrigin(argument.slice("--api-url=".length)); - continue; - } - if (argument === "--no-browser") { - if (kind !== "login") throw new AccountAuthCommandError("--no-browser is only supported by auth login"); - noBrowser = true; - continue; +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); } - if (argument === "--json") { - if (kind !== "status") throw new AccountAuthCommandError("--json is only supported by auth status"); - json = true; - continue; + } + + 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); } - throw new AccountAuthCommandError(`Unknown option for auth ${kind}: ${argument ?? ""}`); } - return { kind, apiOrigin, noBrowser, json }; -} + 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 }; -export async function executeAccountAuthCommand( - command: AccountAuthCommand, - dependencies: AccountAuthCommandDependencies = createDefaultDependencies(), -): Promise { - try { - switch (command.kind) { - case "login": - return await login(command, dependencies); - case "status": - return await status(command, dependencies); - case "logout": - return await logout(command, dependencies); + 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; } - } catch (error) { - if (error instanceof DiffexAccountStorageError) { - throw new AccountAuthCommandError(error.message, error); + + try { + await this.#dependencies.storage.delete(this.apiOrigin); + } catch (error) { + throw normalizeStorageError(error); } - throw error; + return { hadSession: true, remoteConfirmed }; } } -function createDefaultDependencies(): AccountAuthCommandDependencies { +function createDefaultDependencies(): DiffexAccountAuthDependencies { return { fetch: (input, init) => globalThis.fetch(input, init), storage: new KeyringDiffexAccountAuthStorage(), - openBrowser, - writeLine: (line) => console.log(line), now: () => Date.now(), - sleep: async (milliseconds) => { - await sleep(milliseconds); + sleep: async (milliseconds, signal) => { + await sleep(milliseconds, undefined, signal ? { signal } : undefined); }, }; } -async function login(command: AccountAuthCommand, dependencies: AccountAuthCommandDependencies): Promise { - const started = await postJson(dependencies, command.apiOrigin, "/v1/auth/cli/start", {}); - if (!started.response.ok) throw responseError(started, "Unable to start Diffex sign in"); - const authorization = parseDeviceAuthorization(started.body, command.apiOrigin); - const browserUrl = authorization.verificationUriComplete ?? authorization.verificationUri; - - dependencies.writeLine("Open this URL to sign in:"); - dependencies.writeLine(browserUrl); - dependencies.writeLine(`Confirmation code: ${authorization.userCode}`); - if (!command.noBrowser) dependencies.openBrowser(browserUrl); - - const token = await pollForToken(command.apiOrigin, authorization, dependencies); - const session = toStoredSession(command.apiOrigin, token, dependencies.now()); - await dependencies.storage.write(session); - dependencies.writeLine("Signed in to Diffex."); - return 0; -} - -async function status(command: AccountAuthCommand, dependencies: AccountAuthCommandDependencies): Promise { - let session = await dependencies.storage.read(command.apiOrigin); - if (!session) { - writeStatus(command, dependencies, "signed_out"); - return 1; - } - - if (session.expiresAt <= dependencies.now() + REFRESH_WINDOW_MS) { - session = await refreshSession(session, dependencies); - } - - writeStatus(command, dependencies, "signed_in", session); - return 0; -} - -async function logout(command: AccountAuthCommand, dependencies: AccountAuthCommandDependencies): Promise { - const stored = await dependencies.storage.read(command.apiOrigin); - if (!stored) { - dependencies.writeLine("Already signed out."); - return 0; - } - - let remoteFailure: unknown; - try { - const session = - stored.expiresAt <= dependencies.now() + REFRESH_WINDOW_MS - ? await refreshSession(stored, dependencies) - : stored; - const result = await postJson( - dependencies, - command.apiOrigin, - "/v1/auth/logout", - {}, - { authorization: `Bearer ${session.accessToken}` }, - ); - if (!result.response.ok && result.response.status !== 401) { - remoteFailure = responseError(result, "Unable to confirm server logout"); - } - } catch (error) { - remoteFailure = error; - } - - await dependencies.storage.delete(command.apiOrigin); - dependencies.writeLine("Signed out of Diffex."); - if (remoteFailure) { - throw new AccountAuthCommandError( - "Local credentials were removed, but server logout could not be confirmed", - remoteFailure, - ); - } - return 0; -} - async function pollForToken( apiOrigin: string, authorization: DeviceAuthorization, - dependencies: AccountAuthCommandDependencies, + 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(); - await dependencies.sleep(Math.min(intervalMs, remaining)); + 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), - }); - } catch { + 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; } @@ -245,40 +226,48 @@ async function pollForToken( throw responseError(result, "Diffex sign in was not completed"); } - throw new AccountAuthCommandError("The sign-in code expired. Run diffex auth login again."); + throw new DiffexAccountAuthError("The sign-in code expired. Use /login to try again."); } async function refreshSession( session: DiffexAccountSession, - dependencies: AccountAuthCommandDependencies, + dependencies: DiffexAccountAuthDependencies, + signal?: AbortSignal, ): Promise { - const result = await postJson(dependencies, session.apiOrigin, "/v1/auth/token/refresh", { - refresh_token: session.refreshToken, - }); + 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 AccountAuthCommandError("Your Diffex session expired. Run diffex auth login again."); + 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); + await dependencies.storage.write(refreshed, signal); return refreshed; } async function postJson( - dependencies: AccountAuthCommandDependencies, + 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: { @@ -287,10 +276,11 @@ async function postJson( ...extraHeaders, }, body: JSON.stringify(body), - signal: AbortSignal.timeout(REQUEST_TIMEOUT_MS), + signal: signal ? AbortSignal.any([signal, timeoutSignal]) : timeoutSignal, }); } catch (error) { - throw new AccountAuthCommandError("Unable to reach the Diffex authentication service", error); + if (signal?.aborted) throw loginCancelled(error); + throw new DiffexAccountAuthError("Unable to reach the Diffex authentication service", error); } let responseBody: unknown = {}; @@ -377,38 +367,19 @@ function toStoredSession( }; } -function writeStatus( - command: AccountAuthCommand, - dependencies: AccountAuthCommandDependencies, - statusValue: "signed_in" | "signed_out", - session?: DiffexAccountSession, -): void { - if (command.json) { - dependencies.writeLine( - JSON.stringify({ - status: statusValue, - api_url: command.apiOrigin, - ...(session === undefined ? {} : { expires_at: new Date(session.expiresAt).toISOString() }), - }), - ); - return; - } - dependencies.writeLine(statusValue === "signed_in" ? "Signed in to Diffex." : "Not signed in to Diffex."); -} - -function responseError(result: JsonResponse, fallback: string): AccountAuthCommandError { +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. Run diffex auth login again." + ? "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 AccountAuthCommandError(message); + return new DiffexAccountAuthError(message); } function responseErrorCode(value: unknown): string | undefined { @@ -421,7 +392,7 @@ function normalizeApiOrigin(value: string): string { try { url = new URL(value); } catch (error) { - throw new AccountAuthCommandError("Diffex API URL must be a valid origin", error); + throw new DiffexAccountAuthError("Diffex API URL must be a valid origin", error); } const localHttp = url.protocol === "http:" && isLocalApiHostname(url.hostname); if ( @@ -432,7 +403,7 @@ function normalizeApiOrigin(value: string): string { url.search !== "" || url.hash !== "" ) { - throw new AccountAuthCommandError("Diffex API URL must be an HTTPS origin without credentials or a path"); + throw new DiffexAccountAuthError("Diffex API URL must be an HTTPS origin without credentials or a path"); } return url.origin; } @@ -468,8 +439,20 @@ function parseRetryAfter(value: string | null): number | undefined { return Number.isFinite(seconds) && seconds > 0 ? Math.ceil(seconds) : undefined; } -function invalidServerResponse(cause?: unknown): AccountAuthCommandError { - return new AccountAuthCommandError("The Diffex authentication service returned an invalid response", cause); +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 { 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/main.ts b/packages/coding-agent/src/main.ts index 4d561e7dd..897f0b56f 100644 --- a/packages/coding-agent/src/main.ts +++ b/packages/coding-agent/src/main.ts @@ -8,11 +8,6 @@ import { createInterface } from "node:readline"; import { type ImageContent, modelsAreEqual } from "@earendil-works/diffex-ai"; import chalk from "chalk"; -import { - AccountAuthCommandError, - executeAccountAuthCommand, - parseAccountAuthCommand, -} from "./cli/account-auth-command.ts"; import { type Args, type Mode, parseArgs, printHelp } from "./cli/args.ts"; import { type AuthCheckResult, @@ -163,19 +158,6 @@ async function runAuthCommand(args: string[]): Promise { return true; } - try { - const accountCommand = parseAccountAuthCommand(args); - if (accountCommand) { - process.exitCode = await executeAccountAuthCommand(accountCommand); - return true; - } - } catch (error) { - const message = error instanceof AccountAuthCommandError ? error.message : "Diffex account authentication failed"; - console.error(chalk.red(`Error: ${message}`)); - process.exitCode = 1; - return true; - } - let command: AuthCommand | undefined; try { command = parseAuthCommand(args); 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/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/account-auth-command.test.ts b/packages/coding-agent/test/diffex-account-auth.test.ts similarity index 59% rename from packages/coding-agent/test/account-auth-command.test.ts rename to packages/coding-agent/test/diffex-account-auth.test.ts index 42f66f332..36af85a2c 100644 --- a/packages/coding-agent/test/account-auth-command.test.ts +++ b/packages/coding-agent/test/diffex-account-auth.test.ts @@ -1,11 +1,6 @@ import { describe, expect, it, vi } from "vitest"; -import { - type AccountAuthCommand, - type AccountAuthCommandDependencies, - executeAccountAuthCommand, - parseAccountAuthCommand, -} from "../src/cli/account-auth-command.ts"; 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"; @@ -30,57 +25,36 @@ class MemoryAccountStorage implements DiffexAccountAuthStorage { } function jsonResponse(body: unknown, status = 200, headers?: Record): Response { - return new Response(JSON.stringify(body), { + return new Response(status === 204 ? null : JSON.stringify(body), { status, headers: { "content-type": "application/json", ...headers }, }); } -function command(kind: AccountAuthCommand["kind"]): AccountAuthCommand { - return { kind, apiOrigin, noBrowser: false, json: false }; -} - -function dependencies(input: { responses?: Response[]; storage?: MemoryAccountStorage; now?: { value: number } }) { +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 output: string[] = []; - const fetch = vi.fn(async () => { + const fetch = vi.fn(async () => { const response = responses.shift(); if (!response) throw new Error("Unexpected request"); return response; }); - const result: AccountAuthCommandDependencies = { + const dependencies: DiffexAccountAuthDependencies = { fetch, storage, - openBrowser: vi.fn(), - writeLine: (line) => output.push(line), now: () => now.value, sleep: vi.fn(async (milliseconds) => { now.value += milliseconds; }), }; - return { dependencies: result, fetch, now, output, storage }; + const auth = new HostedDiffexAccountAuth({ apiOrigin, dependencies }); + return { auth, dependencies, fetch, now, storage }; } -describe("Diffex account auth commands", () => { - it("parses account commands without changing provider auth commands", () => { - expect( - parseAccountAuthCommand(["auth", "login", "--api-url", "https://api.staging.diffex.ai", "--no-browser"], {}), - ).toEqual({ - kind: "login", - apiOrigin: "https://api.staging.diffex.ai", - noBrowser: true, - json: false, - }); - expect(parseAccountAuthCommand(["auth", "check", "--provider", "anthropic"], {})).toBeUndefined(); - expect(() => parseAccountAuthCommand(["auth", "login", "--api-url", "http://attacker.example"], {})).toThrow( - "HTTPS origin", - ); - }); - - it("completes device auth while keeping device and token secrets out of terminal output", async () => { - const harness = dependencies({ +describe("Diffex account authentication", () => { + it("completes device auth without exposing device or token secrets", async () => { + const harness = createHarness({ responses: [ jsonResponse({ device_code: "device_secret", @@ -101,20 +75,23 @@ describe("Diffex account auth commands", () => { }), ], }); + const prompts: unknown[] = []; - await expect(executeAccountAuthCommand(command("login"), harness.dependencies)).resolves.toBe(0); - expect(harness.dependencies.openBrowser).toHaveBeenCalledWith( - "https://auth.diffex.ai/device?user_code=ABCD-EFGH", - ); + 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", }); - const terminal = harness.output.join("\n"); - expect(terminal).toContain("ABCD-EFGH"); - expect(terminal).not.toMatch(/device_secret|access_secret|refresh_secret/u); + expect(JSON.stringify(prompts)).not.toMatch(/device_secret|access_secret|refresh_secret/u); expect(harness.fetch).toHaveBeenCalledTimes(4); }); @@ -134,8 +111,8 @@ describe("Diffex account auth commands", () => { verificationUri: "https://auth.diffex.ai/device", verificationUriComplete: "https://attacker.example/device?user_code=ABCD-EFGH", }, - ])("rejects $name before opening a browser", async ({ verificationUri, verificationUriComplete }) => { - const harness = dependencies({ + ])("rejects $name before notifying the UI", async ({ verificationUri, verificationUriComplete }) => { + const harness = createHarness({ responses: [ jsonResponse({ device_code: "device_secret", @@ -147,16 +124,15 @@ describe("Diffex account auth commands", () => { }), ], }); + const onDeviceAuthorization = vi.fn(); - await expect(executeAccountAuthCommand(command("login"), harness.dependencies)).rejects.toThrow( - "invalid response", - ); - expect(harness.dependencies.openBrowser).not.toHaveBeenCalled(); + 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 = dependencies({ + const harness = createHarness({ responses: [ jsonResponse({ device_code: "device_secret", @@ -174,18 +150,32 @@ describe("Diffex account auth commands", () => { }), ], }); - const localCommand = { - ...command("login"), + const localAuth = new HostedDiffexAccountAuth({ apiOrigin: "http://api.diffex.test:4175", - noBrowser: true, - }; + dependencies: harness.dependencies, + }); - await expect(executeAccountAuthCommand(localCommand, harness.dependencies)).resolves.toBe(0); - expect(harness.dependencies.openBrowser).not.toHaveBeenCalled(); + await localAuth.login({ onDeviceAuthorization: vi.fn() }); expect(harness.storage.session?.apiOrigin).toBe("http://api.diffex.test:4175"); }); - it("rotates an expiring refresh token before reporting signed-in status", async () => { + 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, @@ -194,7 +184,7 @@ describe("Diffex account auth commands", () => { tokenType: "Bearer", expiresAt: 10, }); - const harness = dependencies({ + const harness = createHarness({ storage, responses: [ jsonResponse({ @@ -203,18 +193,16 @@ describe("Diffex account auth commands", () => { token_type: "Bearer", expires_in: 1_800, }), + jsonResponse({}, 204), ], }); - const statusCommand = { ...command("status"), json: true }; - await expect(executeAccountAuthCommand(statusCommand, harness.dependencies)).resolves.toBe(0); - expect(storage.session?.refreshToken).toBe("rotated_refresh"); - expect(harness.output).toHaveLength(1); - expect(harness.output[0]).toContain('"status":"signed_in"'); - expect(harness.output[0]).not.toMatch(/old_|rotated_/u); + await expect(harness.auth.logout()).resolves.toEqual({ hadSession: true, remoteConfirmed: true }); + expect(storage.session).toBeUndefined(); + expect(harness.fetch).toHaveBeenCalledTimes(2); }); - it("removes local credentials even when remote logout is unavailable", async () => { + it("removes local credentials when remote logout is unavailable", async () => { const storage = new MemoryAccountStorage({ version: 1, apiOrigin, @@ -223,15 +211,12 @@ describe("Diffex account auth commands", () => { tokenType: "Bearer", expiresAt: 3_600_000, }); - const harness = dependencies({ + const harness = createHarness({ storage, responses: [jsonResponse({ error: { code: "temporarily_unavailable" } }, 503)], }); - await expect(executeAccountAuthCommand(command("logout"), harness.dependencies)).rejects.toThrow( - "Local credentials were removed", - ); + await expect(harness.auth.logout()).resolves.toEqual({ hadSession: true, remoteConfirmed: false }); expect(storage.session).toBeUndefined(); - expect(harness.output).toEqual(["Signed out of Diffex."]); }); }); 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",