History

CLI device-flow login for hosted accounts (`executor login`) (#1026)

* 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).

Rhys Sullivan committed 4 months ago

Commit 7c873ec1 parent 6c2478417 files · +1557 −13Browse files

Showing the changes to apps/cli/src (4 of 17 files). Show all

Unified · Split

4 files
  1. apps/cli/src/device-login.ts +328 −0
  2. apps/cli/src/main.ts +321 −1
  3. apps/cli/src/server-connection.ts +4 −2
  4. apps/cli/src/server-profile.ts +8 −0
apps/cli/src/device-login.ts+328−0
@@ -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+};
apps/cli/src/main.ts+321−1
@@ -69,11 +69,20 @@ import { ExecutorApi } from "@executor-js/api"; · 68 unmodified lines
6969 import {
7070 getExecutorServerAuthorizationHeader,
7171 normalizeExecutorServerConnection,
72+ normalizeExecutorServerOrigin,
7273 type ExecutorLocalServerKind,
7374 type ExecutorLocalServerManifest,
7475 type ExecutorServerConnection,
7576 type ExecutorServerConnectionInput,
7677 } from "@executor-js/sdk/shared";
78+import {
79+ decodeAccessTokenClaims,
80+ discoverCliLogin,
81+ openBrowser,
82+ pollForDeviceTokens,
83+ refreshDeviceTokens,
84+ requestDeviceCode,
85+} from "./device-login";
7786 import {
7887 startServer,
7988 runMcpStdioServer,
@@ -138,6 +147,7 @@ import { · 58 unmodified lines
138147 setDefaultCliServerConnectionProfile,
139148 upsertCliServerConnectionProfile,
140149 validateCliServerConnectionProfileName,
150+ type CliServerConnectionStore,
141151 } from "./server-profile";
142152 import {
143153 buildResumeContentTemplate,
@@ -587,6 +597,59 @@ const resolveRequestedExecutorServerConnection = ( · 443 unmodified lines
587597 };
588598 });
589599
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+
590653 const resolveExecutorServerConnection = (
591654 target: ServerTarget,
592655 ): Effect.Effect<ExecutorServerConnection, Error, FileSystem.FileSystem | PlatformPath.Path> =>
@@ -612,10 +675,16 @@ const resolveExecutorServerConnection = ( · 19 unmodified lines
612675 );
613676 }
614677
615− const requested = decision.connection;
678+ const requested = yield* refreshOAuthConnection(decision.connection);
616679 if (decision.kind === "use-active") return requested;
617680
618681 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;
619688 if (yield* isServerReachable(requested.origin)) {
620689 return requested;
621690 }
@@ -1995,6 +2064,254 @@ const serverCommand = Command.make("server").pipe( · 1373 unmodified lines
19952064 Command.withDescription("Manage named Executor server profiles"),
19962065 );
19972066
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+
19982315 const webCommand = Command.make(
19992316 "web",
20002317 {
@@ -2562,6 +2879,9 @@ const root = Command.make("executor").pipe( · 561 unmodified lines
25622879 resumeCommand,
25632880 toolsCommand,
25642881 installCommand,
2882+ loginCommand,
2883+ logoutCommand,
2884+ whoamiCommand,
25652885 serverCommand,
25662886 webCommand,
25672887 daemonCommand,
apps/cli/src/server-connection.ts+4−2
@@ -43,8 +43,10 @@ export const withCliServerAuthFallback = ( · 42 unmodified lines
4343
4444 export const canAutoStartCliServerConnection = (connection: ExecutorServerConnection): boolean => {
4545 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).
4850 if (connection.auth) return false;
4951 const url = new URL(connection.origin);
5052 return url.protocol === "http:" && canAutoStartLocalDaemonForHost(url.hostname);
apps/cli/src/server-profile.ts+8−0
@@ -52,6 +52,14 @@ const PersistedAuth = Schema.Union([ · 51 unmodified lines
5252 kind: Schema.Literal("bearer"),
5353 token: Schema.String,
5454 }),
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+ }),
5563 ]);
5664
5765 const PersistedConnection = Schema.Struct({