* 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 | import { | 69 | import { |
| 70 | getExecutorServerAuthorizationHeader, | 70 | getExecutorServerAuthorizationHeader, |
| 71 | normalizeExecutorServerConnection, | 71 | normalizeExecutorServerConnection, |
| 72 | normalizeExecutorServerOrigin, | ||
| 72 | type ExecutorLocalServerKind, | 73 | type ExecutorLocalServerKind, |
| 73 | type ExecutorLocalServerManifest, | 74 | type ExecutorLocalServerManifest, |
| 74 | type ExecutorServerConnection, | 75 | type ExecutorServerConnection, |
| 75 | type ExecutorServerConnectionInput, | 76 | type ExecutorServerConnectionInput, |
| 76 | } from "@executor-js/sdk/shared"; | 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 | import { | 86 | import { |
| 78 | startServer, | 87 | startServer, |
| 79 | runMcpStdioServer, | 88 | runMcpStdioServer, |
| @@ -138,6 +147,7 @@ import { · 58 unmodified lines | |||
| 138 | setDefaultCliServerConnectionProfile, | 147 | setDefaultCliServerConnectionProfile, |
| 139 | upsertCliServerConnectionProfile, | 148 | upsertCliServerConnectionProfile, |
| 140 | validateCliServerConnectionProfileName, | 149 | validateCliServerConnectionProfileName, |
| 150 | type CliServerConnectionStore, | ||
| 141 | } from "./server-profile"; | 151 | } from "./server-profile"; |
| 142 | import { | 152 | import { |
| 143 | buildResumeContentTemplate, | 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 | const resolveExecutorServerConnection = ( | 653 | const resolveExecutorServerConnection = ( |
| 591 | target: ServerTarget, | 654 | target: ServerTarget, |
| 592 | ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> => | 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 | if (decision.kind === "use-active") return requested; | 679 | if (decision.kind === "use-active") return requested; |
| 617 | 680 | ||
| 618 | if (!canAutoStartCliServerConnection(requested)) { | 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 | if (yield* isServerReachable(requested.origin)) { | 688 | if (yield* isServerReachable(requested.origin)) { |
| 620 | return requested; | 689 | return requested; |
| 621 | } | 690 | } |
| @@ -1995,6 +2064,254 @@ const serverCommand = Command.make("server").pipe( · 1373 unmodified lines | |||
| 1995 | Command.withDescription("Manage named Executor server profiles"), | 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 | const webCommand = Command.make( | 2315 | const webCommand = Command.make( |
| 1999 | "web", | 2316 | "web", |
| 2000 | { | 2317 | { |
| @@ -2562,6 +2879,9 @@ const root = Command.make("executor").pipe( · 561 unmodified lines | |||
| 2562 | resumeCommand, | 2879 | resumeCommand, |
| 2563 | toolsCommand, | 2880 | toolsCommand, |
| 2564 | installCommand, | 2881 | installCommand, |
| 2882 | loginCommand, | ||
| 2883 | logoutCommand, | ||
| 2884 | whoamiCommand, | ||
| 2565 | serverCommand, | 2885 | serverCommand, |
| 2566 | webCommand, | 2886 | webCommand, |
| 2567 | daemonCommand, | 2887 | daemonCommand, |
| @@ -43,8 +43,10 @@ export const withCliServerAuthFallback = ( · 42 unmodified lines | |||
| 43 | 43 | ||
| 44 | export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => { | 44 | export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => { |
| 45 | if (connection.kind !== "http") return false; | 45 | if (connection.kind !== "http") return false; |
| 46 | // Explicit auth means the user is pointing at an existing server (e.g. a | 46 | // A stored credential (basic password, bearer key, or an oauth device-login |
| 47 | // desktop sidecar or a remote), not asking us to spin up a local daemon. | 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 | if (connection.auth) return false; | 50 | if (connection.auth) return false; |
| 49 | const url = new URL(connection.origin); | 51 | const url = new URL(connection.origin); |
| 50 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); | 52 | return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname); |
| @@ -52,6 +52,14 @@ const PersistedAuth = Schema.Union([ · 51 unmodified lines | |||
| 52 | kind: Schema.Literal("bearer"), | 52 | kind: Schema.Literal("bearer"), |
| 53 | token: Schema.String, | 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 | const PersistedConnection = Schema.Struct({ | 65 | const PersistedConnection = Schema.Struct({ |