* Add `executor login` device-flow auth for hosted accounts Let users sign the CLI into a hosted Executor account with the OAuth 2.0 Device Authorization Grant (RFC 8628) instead of hand-pasting an API key. - CLI: `executor login` / `logout` / `whoami`. `login` discovers the provider device endpoints from the server (`GET /api/auth/cli-login`), runs RFC 8628 (request code, open the browser, poll), and stores the resulting tokens in the server profile. The credential is refreshed before expiry on subsequent calls. - SDK + CLI profile: new `oauth` auth kind (access + refresh token + expiry) alongside basic/bearer; sent as `Authorization: Bearer`. - Cloud: the `/api/*` plane now accepts a provider access-token JWT as a bearer (in addition to API keys and the session cookie), reusing the existing MCP JWT verifier; plus a public `/api/auth/cli-login` discovery endpoint. - Fix: a stored credential (oauth/bearer) no longer triggers local daemon auto-start for an http://localhost server. - e2e: a cloud scenario drives the real CLI through the device flow and asserts an authenticated `/api/*` call, recorded for the viewer. The flow is provider-neutral; self-host (Better Auth device authorization) is a follow-up. Driving the cloud e2e depends on a device-flow capable build of the service emulator (developed in the fork; to be published). * CLI login: name profiles by account, not hostname Two accounts on the same host previously collided (the profile name was derived from the hostname, so a second login overwrote the first). Name the profile by the authenticated account instead: capture the email + org from the device-token response and pick the name from that, finding-or- creating so a re-login updates the same account's profile while a different account gets its own. Mirrors how opencode keys accounts (by email), and fixes the same-host clobber. Adds an e2e proving two same-host accounts yield two retained profiles. * e2e: record the browser approval; don't gate authed connections on liveness probe - The device-login e2e now drives the real verification page in Playwright (recorded to session.mp4) alongside the terminal cast, instead of approving headlessly — the recording shows the actual browser hop. - Resolving an authenticated remote connection (oauth/bearer/basic) no longer requires the unauthenticated /api/health liveness probe to pass; that probe only gates the local auto-start decision and isn't exposed by hosted servers. The real API call surfaces any connectivity/auth error. - Update the JWT auth test's org-store stub for the new org slug field. * self-host: executor login device flow (Better Auth) Brings `executor login` to the self-host app, mirroring cloud: - Add Better Auth's deviceAuthorization() plugin; the issued session token is accepted as a Bearer on /api/* via the existing bearer() plugin (no identity-layer change). - Serve GET /api/auth/cli-login discovery (provider better-auth, the /api/auth/device/{code,token} endpoints, clientId executor-cli) and a /device verification page that binds + approves the pending code with the session cookie. Dev vite forwards /device to the Effect handler. - CLI: request encoding is discovery-driven (requestFormat form|json) since Better Auth's device endpoints only accept JSON while WorkOS needs form. - e2e: a self-host scenario drives the real binary + browser approval and an authenticated /api call, recorded (terminal.cast + session.mp4). * e2e: anchor the terminal recording on the focus timeline The cli.session surface now writes a terminal anchor + focus to timeline.json (symmetric with the browser surface), so a combined cli+browser scenario produces the synced SessionPlayer view (terminal/browser cuts + the synthesized URL bar) instead of falling back to separate clips. The device-login scenarios cut focus back to the terminal after the browser approval so the final 'Logged in' + authenticated /api call land on the terminal. * address review: remove em-dashes, scope E2E_FILM, pin verificationUri - Replace em-dash characters in the comments this PR added (repo style rule). - Restore E2E_FILM after the device-login scenarios via a scope finalizer so the filmed-mode flag doesn't leak into later serial e2e files. - Pin Better Auth deviceAuthorization verificationUri to /device (its default, made explicit). * self-host device page: a console SPA route, not raw HTML Replace the server-rendered /device HTML with a real console route (web/device.tsx) rendered chromeless inside the auth gate, the same static-import + pathname-branch convention as the MCP consent screen. It uses the shared Button + design-system styles and drives Better Auth's device endpoints (claim, then approve/deny) with plain fetch. Drops the bespoke HTML route from app.ts and the /device dev-forward from vite.config.ts (the SPA now serves /device in dev and prod).
Showing the changes to apps/cli/src (4 of 17 files). Show all
Unified · Split
| @@ -0,0 +1,328 @@ | ||
| 1 | +// --------------------------------------------------------------------------- | |
| 2 | +// CLI device-login (OAuth 2.0 Device Authorization Grant, RFC 8628). | |
| 3 | +// | |
| 4 | +// `executor login` against a hosted server: | |
| 5 | +// 1. discover the provider device endpoints + public client id from the | |
| 6 | +// server (`GET /api/auth/cli-login`), | |
| 7 | +// 2. request a device code, show the user_code + open the verification URL, | |
| 8 | +// 3. poll the token endpoint until the user approves in the browser, | |
| 9 | +// 4. hand the access + refresh tokens back to be stored in the profile. | |
| 10 | +// | |
| 11 | +// The flow is provider-neutral: the server advertises WorkOS (cloud) or Better | |
| 12 | +// Auth (self-host) endpoints, and both speak the same RFC 8628 wire shape, so | |
| 13 | +// this module never branches on provider. Tokens are sent to the `/api/*` | |
| 14 | +// plane as `Authorization: Bearer <access_token>`. | |
| 15 | +// --------------------------------------------------------------------------- | |
| 16 | + | |
| 17 | +import { spawn } from "node:child_process"; | |
| 18 | + | |
| 19 | +export interface CliLoginDiscovery { | |
| 20 | + readonly provider: string; | |
| 21 | + readonly deviceAuthorizationEndpoint: string; | |
| 22 | + readonly tokenEndpoint: string; | |
| 23 | + readonly clientId: string; | |
| 24 | + readonly scope?: string; | |
| 25 | + /** | |
| 26 | + * How to encode the device-authorization + token requests. RFC 8628 mandates | |
| 27 | + * `form` (WorkOS), but some providers' endpoints only accept JSON (Better | |
| 28 | + * Auth). The server tells us via discovery; defaults to `form`. | |
| 29 | + */ | |
| 30 | + readonly requestFormat: "form" | "json"; | |
| 31 | +} | |
| 32 | + | |
| 33 | +export interface DeviceCodeGrant { | |
| 34 | + readonly deviceCode: string; | |
| 35 | + readonly userCode: string; | |
| 36 | + readonly verificationUri: string; | |
| 37 | + readonly verificationUriComplete?: string; | |
| 38 | + readonly expiresInSeconds: number; | |
| 39 | + readonly intervalSeconds: number; | |
| 40 | +} | |
| 41 | + | |
| 42 | +export interface DeviceTokens { | |
| 43 | + readonly accessToken: string; | |
| 44 | + readonly refreshToken?: string; | |
| 45 | + /** Epoch seconds the access token expires, when known. */ | |
| 46 | + readonly expiresAt?: number; | |
| 47 | + /** The signed-in account's email, if the token response carries a user. */ | |
| 48 | + readonly email?: string; | |
| 49 | + /** The bound organization id, from the response or the token's org_id claim. */ | |
| 50 | + readonly organizationId?: string; | |
| 51 | +} | |
| 52 | + | |
| 53 | +const DEVICE_CODE_GRANT_TYPE = "urn:ietf:params:oauth:grant-type:device_code"; | |
| 54 | +const DEFAULT_INTERVAL_SECONDS = 5; | |
| 55 | + | |
| 56 | +export class DeviceLoginError extends Error { | |
| 57 | + constructor(message: string) { | |
| 58 | + super(message); | |
| 59 | + this.name = "DeviceLoginError"; | |
| 60 | + } | |
| 61 | +} | |
| 62 | + | |
| 63 | +const cliLoginUrl = (origin: string): string => `${origin.replace(/\/+$/, "")}/api/auth/cli-login`; | |
| 64 | + | |
| 65 | +const asString = (value: unknown): string | undefined => | |
| 66 | + typeof value === "string" && value.length > 0 ? value : undefined; | |
| 67 | + | |
| 68 | +const asNumber = (value: unknown): number | undefined => | |
| 69 | + typeof value === "number" && Number.isFinite(value) ? value : undefined; | |
| 70 | + | |
| 71 | +/** Pull `user.email` out of a token response (WorkOS returns the user inline). */ | |
| 72 | +const readUserEmail = (body: Record<string, unknown>): string | undefined => { | |
| 73 | + const user = body.user; | |
| 74 | + if (user && typeof user === "object" && "email" in user) { | |
| 75 | + return asString((user as { email?: unknown }).email); | |
| 76 | + } | |
| 77 | + return undefined; | |
| 78 | +}; | |
| 79 | + | |
| 80 | +/** | |
| 81 | + * Decode a JWT access token's claims WITHOUT verifying it. The CLI only uses | |
| 82 | + * this for display (`whoami`, the post-login summary) and to read `exp`, the | |
| 83 | + * server is the only thing that verifies the token's signature. | |
| 84 | + */ | |
| 85 | +export const decodeAccessTokenClaims = ( | |
| 86 | + accessToken: string, | |
| 87 | +): Record<string, unknown> | undefined => { | |
| 88 | + const segments = accessToken.split("."); | |
| 89 | + if (segments.length !== 3) return undefined; | |
| 90 | + const payloadSegment = segments[1]; | |
| 91 | + if (!payloadSegment) return undefined; | |
| 92 | + try { | |
| 93 | + const json = Buffer.from(payloadSegment, "base64url").toString("utf8"); | |
| 94 | + const claims = JSON.parse(json) as unknown; | |
| 95 | + return claims && typeof claims === "object" ? (claims as Record<string, unknown>) : undefined; | |
| 96 | + } catch { | |
| 97 | + return undefined; | |
| 98 | + } | |
| 99 | +}; | |
| 100 | + | |
| 101 | +/** Read `exp` (epoch seconds) out of a JWT access token without verifying it. */ | |
| 102 | +export const accessTokenExpiry = (accessToken: string): number | undefined => | |
| 103 | + asNumber(decodeAccessTokenClaims(accessToken)?.exp); | |
| 104 | + | |
| 105 | +const deriveExpiresAt = (tokens: { | |
| 106 | + accessToken: string; | |
| 107 | + expiresIn?: number; | |
| 108 | +}): number | undefined => { | |
| 109 | + if (tokens.expiresIn !== undefined) { | |
| 110 | + return Math.floor(Date.now() / 1000) + tokens.expiresIn; | |
| 111 | + } | |
| 112 | + return accessTokenExpiry(tokens.accessToken); | |
| 113 | +}; | |
| 114 | + | |
| 115 | +const formBody = (fields: Record<string, string | undefined>): string => { | |
| 116 | + const params = new URLSearchParams(); | |
| 117 | + for (const [key, value] of Object.entries(fields)) { | |
| 118 | + if (value !== undefined) params.set(key, value); | |
| 119 | + } | |
| 120 | + return params.toString(); | |
| 121 | +}; | |
| 122 | + | |
| 123 | +const definedFields = (fields: Record<string, string | undefined>): Record<string, string> => | |
| 124 | + Object.fromEntries(Object.entries(fields).filter(([, v]) => v !== undefined)) as Record< | |
| 125 | + string, | |
| 126 | + string | |
| 127 | + >; | |
| 128 | + | |
| 129 | +const post = async ( | |
| 130 | + url: string, | |
| 131 | + fields: Record<string, string | undefined>, | |
| 132 | + format: "form" | "json", | |
| 133 | +) => | |
| 134 | + fetch(url, { | |
| 135 | + method: "POST", | |
| 136 | + headers: { | |
| 137 | + "content-type": format === "json" ? "application/json" : "application/x-www-form-urlencoded", | |
| 138 | + accept: "application/json", | |
| 139 | + }, | |
| 140 | + body: format === "json" ? JSON.stringify(definedFields(fields)) : formBody(fields), | |
| 141 | + }); | |
| 142 | + | |
| 143 | +const readJson = async (response: Response): Promise<Record<string, unknown>> => { | |
| 144 | + const text = await response.text(); | |
| 145 | + if (!text) return {}; | |
| 146 | + try { | |
| 147 | + return JSON.parse(text) as Record<string, unknown>; | |
| 148 | + } catch { | |
| 149 | + return { error: "invalid_response", error_description: text.slice(0, 200) }; | |
| 150 | + } | |
| 151 | +}; | |
| 152 | + | |
| 153 | +export const discoverCliLogin = async (origin: string): Promise<CliLoginDiscovery> => { | |
| 154 | + let response: Response; | |
| 155 | + try { | |
| 156 | + response = await fetch(cliLoginUrl(origin), { headers: { accept: "application/json" } }); | |
| 157 | + } catch (cause) { | |
| 158 | + throw new DeviceLoginError( | |
| 159 | + `Could not reach ${origin} to start login: ${cause instanceof Error ? cause.message : String(cause)}`, | |
| 160 | + ); | |
| 161 | + } | |
| 162 | + if (!response.ok) { | |
| 163 | + throw new DeviceLoginError( | |
| 164 | + `${origin} does not support CLI login (GET /api/auth/cli-login returned ${response.status}). ` + | |
| 165 | + "It may be an older server, or a local/unauthenticated server that needs no login.", | |
| 166 | + ); | |
| 167 | + } | |
| 168 | + const body = await readJson(response); | |
| 169 | + const deviceAuthorizationEndpoint = asString(body.deviceAuthorizationEndpoint); | |
| 170 | + const tokenEndpoint = asString(body.tokenEndpoint); | |
| 171 | + const clientId = asString(body.clientId); | |
| 172 | + if (!deviceAuthorizationEndpoint || !tokenEndpoint || !clientId) { | |
| 173 | + throw new DeviceLoginError(`${origin} returned an incomplete CLI-login configuration.`); | |
| 174 | + } | |
| 175 | + return { | |
| 176 | + provider: asString(body.provider) ?? "unknown", | |
| 177 | + deviceAuthorizationEndpoint, | |
| 178 | + tokenEndpoint, | |
| 179 | + clientId, | |
| 180 | + scope: asString(body.scope), | |
| 181 | + requestFormat: asString(body.requestFormat) === "json" ? "json" : "form", | |
| 182 | + }; | |
| 183 | +}; | |
| 184 | + | |
| 185 | +export const requestDeviceCode = async (discovery: CliLoginDiscovery): Promise<DeviceCodeGrant> => { | |
| 186 | + const response = await post( | |
| 187 | + discovery.deviceAuthorizationEndpoint, | |
| 188 | + { client_id: discovery.clientId, scope: discovery.scope }, | |
| 189 | + discovery.requestFormat, | |
| 190 | + ); | |
| 191 | + const body = await readJson(response); | |
| 192 | + if (!response.ok) { | |
| 193 | + throw new DeviceLoginError( | |
| 194 | + `Device authorization failed (${response.status}): ${asString(body.error_description) ?? asString(body.error) ?? "unknown error"}`, | |
| 195 | + ); | |
| 196 | + } | |
| 197 | + const deviceCode = asString(body.device_code); | |
| 198 | + const userCode = asString(body.user_code); | |
| 199 | + const verificationUri = asString(body.verification_uri); | |
| 200 | + if (!deviceCode || !userCode || !verificationUri) { | |
| 201 | + throw new DeviceLoginError("Device authorization response was missing required fields."); | |
| 202 | + } | |
| 203 | + return { | |
| 204 | + deviceCode, | |
| 205 | + userCode, | |
| 206 | + verificationUri, | |
| 207 | + verificationUriComplete: asString(body.verification_uri_complete), | |
| 208 | + expiresInSeconds: asNumber(body.expires_in) ?? 300, | |
| 209 | + intervalSeconds: asNumber(body.interval) ?? DEFAULT_INTERVAL_SECONDS, | |
| 210 | + }; | |
| 211 | +}; | |
| 212 | + | |
| 213 | +const sleep = (ms: number): Promise<void> => new Promise((resolve) => setTimeout(resolve, ms)); | |
| 214 | + | |
| 215 | +/** | |
| 216 | + * Poll the token endpoint until the user approves, honoring the RFC 8628 | |
| 217 | + * pacing rules (`authorization_pending` keeps waiting, `slow_down` backs off, | |
| 218 | + * `access_denied` / `expired_token` are terminal). | |
| 219 | + */ | |
| 220 | +export const pollForDeviceTokens = async ( | |
| 221 | + discovery: CliLoginDiscovery, | |
| 222 | + grant: DeviceCodeGrant, | |
| 223 | + options: { readonly now?: () => number } = {}, | |
| 224 | +): Promise<DeviceTokens> => { | |
| 225 | + const now = options.now ?? (() => Date.now()); | |
| 226 | + const deadline = now() + grant.expiresInSeconds * 1000; | |
| 227 | + let intervalMs = Math.max(1, grant.intervalSeconds) * 1000; | |
| 228 | + | |
| 229 | + for (;;) { | |
| 230 | + if (now() >= deadline) { | |
| 231 | + throw new DeviceLoginError("Login timed out before it was approved."); | |
| 232 | + } | |
| 233 | + await sleep(intervalMs); | |
| 234 | + | |
| 235 | + const response = await post( | |
| 236 | + discovery.tokenEndpoint, | |
| 237 | + { | |
| 238 | + grant_type: DEVICE_CODE_GRANT_TYPE, | |
| 239 | + device_code: grant.deviceCode, | |
| 240 | + client_id: discovery.clientId, | |
| 241 | + }, | |
| 242 | + discovery.requestFormat, | |
| 243 | + ); | |
| 244 | + const body = await readJson(response); | |
| 245 | + | |
| 246 | + if (response.ok) { | |
| 247 | + const accessToken = asString(body.access_token); | |
| 248 | + if (!accessToken) { | |
| 249 | + throw new DeviceLoginError("Token response was missing an access token."); | |
| 250 | + } | |
| 251 | + const claims = decodeAccessTokenClaims(accessToken); | |
| 252 | + return { | |
| 253 | + accessToken, | |
| 254 | + refreshToken: asString(body.refresh_token), | |
| 255 | + expiresAt: deriveExpiresAt({ accessToken, expiresIn: asNumber(body.expires_in) }), | |
| 256 | + email: | |
| 257 | + readUserEmail(body) ?? (typeof claims?.email === "string" ? claims.email : undefined), | |
| 258 | + organizationId: | |
| 259 | + asString(body.organization_id) ?? | |
| 260 | + (typeof claims?.org_id === "string" ? claims.org_id : undefined), | |
| 261 | + }; | |
| 262 | + } | |
| 263 | + | |
| 264 | + const error = asString(body.error); | |
| 265 | + if (error === "authorization_pending") continue; | |
| 266 | + if (error === "slow_down") { | |
| 267 | + intervalMs += 5000; | |
| 268 | + continue; | |
| 269 | + } | |
| 270 | + if (error === "access_denied") { | |
| 271 | + throw new DeviceLoginError("Login was denied."); | |
| 272 | + } | |
| 273 | + if (error === "expired_token") { | |
| 274 | + throw new DeviceLoginError("The login request expired before it was approved."); | |
| 275 | + } | |
| 276 | + throw new DeviceLoginError( | |
| 277 | + `Login failed: ${asString(body.error_description) ?? error ?? `HTTP ${response.status}`}`, | |
| 278 | + ); | |
| 279 | + } | |
| 280 | +}; | |
| 281 | + | |
| 282 | +/** Exchange a refresh token for a fresh access token (silent re-auth). Only | |
| 283 | + * providers that issue refresh tokens reach here (WorkOS, which is form-encoded | |
| 284 | + * per RFC 8628); Better Auth's device flow issues no refresh token. */ | |
| 285 | +export const refreshDeviceTokens = async (input: { | |
| 286 | + readonly tokenEndpoint: string; | |
| 287 | + readonly clientId: string; | |
| 288 | + readonly refreshToken: string; | |
| 289 | +}): Promise<DeviceTokens> => { | |
| 290 | + const response = await post( | |
| 291 | + input.tokenEndpoint, | |
| 292 | + { | |
| 293 | + grant_type: "refresh_token", | |
| 294 | + refresh_token: input.refreshToken, | |
| 295 | + client_id: input.clientId, | |
| 296 | + }, | |
| 297 | + "form", | |
| 298 | + ); | |
| 299 | + const body = await readJson(response); | |
| 300 | + if (!response.ok) { | |
| 301 | + throw new DeviceLoginError( | |
| 302 | + `Token refresh failed: ${asString(body.error_description) ?? asString(body.error) ?? `HTTP ${response.status}`}`, | |
| 303 | + ); | |
| 304 | + } | |
| 305 | + const accessToken = asString(body.access_token); | |
| 306 | + if (!accessToken) throw new DeviceLoginError("Refresh response was missing an access token."); | |
| 307 | + return { | |
| 308 | + accessToken, | |
| 309 | + refreshToken: asString(body.refresh_token) ?? input.refreshToken, | |
| 310 | + expiresAt: deriveExpiresAt({ accessToken, expiresIn: asNumber(body.expires_in) }), | |
| 311 | + }; | |
| 312 | +}; | |
| 313 | + | |
| 314 | +/** Best-effort open the verification URL in the user's default browser. */ | |
| 315 | +export const openBrowser = (url: string): void => { | |
| 316 | + const platform = process.platform; | |
| 317 | + const command = platform === "darwin" ? "open" : platform === "win32" ? "cmd" : "xdg-open"; | |
| 318 | + const args = platform === "win32" ? ["/c", "start", "", url] : [url]; | |
| 319 | + try { | |
| 320 | + const child = spawn(command, args, { stdio: "ignore", detached: true }); | |
| 321 | + // Swallow async spawn failures (e.g. the opener binary is missing), the | |
| 322 | + // URL is printed too, so the user can open it by hand. | |
| 323 | + (child as { on?: (event: "error", listener: () => void) => void }).on?.("error", () => {}); | |
| 324 | + child.unref(); | |
| 325 | + } catch { | |
| 326 | + // Ignore, the URL is also printed for manual opening. | |
| 327 | + } | |
| 328 | +}; | |
| @@ -69,11 +69,20 @@ import { ExecutorApi } from "@executor-js/api"; · 68 unmodified lines | ||
| 69 | 69 | import { |
| 70 | 70 | getExecutorServerAuthorizationHeader, |
| 71 | 71 | normalizeExecutorServerConnection, |
| 72 | + normalizeExecutorServerOrigin, | |
| 72 | 73 | type ExecutorLocalServerKind, |
| 73 | 74 | type ExecutorLocalServerManifest, |
| 74 | 75 | type ExecutorServerConnection, |
| 75 | 76 | type ExecutorServerConnectionInput, |
| 76 | 77 | } from "@executor-js/sdk/shared"; |
| 78 | +import { | |
| 79 | + decodeAccessTokenClaims, | |
| 80 | + discoverCliLogin, | |
| 81 | + openBrowser, | |
| 82 | + pollForDeviceTokens, | |
| 83 | + refreshDeviceTokens, | |
| 84 | + requestDeviceCode, | |
| 85 | +} from "./device-login"; | |
| 77 | 86 | import { |
| 78 | 87 | startServer, |
| 79 | 88 | runMcpStdioServer, |
| @@ -138,6 +147,7 @@ import { · 58 unmodified lines | ||
| 138 | 147 | setDefaultCliServerConnectionProfile, |
| 139 | 148 | upsertCliServerConnectionProfile, |
| 140 | 149 | validateCliServerConnectionProfileName, |
| 150 | + type CliServerConnectionStore, | |
| 141 | 151 | } from "./server-profile"; |
| 142 | 152 | import { |
| 143 | 153 | buildResumeContentTemplate, |
| @@ -587,6 +597,59 @@ const resolveRequestedExecutorServerConnection = ( · 443 unmodified lines | ||
| 587 | 597 | }; |
| 588 | 598 | }); |
| 589 | 599 | |
| 600 | +// Refresh an `oauth` (device-login) credential a minute before it expires, so | |
| 601 | +// `executor call` against a hosted server keeps working long after the browser | |
| 602 | +// login. The refreshed tokens are written back to the originating profile. | |
| 603 | +const OAUTH_REFRESH_SKEW_SECONDS = 60; | |
| 604 | + | |
| 605 | +const profileNameFromKey = (key: string): string | null => | |
| 606 | + key.startsWith("profile:") ? key.slice("profile:".length) : null; | |
| 607 | + | |
| 608 | +const refreshOAuthConnection = ( | |
| 609 | + connection: ExecutorServerConnection, | |
| 610 | +): Effect.Effect<ExecutorServerConnection, never, FileSystem.FileSystem | PlatformPath.Path> => | |
| 611 | + Effect.gen(function* () { | |
| 612 | + const auth = connection.auth; | |
| 613 | + if (!auth || auth.kind !== "oauth") return connection; | |
| 614 | + const now = Math.floor(Date.now() / 1000); | |
| 615 | + if (auth.expiresAt && auth.expiresAt - now > OAUTH_REFRESH_SKEW_SECONDS) return connection; | |
| 616 | + // Destructure so the narrowed string types survive into the deferred | |
| 617 | + // `tryPromise` callback (where TS would otherwise re-widen the fields). | |
| 618 | + const { refreshToken, tokenEndpoint, clientId } = auth; | |
| 619 | + if (!refreshToken || !tokenEndpoint || !clientId) return connection; | |
| 620 | + | |
| 621 | + const refreshed = yield* Effect.tryPromise({ | |
| 622 | + try: () => refreshDeviceTokens({ tokenEndpoint, clientId, refreshToken }), | |
| 623 | + catch: toError, | |
| 624 | + // On a failed refresh, keep the existing token and let the eventual 401 | |
| 625 | + // surface, better than blocking the command on a transient hiccup. | |
| 626 | + }).pipe(Effect.option); | |
| 627 | + if (Option.isNone(refreshed)) return connection; | |
| 628 | + | |
| 629 | + const next = refreshed.value; | |
| 630 | + const nextConnection = normalizeExecutorServerConnection({ | |
| 631 | + ...connection, | |
| 632 | + auth: { | |
| 633 | + kind: "oauth", | |
| 634 | + accessToken: next.accessToken, | |
| 635 | + refreshToken: next.refreshToken ?? refreshToken, | |
| 636 | + ...(next.expiresAt ? { expiresAt: next.expiresAt } : {}), | |
| 637 | + tokenEndpoint, | |
| 638 | + clientId, | |
| 639 | + }, | |
| 640 | + }); | |
| 641 | + | |
| 642 | + const profileName = profileNameFromKey(connection.key); | |
| 643 | + if (profileName) { | |
| 644 | + yield* upsertCliServerConnectionProfile({ | |
| 645 | + name: profileName, | |
| 646 | + connection: nextConnection, | |
| 647 | + makeDefault: false, | |
| 648 | + }).pipe(Effect.ignore); | |
| 649 | + } | |
| 650 | + return nextConnection; | |
| 651 | + }); | |
| 652 | + | |
| 590 | 653 | const resolveExecutorServerConnection = ( |
| 591 | 654 | target: ServerTarget, |
| 592 | 655 | ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => |
| @@ -612,10 +675,16 @@ const resolveExecutorServerConnection = ( · 19 unmodified lines | ||
| 612 | 675 | ); |
| 613 | 676 | } |
| 614 | 677 | |
| 615 | − const requested = decision.connection; | |
| 678 | + const requested = yield* refreshOAuthConnection(decision.connection); | |
| 616 | 679 | if (decision.kind === "use-active") return requested; |
| 617 | 680 | |
| 618 | 681 | if (!canAutoStartCliServerConnection(requested)) { |
| 682 | + // An authenticated remote connection (oauth device-login, bearer key, or | |
| 683 | + // basic password): use it directly. The /api/health liveness probe is only | |
| 684 | + // a gate for the local auto-start decision and isn't necessarily exposed | |
| 685 | + // by hosted servers, the real API call surfaces any connectivity/auth | |
| 686 | + // error with proper context. | |
| 687 | + if (requested.auth) return requested; | |
| 619 | 688 | if (yield* isServerReachable(requested.origin)) { |
| 620 | 689 | return requested; |
| 621 | 690 | } |
| @@ -1995,6 +2064,254 @@ const serverCommand = Command.make("server").pipe( · 1373 unmodified lines | ||
| 1995 | 2064 | Command.withDescription("Manage named Executor server profiles"), |
| 1996 | 2065 | ); |
| 1997 | 2066 | |
| 2067 | +const loginHostLabel = (origin: string): string => { | |
| 2068 | + try { | |
| 2069 | + const host = new URL(origin).hostname; | |
| 2070 | + const first = host.split(".")[0]; | |
| 2071 | + return first && first.length > 0 ? first : host; | |
| 2072 | + } catch { | |
| 2073 | + return "server"; | |
| 2074 | + } | |
| 2075 | +}; | |
| 2076 | + | |
| 2077 | +const sanitizeProfileName = (raw: string): string => { | |
| 2078 | + const cleaned = raw | |
| 2079 | + .trim() | |
| 2080 | + .replace(/[^A-Za-z0-9_.-]+/g, "-") | |
| 2081 | + .replace(/^-+|-+$/g, ""); | |
| 2082 | + return cleaned.length > 0 ? cleaned : "server"; | |
| 2083 | +}; | |
| 2084 | + | |
| 2085 | +// The (origin, user, org) a stored oauth profile authenticates as, lets us | |
| 2086 | +// recognize a re-login to the SAME account (update in place) versus a | |
| 2087 | +// different account on the same host (needs its own profile). | |
| 2088 | +const oauthAccountIdentity = (connection: ExecutorServerConnection): string | null => { | |
| 2089 | + const auth = connection.auth; | |
| 2090 | + if (!auth || auth.kind !== "oauth") return null; | |
| 2091 | + const claims = decodeAccessTokenClaims(auth.accessToken); | |
| 2092 | + const sub = typeof claims?.sub === "string" ? claims.sub : undefined; | |
| 2093 | + const org = typeof claims?.org_id === "string" ? claims.org_id : undefined; | |
| 2094 | + return sub && org ? `${connection.origin}|${sub}|${org}` : null; | |
| 2095 | +}; | |
| 2096 | + | |
| 2097 | +// Name a login's profile by the ACCOUNT it authenticates (email, falling back | |
| 2098 | +// to user id), not the hostname, so two accounts on the same server get | |
| 2099 | +// distinct profiles instead of clobbering each other (the way opencode keys | |
| 2100 | +// accounts by email/url). A re-login to the same account reuses its profile. | |
| 2101 | +const chooseLoginProfileName = ( | |
| 2102 | + store: CliServerConnectionStore, | |
| 2103 | + account: { | |
| 2104 | + readonly origin: string; | |
| 2105 | + readonly sub?: string; | |
| 2106 | + readonly org?: string; | |
| 2107 | + readonly email?: string; | |
| 2108 | + }, | |
| 2109 | +): string => { | |
| 2110 | + const identity = | |
| 2111 | + account.sub && account.org ? `${account.origin}|${account.sub}|${account.org}` : null; | |
| 2112 | + if (identity) { | |
| 2113 | + const existing = store.profiles.find((p) => oauthAccountIdentity(p.connection) === identity); | |
| 2114 | + if (existing) return existing.name; | |
| 2115 | + } | |
| 2116 | + const base = sanitizeProfileName(account.email ?? account.sub ?? loginHostLabel(account.origin)); | |
| 2117 | + if (!store.profiles.some((p) => p.name === base)) return base; | |
| 2118 | + for (let suffix = 2; ; suffix += 1) { | |
| 2119 | + const candidate = `${base}-${suffix}`; | |
| 2120 | + if (!store.profiles.some((p) => p.name === candidate)) return candidate; | |
| 2121 | + } | |
| 2122 | +}; | |
| 2123 | + | |
| 2124 | +// Resolve which server a login/logout targets: an existing profile (--server | |
| 2125 | +// or the default) or a bare origin (--base-url). The profile name is decided | |
| 2126 | +// later, from the authenticated account. | |
| 2127 | +const resolveLoginOrigin = (input: { | |
| 2128 | + readonly baseUrl: Option.Option<string>; | |
| 2129 | + readonly server: Option.Option<string>; | |
| 2130 | +}): Effect.Effect< | |
| 2131 | + { readonly origin: string; readonly profile: ReturnType<typeof findCliServerConnectionProfile> }, | |
| 2132 | + Error, | |
| 2133 | + FileSystem.FileSystem | PlatformPath.Path | |
| 2134 | +> => | |
| 2135 | + Effect.gen(function* () { | |
| 2136 | + const baseUrl = Option.getOrUndefined(input.baseUrl); | |
| 2137 | + const serverName = Option.getOrUndefined(input.server); | |
| 2138 | + if (baseUrl && serverName) { | |
| 2139 | + return yield* Effect.fail(new Error("Use either --server or --base-url, not both.")); | |
| 2140 | + } | |
| 2141 | + if (serverName) { | |
| 2142 | + const store = yield* readCliServerConnectionStore(); | |
| 2143 | + const profile = findCliServerConnectionProfile(store, serverName); | |
| 2144 | + if (!profile) | |
| 2145 | + return yield* Effect.fail(new Error(`No server profile named "${serverName}".`)); | |
| 2146 | + return { origin: profile.connection.origin, profile }; | |
| 2147 | + } | |
| 2148 | + if (baseUrl) { | |
| 2149 | + return { origin: normalizeExecutorServerOrigin(baseUrl), profile: null }; | |
| 2150 | + } | |
| 2151 | + const store = yield* readCliServerConnectionStore(); | |
| 2152 | + const profile = defaultCliServerConnectionProfile(store); | |
| 2153 | + if (profile) return { origin: profile.connection.origin, profile }; | |
| 2154 | + return yield* Effect.fail( | |
| 2155 | + new Error( | |
| 2156 | + "No server to log in to. Pass --base-url <https://your-host> or --server <profile>.", | |
| 2157 | + ), | |
| 2158 | + ); | |
| 2159 | + }); | |
| 2160 | + | |
| 2161 | +const loginNameOption = Options.string("name").pipe( | |
| 2162 | + Options.optional, | |
| 2163 | + Options.withDescription("Profile name to save the login under (defaults to your account)."), | |
| 2164 | +); | |
| 2165 | + | |
| 2166 | +const noBrowserOption = Options.boolean("no-browser").pipe( | |
| 2167 | + Options.withDefault(false), | |
| 2168 | + Options.withDescription("Print the verification URL instead of opening a browser."), | |
| 2169 | +); | |
| 2170 | + | |
| 2171 | +const loginCommand = Command.make( | |
| 2172 | + "login", | |
| 2173 | + { | |
| 2174 | + baseUrl: serverBaseUrl, | |
| 2175 | + server: serverProfile, | |
| 2176 | + name: loginNameOption, | |
| 2177 | + noBrowser: noBrowserOption, | |
| 2178 | + }, | |
| 2179 | + ({ baseUrl, server, name, noBrowser }) => | |
| 2180 | + Effect.gen(function* () { | |
| 2181 | + const target = yield* resolveLoginOrigin({ baseUrl, server }); | |
| 2182 | + const explicitName = Option.getOrUndefined(name); | |
| 2183 | + const discovery = yield* Effect.tryPromise({ | |
| 2184 | + try: () => discoverCliLogin(target.origin), | |
| 2185 | + catch: toError, | |
| 2186 | + }); | |
| 2187 | + const grant = yield* Effect.tryPromise({ | |
| 2188 | + try: () => requestDeviceCode(discovery), | |
| 2189 | + catch: toError, | |
| 2190 | + }); | |
| 2191 | + const verifyUrl = grant.verificationUriComplete ?? grant.verificationUri; | |
| 2192 | + console.log(""); | |
| 2193 | + console.log(`To sign in, open: ${verifyUrl}`); | |
| 2194 | + console.log(`and confirm the code: ${grant.userCode}`); | |
| 2195 | + console.log(""); | |
| 2196 | + if (!noBrowser) openBrowser(verifyUrl); | |
| 2197 | + console.log("Waiting for you to approve in the browser..."); | |
| 2198 | + const tokens = yield* Effect.tryPromise({ | |
| 2199 | + try: () => pollForDeviceTokens(discovery, grant), | |
| 2200 | + catch: toError, | |
| 2201 | + }); | |
| 2202 | + | |
| 2203 | + const claims = decodeAccessTokenClaims(tokens.accessToken); | |
| 2204 | + const sub = typeof claims?.sub === "string" ? claims.sub : undefined; | |
| 2205 | + const org = | |
| 2206 | + tokens.organizationId ?? (typeof claims?.org_id === "string" ? claims.org_id : undefined); | |
| 2207 | + const email = tokens.email; | |
| 2208 | + | |
| 2209 | + // Name by account so a different account on the same host doesn't clobber | |
| 2210 | + // an existing login; --server / the default profile / --name pin it. | |
| 2211 | + const store = yield* readCliServerConnectionStore(); | |
| 2212 | + const profileName = explicitName | |
| 2213 | + ? validateCliServerConnectionProfileName(explicitName) | |
| 2214 | + : target.profile | |
| 2215 | + ? target.profile.name | |
| 2216 | + : chooseLoginProfileName(store, { origin: target.origin, sub, org, email }); | |
| 2217 | + | |
| 2218 | + yield* upsertCliServerConnectionProfile({ | |
| 2219 | + name: profileName, | |
| 2220 | + connection: { | |
| 2221 | + kind: "http", | |
| 2222 | + origin: target.origin, | |
| 2223 | + ...(email ? { displayName: email } : {}), | |
| 2224 | + auth: { | |
| 2225 | + kind: "oauth", | |
| 2226 | + accessToken: tokens.accessToken, | |
| 2227 | + ...(tokens.refreshToken ? { refreshToken: tokens.refreshToken } : {}), | |
| 2228 | + ...(tokens.expiresAt ? { expiresAt: tokens.expiresAt } : {}), | |
| 2229 | + tokenEndpoint: discovery.tokenEndpoint, | |
| 2230 | + clientId: discovery.clientId, | |
| 2231 | + }, | |
| 2232 | + }, | |
| 2233 | + makeDefault: true, | |
| 2234 | + }); | |
| 2235 | + | |
| 2236 | + console.log(""); | |
| 2237 | + console.log(`Logged in to ${target.origin} (profile "${profileName}", now the default).`); | |
| 2238 | + if (email) console.log(`Account: ${email}`); | |
| 2239 | + if (org) console.log(`Organization: ${org}`); | |
| 2240 | + else if (sub) console.log(`User: ${sub}`); | |
| 2241 | + }), | |
| 2242 | +).pipe(Command.withDescription("Sign in to a hosted Executor server in the browser (device flow)")); | |
| 2243 | + | |
| 2244 | +const logoutCommand = Command.make( | |
| 2245 | + "logout", | |
| 2246 | + { baseUrl: serverBaseUrl, server: serverProfile }, | |
| 2247 | + ({ baseUrl, server }) => | |
| 2248 | + Effect.gen(function* () { | |
| 2249 | + const target = yield* resolveLoginOrigin({ baseUrl, server }); | |
| 2250 | + const store = yield* readCliServerConnectionStore(); | |
| 2251 | + // --server / default give the profile directly; --base-url matches by origin. | |
| 2252 | + const profile = | |
| 2253 | + target.profile ?? store.profiles.find((p) => p.connection.origin === target.origin) ?? null; | |
| 2254 | + if (!profile) { | |
| 2255 | + console.log(`No stored login for ${target.origin}.`); | |
| 2256 | + return; | |
| 2257 | + } | |
| 2258 | + if (!profile.connection.auth) { | |
| 2259 | + console.log(`Profile "${profile.name}" has no stored credentials.`); | |
| 2260 | + return; | |
| 2261 | + } | |
| 2262 | + yield* upsertCliServerConnectionProfile({ | |
| 2263 | + name: profile.name, | |
| 2264 | + connection: { | |
| 2265 | + kind: profile.connection.kind, | |
| 2266 | + origin: profile.connection.origin, | |
| 2267 | + displayName: profile.connection.displayName, | |
| 2268 | + }, | |
| 2269 | + makeDefault: store.defaultProfile === profile.name, | |
| 2270 | + }); | |
| 2271 | + console.log( | |
| 2272 | + `Logged out of ${profile.connection.origin} (cleared credentials for "${profile.name}").`, | |
| 2273 | + ); | |
| 2274 | + }), | |
| 2275 | +).pipe(Command.withDescription("Clear stored credentials for a server profile")); | |
| 2276 | + | |
| 2277 | +const whoamiCommand = Command.make( | |
| 2278 | + "whoami", | |
| 2279 | + { baseUrl: serverBaseUrl, server: serverProfile }, | |
| 2280 | + ({ baseUrl, server }) => | |
| 2281 | + Effect.gen(function* () { | |
| 2282 | + const requested = yield* resolveRequestedExecutorServerConnection( | |
| 2283 | + serverTargetFromOptions({ baseUrl, server }), | |
| 2284 | + ); | |
| 2285 | + const connection = yield* refreshOAuthConnection(requested.connection); | |
| 2286 | + const auth = connection.auth; | |
| 2287 | + console.log(`Server: ${connection.origin}`); | |
| 2288 | + if (!auth) { | |
| 2289 | + console.log("Not logged in (no stored credentials)."); | |
| 2290 | + return; | |
| 2291 | + } | |
| 2292 | + if (auth.kind === "oauth") { | |
| 2293 | + const claims = decodeAccessTokenClaims(auth.accessToken); | |
| 2294 | + const sub = claims?.sub; | |
| 2295 | + const org = claims?.org_id; | |
| 2296 | + // The email lives on the profile's displayName (WorkOS access tokens | |
| 2297 | + // don't carry an email claim); the token carries sub + org_id. | |
| 2298 | + if (connection.displayName.includes("@")) console.log(`Account: ${connection.displayName}`); | |
| 2299 | + if (typeof sub === "string") console.log(`User: ${sub}`); | |
| 2300 | + if (typeof org === "string") console.log(`Organization: ${org}`); | |
| 2301 | + if (auth.expiresAt) { | |
| 2302 | + const remaining = auth.expiresAt - Math.floor(Date.now() / 1000); | |
| 2303 | + console.log( | |
| 2304 | + remaining > 0 | |
| 2305 | + ? `Token valid for ~${Math.round(remaining / 60)} min.` | |
| 2306 | + : "Token expired (refreshes on next use).", | |
| 2307 | + ); | |
| 2308 | + } | |
| 2309 | + return; | |
| 2310 | + } | |
| 2311 | + console.log(`Authenticated via ${auth.kind}.`); | |
| 2312 | + }), | |
| 2313 | +).pipe(Command.withDescription("Show who you're signed in as on a server")); | |
| 2314 | + | |
| 1998 | 2315 | const webCommand = Command.make( |
| 1999 | 2316 | "web", |
| 2000 | 2317 | { |
| @@ -2562,6 +2879,9 @@ const root = Command.make("executor").pipe( · 561 unmodified lines | ||
| 2562 | 2879 | resumeCommand, |
| 2563 | 2880 | toolsCommand, |
| 2564 | 2881 | installCommand, |
| 2882 | + loginCommand, | |
| 2883 | + logoutCommand, | |
| 2884 | + whoamiCommand, | |
| 2565 | 2885 | serverCommand, |
| 2566 | 2886 | webCommand, |
| 2567 | 2887 | daemonCommand, |
| @@ -43,8 +43,10 @@ export const withCliServerAuthFallback = ( · 42 unmodified lines | ||
| 43 | 43 | |
| 44 | 44 | export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => { |
| 45 | 45 | if (connection.kind !== "http") return false; |
| 46 | − // Explicit auth means the user is pointing at an existing server (e.g. a | |
| 47 | − // desktop sidecar or a remote), not asking us to spin up a local daemon. | |
| 46 | + // A stored credential (basic password, bearer key, or an oauth device-login | |
| 47 | + // token) means the user is targeting an EXISTING authenticated server, never | |
| 48 | + // spawn a local daemon for it, even when it happens to be on http://localhost | |
| 49 | + // (e.g. a hosted server reached over a tunnel, or a local e2e cloud server). | |
| 48 | 50 | if (connection.auth) return false; |
| 49 | 51 | const url = new URL(connection.origin); |
| 50 | 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); |
| @@ -52,6 +52,14 @@ const PersistedAuth = Schema.Union([ · 51 unmodified lines | ||
| 52 | 52 | kind: Schema.Literal("bearer"), |
| 53 | 53 | token: Schema.String, |
| 54 | 54 | }), |
| 55 | + Schema.Struct({ | |
| 56 | + kind: Schema.Literal("oauth"), | |
| 57 | + accessToken: Schema.String, | |
| 58 | + refreshToken: Schema.optional(Schema.String), | |
| 59 | + expiresAt: Schema.optional(Schema.Number), | |
| 60 | + tokenEndpoint: Schema.optional(Schema.String), | |
| 61 | + clientId: Schema.optional(Schema.String), | |
| 62 | + }), | |
| 55 | 63 | ]); |
| 56 | 64 | |
| 57 | 65 | const PersistedConnection = Schema.Struct({ |