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

Unified · Split

17 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
  5. apps/cloud/src/api/protected-jwt-auth.node.test.ts +169 −0
  6. apps/cloud/src/auth/api-jwt-bearer.ts +35 −0
  7. apps/cloud/src/auth/api.ts +13 −1
  8. apps/cloud/src/auth/handlers.ts +16 −0
  9. apps/cloud/src/auth/workos-auth-provider.ts +90 −4
  10. apps/host-selfhost/src/app.ts +23 −1
  11. apps/host-selfhost/src/auth/better-auth.ts +9 −1
  12. apps/host-selfhost/web/device.tsx +137 −0
  13. apps/host-selfhost/web/routes/__root.tsx +15 −0
  14. e2e/cloud/cli-device-login.test.ts +223 −0
  15. e2e/selfhost/cli-device-login.test.ts +135 −0
  16. e2e/src/surfaces/cli.ts +10 −3
  17. packages/core/sdk/src/server-connection.ts +21 −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({
apps/cloud/src/api/protected-jwt-auth.node.test.ts+169−0
@@ -0,0 +1,169 @@
1+import { describe, expect, it } from "@effect/vitest";
2+import { Effect, Layer } from "effect";
3+import { SignJWT, createLocalJWKSet, exportJWK, generateKeyPair } from "jose";
4+
5+import { ApiKeyService } from "../auth/api-keys";
6+import { UserStoreService } from "../auth/context";
7+import type { JwtBearerConfig } from "../auth/workos-auth-provider";
8+import { WorkOSClient, type WorkOSClientService } from "../auth/workos";
9+import { resolveProtectedPrincipal } from "./protected";
10+
11+const createdAt = new Date("2026-01-01T00:00:00.000Z");
12+const issuer = "https://test-authkit.example.com";
13+const audience = "client_test_audience";
14+
15+// A WorkOS access-token JWT verifier backed by a local RS256 key pair (no
16+// network), plus a signer that mints tokens for it. Mirrors
17+// `mcp/mcp-auth.node.test.ts`.
18+const makeJwt = async () => {
19+ const { publicKey, privateKey } = await generateKeyPair("RS256");
20+ const jwk = await exportJWK(publicKey);
21+ const jwks = createLocalJWKSet({ keys: [{ ...jwk, kid: "test-key" }] });
22+ const config: JwtBearerConfig = { jwks, issuer, audience };
23+ const sign = (claims: Record<string, unknown>, expiration: string | number = "5m") =>
24+ new SignJWT(claims)
25+ .setProtectedHeader({ alg: "RS256", kid: "test-key" })
26+ .setIssuer(issuer)
27+ .setAudience(audience)
28+ .setSubject("user_123")
29+ .setIssuedAt()
30+ .setExpirationTime(expiration)
31+ .sign(privateKey);
32+ return { config, sign };
33+};
34+
35+// The api-key path must never run for a JWT bearer, die loudly if it does.
36+const stubApiKeys = Layer.succeed(ApiKeyService)({
37+ validate: () => Effect.die("JWT bearer must not hit ApiKeyService.validate"),
38+ listUserKeys: () => Effect.succeed([]),
39+ createUserKey: () => Effect.die("JWT auth test does not create API keys"),
40+ revokeUserKey: () => Effect.void,
41+});
42+
43+const stubWorkOS = Layer.succeed(
44+ WorkOSClient,
45+ new Proxy({} as WorkOSClientService, {
46+ get: (_target, prop) => {
47+ if (prop === "listUserMemberships") {
48+ return (userId: string) =>
49+ Effect.succeed({
50+ data:
51+ userId === "user_123"
52+ ? [{ userId, organizationId: "org_123", status: "active" }]
53+ : [],
54+ });
55+ }
56+ return () => Effect.die(`unexpected WorkOSClient.${String(prop)} call`);
57+ },
58+ }),
59+);
60+
61+const stubUsers = Layer.succeed(UserStoreService)({
62+ use: (fn) =>
63+ Effect.promise(() =>
64+ fn({
65+ ensureAccount: async (id: string) => ({ id, createdAt }),
66+ getAccount: async (id: string) => ({ id, createdAt }),
67+ upsertOrganization: async (org: { id: string; name: string }) => ({
68+ ...org,
69+ slug: `org-slug-${org.id}`,
70+ createdAt,
71+ }),
72+ getOrganization: async (id: string) => ({
73+ id,
74+ name: `Org ${id}`,
75+ slug: `org-slug-${id}`,
76+ createdAt,
77+ }),
78+ getOrganizationBySlug: async (slug: string) => ({
79+ id: "org_by_slug",
80+ name: `Org ${slug}`,
81+ slug,
82+ createdAt,
83+ }),
84+ }),
85+ ),
86+});
87+
88+const run = (request: Request, jwt: JwtBearerConfig) =>
89+ resolveProtectedPrincipal(request, jwt).pipe(
90+ Effect.provide(Layer.mergeAll(stubApiKeys, stubWorkOS, stubUsers)),
91+ );
92+
93+const request = (token: string) =>
94+ new Request("https://executor.test/api/tools", {
95+ headers: { authorization: `Bearer ${token}` },
96+ });
97+
98+describe("protected JWT (device-login) auth", () => {
99+ it.effect("resolves a valid WorkOS access token into protected identity", () =>
100+ Effect.gen(function* () {
101+ const { config, sign } = yield* Effect.promise(() => makeJwt());
102+ const token = yield* Effect.promise(() => sign({ org_id: "org_123" }));
103+
104+ const identity = yield* run(request(token), config);
105+
106+ expect(identity).toEqual({
107+ accountId: "user_123",
108+ organizationId: "org_123",
109+ organizationName: "Org org_123",
110+ email: "",
111+ name: null,
112+ avatarUrl: null,
113+ roles: [],
114+ });
115+ }),
116+ );
117+
118+ it.effect("rejects an expired access token as Unauthorized", () =>
119+ Effect.gen(function* () {
120+ const { config, sign } = yield* Effect.promise(() => makeJwt());
121+ // Expired one hour ago.
122+ const token = yield* Effect.promise(() =>
123+ sign({ org_id: "org_123" }, Math.floor(Date.now() / 1000) - 3600),
124+ );
125+
126+ const error = yield* Effect.flip(run(request(token), config));
127+
128+ expect(error).toMatchObject({
129+ _tag: "Unauthorized",
130+ code: "invalid_access_token",
131+ });
132+ }),
133+ );
134+
135+ it.effect("rejects a token signed by a different key as Unauthorized", () =>
136+ Effect.gen(function* () {
137+ const { config } = yield* Effect.promise(() => makeJwt());
138+ const other = yield* Effect.promise(() => makeJwt());
139+ const token = yield* Effect.promise(() => other.sign({ org_id: "org_123" }));
140+
141+ const error = yield* Effect.flip(run(request(token), config));
142+
143+ expect(error).toMatchObject({ _tag: "Unauthorized", code: "invalid_access_token" });
144+ }),
145+ );
146+
147+ it.effect("rejects a token with no org_id as NoOrganization", () =>
148+ Effect.gen(function* () {
149+ const { config, sign } = yield* Effect.promise(() => makeJwt());
150+ const token = yield* Effect.promise(() => sign({}));
151+
152+ const error = yield* Effect.flip(run(request(token), config));
153+
154+ expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });
155+ }),
156+ );
157+
158+ it.effect("rejects a valid token whose org membership is not active", () =>
159+ Effect.gen(function* () {
160+ const { config, sign } = yield* Effect.promise(() => makeJwt());
161+ // org_999 is not in the membership stub -> not authorized.
162+ const token = yield* Effect.promise(() => sign({ org_id: "org_999" }));
163+
164+ const error = yield* Effect.flip(run(request(token), config));
165+
166+ expect(error).toMatchObject({ _tag: "NoOrganization", code: "no_organization" });
167+ }),
168+ );
169+});
apps/cloud/src/auth/api-jwt-bearer.ts+35−0
@@ -0,0 +1,35 @@
1+// ---------------------------------------------------------------------------
2+// The `/api/*` plane's WorkOS JWT bearer config.
3+//
4+// The protected HTTP API accepts THREE credential forms on the `Authorization`
5+// header, in this precedence: a WorkOS access-token JWT (CLI device-login,
6+// `executor login`), then a WorkOS API key, then, with no header, the
7+// sealed-session cookie. This module supplies the JWT-verification config
8+// (JWKS + issuer + audience) the JWT branch needs.
9+//
10+// It mirrors the MCP plane's setup (`mcp/auth.ts`): the SAME AuthKit JWKS,
11+// issuer (`MCP_AUTHKIT_DOMAIN`), and audience (`WORKOS_CLIENT_ID`). A device
12+// access token minted by AuthKit is byte-for-byte the same kind of token the
13+// MCP plane already verifies, so a CLI can hold ONE credential for both planes.
14+//
15+// This is the `cloudflare:workers`-reading leaf; `workos-auth-provider.ts`
16+// stays env-free and receives this config as a plain value, so its node-pool
17+// resolver tests can inject a local JWKS instead.
18+// ---------------------------------------------------------------------------
19+
20+import { env } from "cloudflare:workers";
21+
22+import { createCachedRemoteJWKSet } from "./jwks-cache";
23+import type { JwtBearerConfig } from "./workos-auth-provider";
24+
25+const AUTHKIT_DOMAIN = env.MCP_AUTHKIT_DOMAIN ?? "https://signin.executor.sh";
26+
27+// Module-scope cache, same rationale as `mcp/auth.ts`: one JWKS fetch per
28+// isolate-hour rather than one per cold isolate. The two caches (here + MCP)
29+// are independent module scopes hitting the same upstream; the 1h TTL keeps
30+// the duplication cheap.
31+export const workosApiJwtBearerConfig: JwtBearerConfig = {
32+ jwks: createCachedRemoteJWKSet(new URL(`${AUTHKIT_DOMAIN}/oauth2/jwks`)),
33+ issuer: AUTHKIT_DOMAIN,
34+ audience: env.WORKOS_CLIENT_ID,
35+};
apps/cloud/src/auth/api.ts+13−1
@@ -44,6 +44,17 @@ const CreateOrganizationResponse = Schema.Struct({ · 43 unmodified lines
4444 slug: Schema.String,
4545 });
4646
47+// CLI device-login discovery (`executor login`). Tells the CLI where to run
48+// the OAuth 2.0 Device Authorization Grant (RFC 8628) and which public client
49+// to use. The CLI hits these provider endpoints directly, gets a WorkOS access
50+// token (a JWT), and sends it as a Bearer to the `/api/*` plane.
51+const CliLoginResponse = Schema.Struct({
52+ provider: Schema.Literal("workos"),
53+ deviceAuthorizationEndpoint: Schema.String,
54+ tokenEndpoint: Schema.String,
55+ clientId: Schema.String,
56+});
57+
4758 // `state` is optional — some WorkOS-initiated redirects arrive at the
4859 // callback without the state we set on /auth/login. The CSRF check is
4960 // only enforced when state is present (see callback handler).
@@ -156,7 +167,8 @@ export class CloudAuthPublicApi extends HttpApiGroup.make("cloudAuthPublic") · 106 unmodified lines
156167 query: AuthCallbackSearch,
157168 error: AuthErrors,
158169 }),
159− ) {}
170+ )
171+ .add(HttpApiEndpoint.get("cliLogin", "/auth/cli-login", { success: CliLoginResponse })) {}
160172
161173 /** Session auth endpoints — require a logged-in user, may not have an org */
162174 export class CloudAuthApi extends HttpApiGroup.make("cloudAuth")
apps/cloud/src/auth/handlers.ts+16−0
@@ -236,6 +236,22 @@ export const CloudAuthPublicHandlers = HttpApiBuilder.group( · 235 unmodified lines
236236 STATE_COOKIE,
237237 );
238238 }),
239+ )
240+ // CLI device-login discovery. The WorkOS device endpoints live on the
241+ // WorkOS API host (`WORKOS_API_URL`, or api.workos.com in production,
242+ // the SAME base the SDK uses, so e2e points the CLI at the emulator with
243+ // zero extra wiring). The CLI runs RFC 8628 against them as a public
244+ // client (no secret) and gets a WorkOS access-token JWT back.
245+ .handle("cliLogin", () =>
246+ Effect.sync(() => {
247+ const base = (env.WORKOS_API_URL ?? "https://api.workos.com").replace(/\/+$/, "");
248+ return {
249+ provider: "workos" as const,
250+ deviceAuthorizationEndpoint: `${base}/user_management/authorize/device`,
251+ tokenEndpoint: `${base}/user_management/authenticate`,
252+ clientId: env.WORKOS_CLIENT_ID,
253+ };
254+ }),
239255 ),
240256 );
241257
apps/cloud/src/auth/workos-auth-provider.ts+90−4
@@ -29,6 +29,7 @@ · 28 unmodified lines
2929
3030 import { Effect, Layer } from "effect";
3131 import { HttpServerResponse } from "effect/unstable/http";
32+import type { JWTVerifyGetKey } from "jose";
3233
3334 import {
3435 IdentityProvider,
@@ -39,6 +40,7 @@ import { · 4 unmodified lines
3940 import type { FailureRenderingStrategy, IdentityFailure, Principal } from "@executor-js/api/server";
4041
4142 import { ApiKeyService } from "./api-keys";
43+import { workosApiJwtBearerConfig } from "./api-jwt-bearer";
4244 import { BEARER_PREFIX } from "./bearer";
4345 import {
4446 authorizeOrganization,
@@ -49,6 +51,20 @@ import { UserStoreService } from "./context"; · 4 unmodified lines
4951 import { sealedSessionDisplayName } from "./middleware";
5052 import type { UserStoreError, WorkOSError } from "./errors";
5153 import { WorkOSClient } from "./workos";
54+import { verifyWorkOSMcpAccessToken } from "../mcp/jwt";
55+
56+/**
57+ * The config the bearer-JWT branch needs to verify a WorkOS access token:
58+ * the AuthKit JWKS resolver plus the issuer + audience to assert. Passed in
59+ * as a plain value so this module stays `cloudflare:workers`-free and the
60+ * node-pool resolver tests can inject a local JWKS. Production supplies
61+ * {@link workosApiJwtBearerConfig} (built from `cloudflare:workers` env).
62+ */
63+export interface JwtBearerConfig {
64+ readonly jwks: JWTVerifyGetKey;
65+ readonly issuer: string;
66+ readonly audience: string;
67+}
5268
5369 // The exact machine codes + messages each rejected path has always emitted.
5470 // Carried on the shared identity error so the failure strategy renders the
@@ -70,8 +86,75 @@ const NO_ORGANIZATION_IN_SESSION = { · 15 unmodified lines
7086 code: "no_organization",
7187 message: "No organization in session",
7288 };
89+const INVALID_ACCESS_TOKEN = {
90+ code: "invalid_access_token",
91+ message: "Invalid or expired access token",
92+};
93+const ACCESS_TOKEN_VERIFICATION_UNAVAILABLE = {
94+ code: "access_token_verification_unavailable",
95+ message: "Access token verification is temporarily unavailable",
96+};
97+const NO_ORGANIZATION_IN_ACCESS_TOKEN = {
98+ code: "no_organization",
99+ message: "No organization in access token",
100+};
101+
102+// A bearer value with three dot-separated segments is a JWT (a WorkOS access
103+// token from the CLI device-login); anything else is treated as an API key.
104+// Same discriminator the MCP plane uses (`mcp/auth.ts`).
105+const looksLikeJwt = (token: string): boolean => token.split(".").length === 3;
73106
74−export const resolveApiKeyPrincipal = (request: Request) =>
107+/**
108+ * Resolve a WorkOS access-token JWT (CLI `executor login`) into a protected
109+ * `Principal`. Verifies the token against AuthKit's JWKS (issuer + audience),
110+ * then live-checks org membership, exactly like the api-key path. The
111+ * `org_id` claim must be present (a token with no org context is rejected as
112+ * `NoOrganization`). Mirrors the MCP plane's JWT verify, reusing the same
113+ * `cloudflare:workers`-free verifier so a CLI holds ONE credential for both
114+ * the `/api/*` and `/mcp` planes.
115+ */
116+const resolveJwtPrincipal = (token: string, jwt: JwtBearerConfig) =>
117+ Effect.gen(function* () {
118+ const verified = yield* verifyWorkOSMcpAccessToken(token, jwt.jwks, {
119+ issuer: jwt.issuer,
120+ audience: jwt.audience,
121+ }).pipe(
122+ Effect.catchTag("McpJwtVerificationError", (error) =>
123+ Effect.fail(
124+ error.reason === "system"
125+ ? new Unavailable(ACCESS_TOKEN_VERIFICATION_UNAVAILABLE)
126+ : new Unauthorized(INVALID_ACCESS_TOKEN),
127+ ),
128+ ),
129+ );
130+
131+ if (!verified || !verified.accountId) return yield* new Unauthorized(INVALID_ACCESS_TOKEN);
132+ if (!verified.organizationId) {
133+ return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
134+ }
135+
136+ const org = yield* authorizeOrganization(verified.accountId, verified.organizationId);
137+ if (!org) return yield* new NoOrganization(NO_ORGANIZATION_IN_ACCESS_TOKEN);
138+
139+ return {
140+ accountId: verified.accountId,
141+ organizationId: org.id,
142+ organizationName: org.name,
143+ email: "",
144+ name: null,
145+ avatarUrl: null,
146+ roles: [],
147+ } satisfies Principal;
148+ });
149+
150+/**
151+ * Resolve a `Bearer` credential into a `Principal`: a WorkOS access-token JWT
152+ * (when `jwt` config is supplied and the value looks like a JWT) or a WorkOS
153+ * API key. Returns `null` when there is no `Authorization` header, so the
154+ * caller falls through to the sealed-session path. (Kept the historical name,
155+ * the re-export and resolver tests reference it.)
156+ */
157+export const resolveApiKeyPrincipal = (request: Request, jwt: JwtBearerConfig | null = null) =>
75158 Effect.gen(function* () {
76159 const authHeader = request.headers.get("authorization");
77160 if (!authHeader) return null;
@@ -83,6 +166,8 @@ export const resolveApiKeyPrincipal = (request: Request) => · 5 unmodified lines
83166 const value = authHeader.slice(BEARER_PREFIX.length).trim();
84167 if (!value) return yield* new Unauthorized(INVALID_API_KEY);
85168
169+ if (jwt && looksLikeJwt(value)) return yield* resolveJwtPrincipal(value, jwt);
170+
86171 const apiKeys = yield* ApiKeyService;
87172 const principal = yield* apiKeys
88173 .validate(value)
@@ -149,14 +234,15 @@ export const resolveSessionPrincipal = (request: Request) => · 60 unmodified lines
149234 */
150235 export const resolveProtectedPrincipal = (
151236 request: Request,
237+ jwt: JwtBearerConfig | null = null,
152238 ): Effect.Effect<
153239 Principal,
154240 Unauthorized | NoOrganization | Unavailable | UserStoreError | WorkOSError,
155241 WorkOSClient | ApiKeyService | UserStoreService
156242 > =>
157243 Effect.gen(function* () {
158− const apiKeyPrincipal = yield* resolveApiKeyPrincipal(request);
159− if (apiKeyPrincipal) return apiKeyPrincipal;
244+ const bearerPrincipal = yield* resolveApiKeyPrincipal(request, jwt);
245+ if (bearerPrincipal) return bearerPrincipal;
160246 return yield* resolveSessionPrincipal(request);
161247 });
162248
@@ -180,7 +266,7 @@ export const workosIdentityLayer: Layer.Layer< · 17 unmodified lines
180266 const context = yield* Effect.context<WorkOSClient | ApiKeyService | UserStoreService>();
181267 return IdentityProvider.of({
182268 authenticate: (request) =>
183− resolveProtectedPrincipal(request).pipe(
269+ resolveProtectedPrincipal(request, workosApiJwtBearerConfig).pipe(
184270 // `UserStoreError` / `WorkOSError` are org-resolution infra failures —
185271 // surface as a 500 defect, exactly as the old inline resolver let them
186272 // bubble. The narrow `die` here is the runtime edge for that infra
apps/host-selfhost/src/app.ts+23−1
@@ -73,6 +73,24 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 72 unmodified lines
7373 // a reverse proxy (not the internal 127.0.0.1 bind from the request URL).
7474 const mcp = makeSelfHostMcpSeams(dbHandle, betterAuth, config.webBaseUrl);
7575
76+ // CLI device-login discovery (`executor login`). Points the CLI at Better
77+ // Auth's device endpoints; `requestFormat: "json"` because those endpoints
78+ // only accept JSON (unlike WorkOS's form-encoded ones). The issued token is a
79+ // Better Auth session that `bearer()` accepts on the /api/* plane.
80+ const cliLoginHandler = HttpEffect.fromWebHandler(
81+ async () =>
82+ new Response(
83+ JSON.stringify({
84+ provider: "better-auth",
85+ deviceAuthorizationEndpoint: `${config.webBaseUrl}/api/auth/device/code`,
86+ tokenEndpoint: `${config.webBaseUrl}/api/auth/device/token`,
87+ clientId: "executor-cli",
88+ requestFormat: "json",
89+ }),
90+ { headers: { "content-type": "application/json" } },
91+ ),
92+ );
93+
7694 const { appLayer, toWebHandler } = ExecutorApp.make({
7795 plugins: selfHostPlugins,
7896 providers: {
@@ -86,7 +104,11 @@ export const makeSelfHostApp = async (options: MakeSelfHostAppOptions = {}) => { · 7 unmodified lines
86104 },
87105 extensions: {
88106 routes: [
89− // Better Auth owns /api/auth/* — the full path reaches it unmodified.
107+ // CLI device-login discovery, must precede the /api/auth/* wildcard
108+ // below (Better Auth would otherwise 404 it). The verification page it
109+ // points at (/device) is a console SPA route (web/device.tsx).
110+ HttpRouter.add("GET", "/api/auth/cli-login", cliLoginHandler),
111+ // Better Auth owns the rest of /api/auth/*, the full path reaches it.
90112 HttpRouter.add("*", "/api/auth/*", HttpEffect.fromWebHandler(authHandler)),
91113 // Browser approval of paused MCP executions: the console resume page
92114 // reads paused detail (GET) and records the decision (POST .../resume),
apps/host-selfhost/src/auth/better-auth.ts+9−1
@@ -1,6 +1,6 @@
11 import { betterAuth, type BetterAuthOptions } from "better-auth";
22 import { APIError } from "better-auth/api";
3−import { admin, bearer, mcp, organization } from "better-auth/plugins";
3+import { admin, bearer, deviceAuthorization, mcp, organization } from "better-auth/plugins";
44 import { apiKey } from "@better-auth/api-key";
55 import { type Client } from "@libsql/client";
66 import { LibsqlDialect, type LibsqlDialectConfig } from "@libsql/kysely-libsql";
@@ -114,6 +114,14 @@ const makeAuthOptions = (client: Client, getOrganizationId: () => string, gate?: · 107 unmodified lines
114114 admin(),
115115 apiKey({ enableSessionForAPIKeys: true, rateLimit: { enabled: false } }),
116116 bearer(),
117+ // RFC 8628 device authorization, the CLI `executor login` flow. Registers
118+ // /device/code + /device/token + the approval endpoints; the issued token
119+ // is an opaque session that `bearer()` (above) accepts as `Authorization:
120+ // Bearer` on the /api/* plane. `validateClient` is left unset, so any
121+ // client_id is accepted (the CLI presents "executor-cli"). `verificationUri`
122+ // is the page the user opens to confirm the code — the self-host app serves
123+ // it at /device (this is also the Better Auth default; pinned for clarity).
124+ deviceAuthorization({ verificationUri: "/device" }),
117125 // `consentPage` makes the MCP authorize flow redirect to a human approval
118126 // screen instead of auto-issuing a code — but ONLY when the request
119127 // carries `prompt=consent`. MCP clients don't send that, so the self-host
apps/host-selfhost/web/device.tsx+137−0
@@ -0,0 +1,137 @@
1+import { useEffect, useState } from "react";
2+
3+import { Button } from "@executor-js/react/components/button";
4+
5+// ---------------------------------------------------------------------------
6+// CLI device-login verification page. Better Auth's deviceAuthorization()
7+// plugin ships the JSON device API but no UI, so this is the page its
8+// `verificationUri` (/device) points at. `executor login` prints the code and
9+// opens this URL; the user confirms it and authorizes the device.
10+//
11+// Flow: on load we claim the pending code for the signed-in session (GET
12+// /api/auth/device?user_code, the plugin's bind step), then Approve/Deny POST
13+// to /api/auth/device/{approve,deny}. Like McpConsentPage, this is rendered
14+// chromeless INSIDE the auth gate (so the user is already signed in) via a
15+// static import in routes/__root.tsx, and drives the endpoints with plain fetch
16+// (the same convention the MCP consent screen uses).
17+// ---------------------------------------------------------------------------
18+
19+type Phase = "binding" | "ready" | "approved" | "denied" | "error";
20+
21+export const DevicePage = () => {
22+ const params = new URLSearchParams(globalThis.window?.location.search ?? "");
23+ const userCode = params.get("user_code") ?? "";
24+ const [phase, setPhase] = useState<Phase>("binding");
25+ const [busy, setBusy] = useState<"approve" | "deny" | null>(null);
26+ const [error, setError] = useState<string | null>(null);
27+
28+ useEffect(() => {
29+ if (!userCode) {
30+ setPhase("error");
31+ setError("This link is missing its device code.");
32+ return;
33+ }
34+ let alive = true;
35+ // Claim the pending code for this session; only the same session can then
36+ // approve or deny it.
37+ const bind = async () => {
38+ const res = await fetch(
39+ `${globalThis.location.origin}/api/auth/device?user_code=${encodeURIComponent(userCode)}`,
40+ { credentials: "include", headers: { accept: "application/json" } },
41+ );
42+ if (!alive) return;
43+ if (res.ok) {
44+ setPhase("ready");
45+ } else {
46+ setPhase("error");
47+ setError("This device code is unknown, already used, or expired.");
48+ }
49+ };
50+ void bind();
51+ return () => {
52+ alive = false;
53+ };
54+ }, [userCode]);
55+
56+ const decide = async (accept: boolean) => {
57+ setBusy(accept ? "approve" : "deny");
58+ setError(null);
59+ const res = await fetch(
60+ `${globalThis.location.origin}/api/auth/device/${accept ? "approve" : "deny"}`,
61+ {
62+ method: "POST",
63+ credentials: "include",
64+ headers: { "content-type": "application/json" },
65+ body: JSON.stringify({ userCode }),
66+ },
67+ );
68+ setBusy(null);
69+ if (!res.ok) {
70+ setError("Could not record your decision. The code may have expired, try again.");
71+ return;
72+ }
73+ setPhase(accept ? "approved" : "denied");
74+ };
75+
76+ return (
77+ <div className="flex min-h-screen items-center justify-center bg-background p-6">
78+ <div
79+ id="device-approval"
80+ className="w-full max-w-md space-y-5 rounded-xl border border-border bg-card p-6 text-center shadow-sm"
81+ >
82+ <div className="space-y-1">
83+ <h1 className="font-display text-2xl tracking-tight text-foreground">
84+ Authorize this device
85+ </h1>
86+ <p className="text-sm text-muted-foreground">Confirm the code shown in your terminal.</p>
87+ </div>
88+
89+ <div className="rounded-lg border border-border bg-background/50 p-4 font-mono text-2xl tracking-[0.3em] text-foreground">
90+ {userCode || "XXXX-XXXX"}
91+ </div>
92+
93+ {phase === "binding" && <p className="text-sm text-muted-foreground">Checking the code…</p>}
94+
95+ {phase === "ready" && (
96+ <>
97+ <p className="text-xs text-muted-foreground">
98+ A signed-in device will be able to act as your account through the CLI.
99+ </p>
100+ {error && <p className="text-sm text-destructive">{error}</p>}
101+ <div className="flex gap-3">
102+ <Button
103+ id="device-deny"
104+ type="button"
105+ variant="outline"
106+ className="flex-1"
107+ disabled={busy !== null}
108+ onClick={() => void decide(false)}
109+ >
110+ {busy === "deny" ? "Denying…" : "Deny"}
111+ </Button>
112+ <Button
113+ id="device-approve"
114+ type="button"
115+ className="flex-1"
116+ disabled={busy !== null}
117+ onClick={() => void decide(true)}
118+ >
119+ {busy === "approve" ? "Authorizing…" : "Authorize device"}
120+ </Button>
121+ </div>
122+ </>
123+ )}
124+
125+ {phase === "approved" && (
126+ <p className="text-sm text-foreground">Device approved. Return to your terminal.</p>
127+ )}
128+ {phase === "denied" && (
129+ <p className="text-sm text-muted-foreground">
130+ Request denied. You can close this window.
131+ </p>
132+ )}
133+ {phase === "error" && <p className="text-sm text-destructive">{error}</p>}
134+ </div>
135+ </div>
136+ );
137+};
apps/host-selfhost/web/routes/__root.tsx+15−0
@@ -11,6 +11,7 @@ import { Shell, defaultShellNavItems } from "@executor-js/react/multiplayer/shel · 10 unmodified lines
1111 import { plugins as clientPlugins } from "virtual:executor/plugins-client";
1212
1313 import { authClient } from "../auth-client";
14+import { DevicePage } from "../device";
1415 import { McpConsentPage } from "../mcp-consent";
1516 import { LoginPage } from "../login";
1617 import { SetupPage } from "../setup";
@@ -127,6 +128,20 @@ function RootComponent() { · 110 unmodified lines
127128 );
128129 }
129130
131+ // The CLI device-login verification page: chromeless, inside the auth gate
132+ // (the user signs in here if needed, then authorizes the code). Same
133+ // static-import + pathname-branch convention as /mcp-consent.
134+ if (pathname === "/device") {
135+ return (
136+ <AuthProvider>
137+ <AuthGate>
138+ <DevicePage />
139+ <Toaster />
140+ </AuthGate>
141+ </AuthProvider>
142+ );
143+ }
144+
130145 return (
131146 <AuthProvider>
132147 <AuthGate>
e2e/cloud/cli-device-login.test.ts+223−0
@@ -0,0 +1,223 @@
1+// Cloud: the CLI `executor login` device-authorization flow end to end against
2+// the real cloud app, terminal AND browser, both recorded for the viewer. The
3+// actual `executor` binary runs the OAuth 2.0 Device Authorization Grant
4+// (RFC 8628) in a real terminal (terminal.cast): it prints the verification URL
5+// and polls. The browser leg is REAL too (session.mp4): Playwright opens that
6+// URL, confirms the code, and clicks "Authorize device" on the WorkOS
7+// emulator's verification page, exactly the human hop, the way the MCP
8+// approval scenarios drive their browser step. The terminal then runs `whoami`
9+// and `tools sources`; a clean exit of that chain proves the resulting WorkOS
10+// access token (a JWT) is accepted by the protected `/api/*` plane.
11+import { spawn } from "node:child_process";
12+import { readFileSync } from "node:fs";
13+import { dirname, join, resolve } from "node:path";
14+import { fileURLToPath } from "node:url";
15+
16+import { expect } from "@effect/vitest";
17+import { Effect } from "effect";
18+
19+import { scenario } from "../src/scenario";
20+import { Browser, Cli, RunDir, Target } from "../src/services";
21+import { enterFocus } from "../src/timeline";
22+import { CLOUD_BASE_URL } from "../targets/cloud";
23+
24+const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
25+const CLI_ENTRY = join(REPO_ROOT, "apps", "cli", "src", "main.ts");
26+
27+scenario(
28+ "CLI · executor login device flow → authenticated /api call",
29+ { timeout: 180_000 },
30+ Effect.scoped(
31+ Effect.gen(function* () {
32+ const target = yield* Target;
33+ // Cloud-only: the discovery endpoint + WorkOS device flow are this target's.
34+ if (target.name !== "cloud") return;
35+
36+ // Slow + hold the browser steps so the recording is watchable. Scoped to
37+ // this scenario and restored after (files run serially, so a leaked flag
38+ // would slow every later scenario).
39+ const prevFilm = process.env.E2E_FILM;
40+ yield* Effect.addFinalizer(() =>
41+ Effect.sync(() => {
42+ if (prevFilm === undefined) delete process.env.E2E_FILM;
43+ else process.env.E2E_FILM = prevFilm;
44+ }),
45+ );
46+ process.env.E2E_FILM = "1";
47+
48+ const cli = yield* Cli;
49+ const browser = yield* Browser;
50+ const runDir = yield* RunDir;
51+ const dataDir = join(runDir, "cli-home");
52+
53+ // A fresh signed-in user with an org, the org is what the device token's
54+ // org_id claim binds to, and what the /api plane authorizes against.
55+ const identity = yield* target.newIdentity();
56+ const email = identity.credentials?.email ?? identity.label;
57+
58+ const env = { ...process.env, EXECUTOR_DATA_DIR: dataDir };
59+ for (const key of ["EXECUTOR_API_KEY", "EXECUTOR_AUTH_TOKEN", "EXECUTOR_AUTH_PASSWORD"]) {
60+ delete (env as Record<string, string | undefined>)[key];
61+ }
62+
63+ // Hand the printed verification URL from the terminal fiber to the browser.
64+ let resolveUrl!: (url: string) => void;
65+ const verificationUrl = new Promise<string>((r) => {
66+ resolveUrl = r;
67+ });
68+
69+ // The terminal journey, recorded to terminal.cast. `&&` means a clean
70+ // exit only happens if every step, including the authenticated /api call
71+ // (`tools sources`), succeeded.
72+ const cli_ = `bun run ${CLI_ENTRY}`;
73+ const journey =
74+ `${cli_} login --base-url ${CLOUD_BASE_URL} --no-browser --name cloud && ` +
75+ `${cli_} whoami --server cloud && ` +
76+ `${cli_} tools sources --server cloud`;
77+
78+ const terminal = cli.session(
79+ ["bash", "-c", journey],
80+ async (session) => {
81+ await session.screen.waitForText(/user_code=/, { timeoutMs: 60_000 });
82+ const match = (await session.screen.text()).match(/(https?:\/\/\S*user_code=\S+)/);
83+ if (!match) throw new Error("verification URL not found on screen");
84+ resolveUrl(match[1]);
85+ await session.screen.waitForText("Logged in to", { timeoutMs: 60_000 });
86+ const exit = await session.waitForExit({ timeoutMs: 60_000 });
87+ if (exit.reason !== "exited" || exit.exit.code !== 0) {
88+ throw new Error(
89+ `journey did not exit cleanly: ${JSON.stringify(exit)}\n${await session.screen.text()}`,
90+ );
91+ }
92+ return session.screen.text();
93+ },
94+ {
95+ cwd: REPO_ROOT,
96+ env,
97+ record: join(runDir, "terminal.cast"),
98+ viewport: { cols: 300, rows: 48 },
99+ },
100+ );
101+
102+ // The browser leg, a REAL Playwright session approving the device on the
103+ // verification page (recorded to session.mp4 + per-step screenshots). Runs
104+ // concurrently with the terminal: it waits for the printed URL, then
105+ // approves while the CLI is mid-poll.
106+ const browserApproval = Effect.gen(function* () {
107+ const url = yield* Effect.promise(() => verificationUrl);
108+ yield* browser.session(identity, async ({ page, step }) => {
109+ await step("Open the device verification link from the terminal", async () => {
110+ await page.goto(url, { waitUntil: "domcontentloaded" });
111+ await page
112+ .getByRole("button", { name: /Authorize device/i })
113+ .waitFor({ timeout: 15_000 });
114+ });
115+ await step("Confirm the code and authorize the device", async () => {
116+ // The visible email field (existing-user quick buttons also carry a
117+ // hidden name="email", so target the typed input by type).
118+ await page.locator('input[type="email"]').fill(email);
119+ await page.getByRole("button", { name: /Authorize device/i }).click();
120+ await page.getByText(/Device approved/i).waitFor({ timeout: 15_000 });
121+ });
122+ });
123+ // Cut the synced player back to the terminal for the "Logged in" + the
124+ // authenticated /api call that follow the browser approval.
125+ yield* Effect.promise(() => enterFocus(runDir, "terminal"));
126+ });
127+
128+ const [finalScreen] = yield* Effect.all([terminal, browserApproval], {
129+ concurrency: "unbounded",
130+ });
131+ expect(finalScreen, "whoami reported the bound organization").toMatch(/org_\w+/);
132+
133+ // The stored profile carries an oauth device-login credential, not a key.
134+ const store = JSON.parse(readFileSync(join(dataDir, "server-connections.json"), "utf8")) as {
135+ defaultProfile: string | null;
136+ profiles: Array<{
137+ name: string;
138+ connection: { auth?: { kind: string; accessToken?: string } };
139+ }>;
140+ };
141+ expect(store.defaultProfile, "the login became the default profile").toBe("cloud");
142+ const cloudProfile = store.profiles.find((p) => p.name === "cloud");
143+ expect(cloudProfile?.connection.auth?.kind, "credential is an oauth device token").toBe(
144+ "oauth",
145+ );
146+ expect(typeof cloudProfile?.connection.auth?.accessToken, "an access token is stored").toBe(
147+ "string",
148+ );
149+ }),
150+ ),
151+);
152+
153+// Run `executor login` as a subprocess, approving the device for `approveEmail`
154+// the moment the verification URL is printed (raw stdout, no PTY).
155+const runCliLogin = (
156+ args: readonly string[],
157+ dataDir: string,
158+ approveEmail: string,
159+): Promise<{ code: number | null; stdout: string }> =>
160+ new Promise((res, rej) => {
161+ const env = { ...process.env, EXECUTOR_DATA_DIR: dataDir };
162+ for (const k of ["EXECUTOR_API_KEY", "EXECUTOR_AUTH_TOKEN", "EXECUTOR_AUTH_PASSWORD"]) {
163+ delete (env as Record<string, string | undefined>)[k];
164+ }
165+ const child = spawn("bun", ["run", CLI_ENTRY, ...args], { cwd: REPO_ROOT, env });
166+ let stdout = "";
167+ let approved = false;
168+ child.stdout.on("data", (chunk: Buffer) => {
169+ stdout += chunk.toString();
170+ if (approved) return;
171+ const match = stdout.match(/(https?:\/\/\S*user_code=\S+)/);
172+ if (!match) return;
173+ approved = true;
174+ const url = new URL(match[1]);
175+ url.searchParams.set("login_hint", approveEmail);
176+ void fetch(url, { redirect: "manual" });
177+ });
178+ child.stderr.on("data", () => {});
179+ child.on("error", rej);
180+ child.on("close", (code) => res({ code, stdout }));
181+ });
182+
183+scenario(
184+ "CLI · two accounts on the same host get separate profiles",
185+ { timeout: 120_000 },
186+ Effect.gen(function* () {
187+ const target = yield* Target;
188+ if (target.name !== "cloud") return;
189+
190+ const runDir = yield* RunDir;
191+ const dataDir = join(runDir, "multi-home");
192+
193+ // Two distinct hosted accounts (different user + org) on the SAME server.
194+ const a = yield* target.newIdentity();
195+ const b = yield* target.newIdentity();
196+ const emailA = a.credentials?.email ?? a.label;
197+ const emailB = b.credentials?.email ?? b.label;
198+
199+ // Log in as each with NO --name, so naming is driven by the account.
200+ const loginA = yield* Effect.promise(() =>
201+ runCliLogin(["login", "--base-url", CLOUD_BASE_URL, "--no-browser"], dataDir, emailA),
202+ );
203+ expect(loginA.code, "first login exited cleanly").toBe(0);
204+ const loginB = yield* Effect.promise(() =>
205+ runCliLogin(["login", "--base-url", CLOUD_BASE_URL, "--no-browser"], dataDir, emailB),
206+ );
207+ expect(loginB.code, "second login exited cleanly").toBe(0);
208+
209+ const store = JSON.parse(readFileSync(join(dataDir, "server-connections.json"), "utf8")) as {
210+ defaultProfile: string | null;
211+ profiles: Array<{
212+ name: string;
213+ connection: { origin: string; displayName?: string; auth?: { kind: string } };
214+ }>;
215+ };
216+ const oauthProfiles = store.profiles.filter((p) => p.connection.auth?.kind === "oauth");
217+ // The second login must NOT clobber the first, both accounts kept.
218+ expect(oauthProfiles.length, "both accounts retained as separate profiles").toBe(2);
219+ expect(new Set(oauthProfiles.map((p) => p.name)).size, "profile names are distinct").toBe(2);
220+ const emails = new Set(oauthProfiles.map((p) => p.connection.displayName));
221+ expect(emails.has(emailA) && emails.has(emailB), "both account emails present").toBe(true);
222+ }),
223+);
e2e/selfhost/cli-device-login.test.ts+135−0
@@ -0,0 +1,135 @@
1+// Self-host: the CLI `executor login` device-authorization flow end to end
2+// against the self-host app (Better Auth), terminal AND browser, both recorded.
3+// The `executor` binary runs RFC 8628 in a real terminal (terminal.cast): it
4+// discovers Better Auth's device endpoints via /api/auth/cli-login, prints the
5+// verification URL, and polls. The browser leg is REAL (session.mp4): Playwright
6+// opens the self-host /device page (signed in via the session cookie) and clicks
7+// "Authorize device". The terminal then runs `whoami` and `tools sources`; a
8+// clean exit of that chain proves the Better Auth device token is accepted as a
9+// Bearer on the protected /api/* plane.
10+import { readFileSync } from "node:fs";
11+import { dirname, join, resolve } from "node:path";
12+import { fileURLToPath } from "node:url";
13+
14+import { expect } from "@effect/vitest";
15+import { Effect } from "effect";
16+
17+import { scenario } from "../src/scenario";
18+import { Browser, Cli, RunDir, Target } from "../src/services";
19+import { enterFocus } from "../src/timeline";
20+import { SELFHOST_BASE_URL } from "../targets/selfhost";
21+
22+const REPO_ROOT = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..");
23+const CLI_ENTRY = join(REPO_ROOT, "apps", "cli", "src", "main.ts");
24+
25+scenario(
26+ "CLI · executor login device flow → authenticated /api call",
27+ { timeout: 180_000 },
28+ Effect.scoped(
29+ Effect.gen(function* () {
30+ const target = yield* Target;
31+ if (target.name !== "selfhost") return;
32+
33+ // Slow + hold the browser steps so the recording is watchable. Scoped to
34+ // this scenario and restored after (files run serially, so a leaked flag
35+ // would slow every later scenario).
36+ const prevFilm = process.env.E2E_FILM;
37+ yield* Effect.addFinalizer(() =>
38+ Effect.sync(() => {
39+ if (prevFilm === undefined) delete process.env.E2E_FILM;
40+ else process.env.E2E_FILM = prevFilm;
41+ }),
42+ );
43+ process.env.E2E_FILM = "1";
44+
45+ const cli = yield* Cli;
46+ const browser = yield* Browser;
47+ const runDir = yield* RunDir;
48+ const dataDir = join(runDir, "cli-home");
49+
50+ // A signed-in identity (its session cookie authorizes the /device page).
51+ const identity = yield* target.newIdentity();
52+
53+ const env = { ...process.env, EXECUTOR_DATA_DIR: dataDir };
54+ for (const key of ["EXECUTOR_API_KEY", "EXECUTOR_AUTH_TOKEN", "EXECUTOR_AUTH_PASSWORD"]) {
55+ delete (env as Record<string, string | undefined>)[key];
56+ }
57+
58+ let resolveUrl!: (url: string) => void;
59+ const verificationUrl = new Promise<string>((r) => {
60+ resolveUrl = r;
61+ });
62+
63+ const cli_ = `bun run ${CLI_ENTRY}`;
64+ const journey =
65+ `${cli_} login --base-url ${SELFHOST_BASE_URL} --no-browser --name selfhost && ` +
66+ `${cli_} whoami --server selfhost && ` +
67+ `${cli_} tools sources --server selfhost`;
68+
69+ const terminal = cli.session(
70+ ["bash", "-c", journey],
71+ async (session) => {
72+ await session.screen.waitForText(/user_code=/, { timeoutMs: 60_000 });
73+ const match = (await session.screen.text()).match(/(https?:\/\/\S*user_code=\S+)/);
74+ if (!match) throw new Error("verification URL not found on screen");
75+ resolveUrl(match[1]);
76+ await session.screen.waitForText("Logged in to", { timeoutMs: 60_000 });
77+ const exit = await session.waitForExit({ timeoutMs: 60_000 });
78+ if (exit.reason !== "exited" || exit.exit.code !== 0) {
79+ throw new Error(
80+ `journey did not exit cleanly: ${JSON.stringify(exit)}\n${await session.screen.text()}`,
81+ );
82+ }
83+ return session.screen.text();
84+ },
85+ {
86+ cwd: REPO_ROOT,
87+ env,
88+ record: join(runDir, "terminal.cast"),
89+ viewport: { cols: 300, rows: 48 },
90+ },
91+ );
92+
93+ // The browser leg, approve on the self-host /device page (session cookie
94+ // from the identity authorizes it). Recorded to session.mp4.
95+ const browserApproval = Effect.gen(function* () {
96+ const url = yield* Effect.promise(() => verificationUrl);
97+ yield* browser.session(identity, async ({ page, step }) => {
98+ await step("Open the device verification page", async () => {
99+ await page.goto(url, { waitUntil: "domcontentloaded" });
100+ // The Authorize button appears once the page binds the signed-in user.
101+ await page
102+ .getByRole("button", { name: /Authorize device/i })
103+ .waitFor({ timeout: 20_000 });
104+ });
105+ await step("Authorize the device", async () => {
106+ await page.getByRole("button", { name: /Authorize device/i }).click();
107+ await page.getByText(/Device approved/i).waitFor({ timeout: 15_000 });
108+ });
109+ });
110+ // Cut the synced player back to the terminal for the "Logged in" + the
111+ // authenticated /api call that follow the browser approval.
112+ yield* Effect.promise(() => enterFocus(runDir, "terminal"));
113+ });
114+
115+ // Reaching here means the whole `&&` chain exited 0, including the
116+ // authenticated `tools sources` /api call.
117+ yield* Effect.all([terminal, browserApproval], { concurrency: "unbounded" });
118+
119+ // The stored profile carries an oauth device-login credential, not a key.
120+ const store = JSON.parse(readFileSync(join(dataDir, "server-connections.json"), "utf8")) as {
121+ defaultProfile: string | null;
122+ profiles: Array<{
123+ name: string;
124+ connection: { auth?: { kind: string; accessToken?: string } };
125+ }>;
126+ };
127+ expect(store.defaultProfile, "the login became the default profile").toBe("selfhost");
128+ const profile = store.profiles.find((p) => p.name === "selfhost");
129+ expect(profile?.connection.auth?.kind, "credential is an oauth device token").toBe("oauth");
130+ expect(typeof profile?.connection.auth?.accessToken, "an access token is stored").toBe(
131+ "string",
132+ );
133+ }),
134+ ),
135+);
e2e/src/surfaces/cli.ts+10−3
@@ -3,11 +3,12 @@ · 2 unmodified lines
33 // vitest; pass `record` to save an asciicast v2 the viewer can replay. The
44 // recording is written in release so a timeout still leaves the evidence.
55 import { writeFileSync } from "node:fs";
6+import { dirname } from "node:path";
67
78 import { Effect } from "effect";
89 import { TerminalControl, type Session } from "@kitlangton/terminal-control";
910
10−import { beat } from "../timeline";
11+import { beat, enterFocus, markRecordingStart } from "../timeline";
1112
1213 export interface CliSurface {
1314 readonly session: <T>(
@@ -67,10 +68,16 @@ export const makeCliSurface = (): CliSurface => ({ · 53 unmodified lines
6768 record: options?.record ? true : undefined,
6869 viewport: options?.viewport,
6970 });
70− return { tc, session };
71+ // Anchor the terminal recording on the run's focus timeline (symmetric
72+ // with the browser surface), so a combined cli+browser scenario gets the
73+ // synced SessionPlayer view (terminal/browser cuts + URL bar) for free.
74+ const runDir = options?.record ? dirname(options.record) : null;
75+ if (runDir) markRecordingStart(runDir, "terminal");
76+ return { tc, session, runDir };
7177 }),
72− ({ session }) =>
78+ ({ session, runDir }) =>
7379 Effect.promise(async () => {
80+ if (runDir) await enterFocus(runDir, "terminal");
7481 const result = await drive(session);
7582 // Hold the terminal's final frame (e.g. "connected") before the
7683 // recording stops, so it isn't a single flash in the film. Filming
packages/core/sdk/src/server-connection.ts+21−0
@@ -15,6 +15,18 @@ export type ExecutorServerAuth = · 14 unmodified lines
1515 | {
1616 readonly kind: "bearer";
1717 readonly token: string;
18+ }
19+ | {
20+ // OAuth 2.0 device-flow credential from `executor login` against a hosted
21+ // server. The access token is sent as a Bearer; `refreshToken` +
22+ // `expiresAt` (epoch seconds) + `tokenEndpoint` + `clientId` let the CLI
23+ // refresh it before expiry without a fresh browser login.
24+ readonly kind: "oauth";
25+ readonly accessToken: string;
26+ readonly refreshToken?: string;
27+ readonly expiresAt?: number;
28+ readonly tokenEndpoint?: string;
29+ readonly clientId?: string;
1830 };
1931
2032 export interface ExecutorServerConnection {
@@ -125,6 +137,7 @@ export const getExecutorServerAuthorizationHeader = ( · 104 unmodified lines
125137 const auth = connection.auth;
126138 if (!auth) return null;
127139 if (auth.kind === "bearer") return `Bearer ${auth.token}`;
140+ if (auth.kind === "oauth") return `Bearer ${auth.accessToken}`;
128141 const encoded = encodeBasicCredentials(
129142 `${auth.username ?? DEFAULT_EXECUTOR_SERVER_USERNAME}:${auth.password}`,
130143 );
@@ -141,6 +154,14 @@ const ExecutorServerAuthJson = Schema.Union([ · 10 unmodified lines
141154 kind: Schema.Literal("bearer"),
142155 token: Schema.String,
143156 }),
157+ Schema.Struct({
158+ kind: Schema.Literal("oauth"),
159+ accessToken: Schema.String,
160+ refreshToken: Schema.optional(Schema.String),
161+ expiresAt: Schema.optional(Schema.Number),
162+ tokenEndpoint: Schema.optional(Schema.String),
163+ clientId: Schema.optional(Schema.String),
164+ }),
144165 ]);
145166
146167 const ExecutorServerConnectionJson = Schema.Struct({