diff --git a/docs/adapters/cac.md b/docs/adapters/cac.md index 3a0d22f2..ea4f0ed4 100644 --- a/docs/adapters/cac.md +++ b/docs/adapters/cac.md @@ -75,8 +75,7 @@ defineDevframe({ portRange: [7777, 9000], // passed through to get-port-please random: false, // passed through to get-port-please host: '127.0.0.1', // default host; --host overrides - open: true, // auto-open the browser on dev start - auth: false, // skip the trust handshake (single-user localhost) + open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated configure(cli) { // contribute capability flags/commands cli.option('--config ', 'Custom config file') .option('--no-files', 'Skip file matching') diff --git a/docs/guide/client.md b/docs/guide/client.md index ceea5605..f0ee6a28 100644 --- a/docs/guide/client.md +++ b/docs/guide/client.md @@ -80,6 +80,8 @@ if (!trusted) { } ``` +`connectDevframe()` kicks off that same handshake (stored token, then a magic-link OTP on the page URL, then — top-level pages only — a native prompt) the moment it resolves, without making the caller wait for it. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` know about that in-flight handshake internally and hold anything issued before it settles, so application code can call a trusted method right away — e.g. in a component's `onMount` — without an explicit `ensureTrusted()` guard just to avoid a race. Reach for `ensureTrusted()` when you want to reflect the pending state in the UI (a spinner, a "waiting for auth" banner), not to make calls safe. + ### Authenticating with a one-time code A fresh client holds no token. The dev server prints a 6-digit one-time code; pass it to `requestTrustWithCode` to exchange it for a node-issued token. The token is persisted for future reconnections and shared with sibling tabs, which become trusted without re-entering the code: diff --git a/docs/guide/devframe-definition.md b/docs/guide/devframe-definition.md index 1cdd985d..9c87de34 100644 --- a/docs/guide/devframe-definition.md +++ b/docs/guide/devframe-definition.md @@ -185,8 +185,7 @@ defineDevframe({ portRange: [9876, 10000], // forwarded to get-port-please random: false, // forwarded to get-port-please host: 'localhost', // default host; --host overrides - open: true, // auto-open the browser on dev start - auth: false, // skip the trust handshake (single-user localhost) + open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated configure(cli) { // contribute capability flags/commands cli .option('--my-flag ', 'Tool-specific flag') @@ -208,7 +207,7 @@ defineDevframe({ | `portRange` | `[number, number]` | Port scan range, passed through to `get-port-please`. | | `random` | `boolean` | Prefer a random open port. | | `host` | `string` | Default bind host. | -| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. | +| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. When `auth` is on, the opened URL embeds the current OTP so the tab authenticates automatically. | | `auth` | `boolean` | Disable the WS trust flow when the tool is localhost-only and single-user. Default `true`. | | `configure` | `(cli: CAC) => void` | Contribute capability flags/commands. Runs before `createCac`'s `configureCli` option so the final tool author always has the last word. | diff --git a/docs/guide/security.md b/docs/guide/security.md index 6797b982..6fa44c37 100644 --- a/docs/guide/security.md +++ b/docs/guide/security.md @@ -24,6 +24,8 @@ Exactly one rule decides what an untrusted connection may call: **a method is re `startHttpAndWs` enforces this itself once you give it something to enforce: pass `auth: authHandler` (its `.authorize` becomes the gate) or your own `authorize(methodName, session)` function. Every other call from an untrusted session throws [`DF0036`](../errors/DF0036). +On the client, `connectDevframe()` kicks off the handshake below without waiting for it, so a naive client could otherwise race it — sending a trusted call over the freshly-opened socket before the server has had a chance to answer `anonymous:devframe:auth`, hitting this exact gate. `rpc.call` / `rpc.callOptional` / `rpc.callEvent` hold anything issued while that first handshake is still in flight and release it once the handshake settles, so application code never has to special-case this window itself. + ## Authentication flow Authentication exchanges a short code for a long-lived token. A node mints and owns the token; the browser only ever sends the short code, and only over the open socket. @@ -79,7 +81,7 @@ Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a cod ### Magic-link authentication -To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. Build it from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner): +To skip typing, a host can print a link that embeds the code and open the browser straight into an authenticated session. The standalone CLI (`createCac` / `createDevServer`) does this automatically for `--open`: when the server is auth-gated, the browser it launches already carries the current code, so the tab lands authenticated with no prompt at all. Build the link yourself from the current code with `buildOtpAuthUrl(origin)` (devframe stays headless, so the host prints its own banner): ``` Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456 diff --git a/docs/guide/standalone-cli.md b/docs/guide/standalone-cli.md index f641745e..eb73c65e 100644 --- a/docs/guide/standalone-cli.md +++ b/docs/guide/standalone-cli.md @@ -43,8 +43,7 @@ const devframe = defineDevframe({ distDir, port: 7777, portRange: [7777, 9000], - open: true, - auth: false, // single-user localhost — skip the trust handshake + open: true, // auth defaults to on; `--open` embeds the current OTP so the tab lands authenticated configure(cli) { cli .option('--config ', 'Config file path') diff --git a/packages/devframe/src/adapters/__tests__/dev.test.ts b/packages/devframe/src/adapters/__tests__/dev.test.ts index 39242b61..a471a346 100644 --- a/packages/devframe/src/adapters/__tests__/dev.test.ts +++ b/packages/devframe/src/adapters/__tests__/dev.test.ts @@ -4,6 +4,7 @@ import { tmpdir } from 'node:os' import { join } from 'node:path' import { createRpcClient } from 'devframe/rpc/client' import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client' +import { open } from 'devframe/utils/open' import { getPort } from 'get-port-please' import { describe, expect, it, vi } from 'vitest' import { WebSocket } from 'ws' @@ -11,6 +12,10 @@ import { getTempAuthCode } from '../../node/auth/state' import { defineDevframe } from '../../types/devframe' import { createDevServer, resolveDevServerPort } from '../dev' +// `--open` is exercised directly (asserting the URL handed to the OS opener) +// rather than actually launching a browser in CI. +vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) })) + function connectWsClient(host: string, port: number, authToken?: string) { return createRpcClient( {} as DevframeRpcClientFunctions, @@ -323,6 +328,62 @@ describe('adapters/dev', () => { } }) + it('`--open` embeds the current OTP so the opened tab authenticates automatically', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-open', + name: 'Auth Open', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19415, host }) + const spy = vi.spyOn(console, 'log').mockImplementation(() => {}) + const mockedOpen = vi.mocked(open) + mockedOpen.mockClear() + const code = getTempAuthCode() + const handle = await createDevServer(devframe, { host, port, openBrowser: true }) + + try { + expect(mockedOpen).toHaveBeenCalledTimes(1) + const [target] = mockedOpen.mock.calls[0] + expect(target).toBe(`http://localhost:${port}/?devframe_otp=${code}`) + } + finally { + spy.mockRestore() + await handle.close() + } + }) + + it('`--open` opens the plain origin when `auth: false` (no handler to embed a code)', async () => { + const devframe = defineDevframe({ + id: 'devframe-auth-open-off', + name: 'Auth Open Off', + version: '0.0.0', + packageName: 'devframe-test', + homepage: 'https://example.test', + description: 'Test devframe.', + cli: { auth: false }, + setup: () => {}, + }) + const host = '127.0.0.1' + const port = await getPort({ port: 19416, host }) + const mockedOpen = vi.mocked(open) + mockedOpen.mockClear() + const handle = await createDevServer(devframe, { host, port, openBrowser: true }) + + try { + expect(mockedOpen).toHaveBeenCalledTimes(1) + const [target] = mockedOpen.mock.calls[0] + expect(target).toBe(`http://localhost:${port}/`) + } + finally { + await handle.close() + } + }) + it('opts out with `auth: false`: the server auto-trusts and skips the gate', async () => { const devframe = defineDevframe({ id: 'devframe-auth-off', diff --git a/packages/devframe/src/adapters/dev.ts b/packages/devframe/src/adapters/dev.ts index 454422ab..706d04b5 100644 --- a/packages/devframe/src/adapters/dev.ts +++ b/packages/devframe/src/adapters/dev.ts @@ -241,7 +241,7 @@ export async function createDevServer( // so the code is on screen by the time a browser lands on the page. authHandler?.printBanner() await options.onReady?.(info) - await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser) + await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser, authHandler) }, }) @@ -303,6 +303,7 @@ async function maybeOpenBrowser( flags: Record, origin: string, override: boolean | string | undefined, + authHandler: DevframeAuthHandler | undefined, ): Promise { const flagsOpen = flags.open as boolean | string | undefined const cliOpen = def.cli?.open @@ -314,8 +315,12 @@ async function maybeOpenBrowser( const target = typeof resolved === 'string' ? withBase(resolved, origin) : origin + // When the server is auth-gated, let the handler embed a one-time + // credential (e.g. the OTP query param) in the opened URL so the tab + // lands already authorized instead of prompting the user. + const authorizedTarget = authHandler?.buildOpenUrl?.(target) ?? target try { - await open(target) + await open(authorizedTarget) } catch { // Failing to launch a browser shouldn't break the dev server. diff --git a/packages/devframe/src/client/rpc-auth-gate.test.ts b/packages/devframe/src/client/rpc-auth-gate.test.ts new file mode 100644 index 00000000..34cb3d51 --- /dev/null +++ b/packages/devframe/src/client/rpc-auth-gate.test.ts @@ -0,0 +1,140 @@ +import type { ConnectionMeta } from 'devframe/types' +import type { DevframeRpcClientMode } from './rpc' +import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest' + +// `getDevframeRpcClient` kicks off the auth bootstrap (`requestTrust`, then +// the URL OTP, then a native-prompt fallback) without awaiting it, so it can +// return the client immediately. The regression this guards is a caller's +// very first `rpc.call(...)` — issued the moment `connectDevframe()` resolves +// — racing that still-in-flight bootstrap: without a gate, the transport +// sends the call over an already-open-but-not-yet-trusted socket and the +// server rejects it with DF0036, even though the exact same call succeeds a +// moment later once the handshake lands. +// +// Exercising this through a real WebSocket would mean hand-rolling birpc's +// wire protocol, so instead this mocks `./rpc-ws` with a controllable fake +// mode — a `requestTrust()` the test resolves on its own schedule, and a +// `call` spy whose invocation timing is exactly what's under test. +const fakeMode = vi.hoisted(() => { + const requestTrustGate = { resolve: undefined as ((value: boolean) => void) | undefined } + const call = vi.fn(async () => 'ok') + const callOptional = vi.fn(async () => 'ok') + const callEvent = vi.fn(async () => {}) + return { requestTrustGate, call, callOptional, callEvent } +}) + +vi.mock('./rpc-ws', () => ({ + createWsRpcClientMode: vi.fn((): DevframeRpcClientMode => ({ + isTrusted: false, + status: 'connecting', + connectionError: null, + ensureTrusted: async () => true, + requestTrust: () => new Promise((resolve) => { + fakeMode.requestTrustGate.resolve = resolve + }), + requestTrustWithToken: async () => true, + requestTrustWithCode: async () => null, + call: fakeMode.call as DevframeRpcClientMode['call'], + callOptional: fakeMode.callOptional as DevframeRpcClientMode['callOptional'], + callEvent: fakeMode.callEvent as DevframeRpcClientMode['callEvent'], + })), +})) + +class FakeBroadcastChannel { + onmessage: ((e: any) => void) | null = null + postMessage(): void {} + close(): void {} +} + +const connectionMeta: ConnectionMeta = { backend: 'websocket', websocket: { path: '__ws' } } + +describe('getDevframeRpcClient — auth bootstrap gates outbound calls', () => { + beforeEach(() => { + fakeMode.requestTrustGate.resolve = undefined + fakeMode.call.mockClear() + fakeMode.callOptional.mockClear() + fakeMode.callEvent.mockClear() + vi.stubGlobal('BroadcastChannel', FakeBroadcastChannel) + vi.stubGlobal('navigator', { userAgent: 'test' }) + vi.stubGlobal('location', { + protocol: 'http:', + host: 'localhost:5173', + hostname: 'localhost', + href: 'http://localhost:5173/index.html', + origin: 'http://localhost:5173', + }) + }) + + afterEach(() => { + vi.unstubAllGlobals() + }) + + it('holds `call` until the in-flight bootstrap settles, instead of sending it early', async () => { + const { getDevframeRpcClient } = await import('./rpc') + const rpc = await getDevframeRpcClient({ + connectionMeta, + otpParam: false, + simpleAuth: false, + }) + + // Fired the instant the client is available — mirrors a component's + // `onMount` calling a trusted method right after `connectDevframe()`. + const pending = rpc.call('test:probe' as any) + await Promise.resolve() + await Promise.resolve() + // The bootstrap's `requestTrust()` hasn't resolved yet — the call must + // not have reached the (mocked) transport. + expect(fakeMode.call).not.toHaveBeenCalled() + + // The handshake lands. + fakeMode.requestTrustGate.resolve?.(true) + + await expect(pending).resolves.toBe('ok') + expect(fakeMode.call).toHaveBeenCalledTimes(1) + expect(fakeMode.call).toHaveBeenCalledWith('test:probe') + }) + + it('holds `callOptional` and `callEvent` the same way', async () => { + const { getDevframeRpcClient } = await import('./rpc') + const rpc = await getDevframeRpcClient({ + connectionMeta, + otpParam: false, + simpleAuth: false, + }) + + const pendingOptional = rpc.callOptional('test:optional' as any) + const pendingEvent = rpc.callEvent('test:event' as any) + await Promise.resolve() + await Promise.resolve() + expect(fakeMode.callOptional).not.toHaveBeenCalled() + expect(fakeMode.callEvent).not.toHaveBeenCalled() + + fakeMode.requestTrustGate.resolve?.(true) + + await expect(pendingOptional).resolves.toBe('ok') + await pendingEvent + expect(fakeMode.callOptional).toHaveBeenCalledTimes(1) + expect(fakeMode.callEvent).toHaveBeenCalledTimes(1) + }) + + it('stops gating once the first bootstrap attempt has settled', async () => { + const { getDevframeRpcClient } = await import('./rpc') + const rpc = await getDevframeRpcClient({ + connectionMeta, + otpParam: false, + simpleAuth: false, + }) + + fakeMode.requestTrustGate.resolve?.(true) + // Let the bootstrap's own promise chain fully settle before the next + // call — a few microtask ticks cover `await mode.requestTrust()` + // resuming, `bootstrapAuth()` returning, and its `.then()` flipping + // `bootstrapAuthSettled`. + for (let i = 0; i < 5; i++) + await Promise.resolve() + + await rpc.call('test:probe' as any) + // Sent straight through — no more waiting once bootstrap is over. + expect(fakeMode.call).toHaveBeenCalledTimes(1) + }) +}) diff --git a/packages/devframe/src/client/rpc.ts b/packages/devframe/src/client/rpc.ts index 5b1be907..0704c60b 100644 --- a/packages/devframe/src/client/rpc.ts +++ b/packages/devframe/src/client/rpc.ts @@ -408,6 +408,30 @@ export async function getDevframeRpcClient( } catch {} + // Gate outbound calls behind the auth bootstrap kicked off below (stored + // auth token, then the URL's magic-link OTP, then — standalone/top-level + // only — a native prompt). Without this, a caller's very first RPC calls + // (fired the moment `connectDevframe()` resolves — e.g. a component's + // `onMount`) race that still-in-flight sequence: the socket is open, so + // the transport happily sends them, and the server rejects them with + // DF0036 because trust hasn't landed yet — even though the exact same call + // would succeed a moment later. `bootstrapAuthPromise` is assigned once + // `bootstrapAuth()` is kicked off further down; these closures read the + // variable at call time, so the gate is live even though it's declared + // before that assignment happens. Once the first bootstrap attempt has + // settled (trusted, refused, or given up), `bootstrapAuthSettled` flips + // for good and every later call skips the gate entirely — this only ever + // holds the first wave of calls. + let bootstrapAuthPromise: Promise | undefined + let bootstrapAuthSettled = false + function gateOnBootstrapAuth any>(fn: F): F { + return ((...args: any[]) => { + if (bootstrapAuthSettled || !bootstrapAuthPromise) + return fn(...args) + return bootstrapAuthPromise.then(() => fn(...args)) + }) as F + } + const rpc: DevframeRpcClient = { events, get isTrusted() { @@ -440,9 +464,9 @@ export async function getDevframeRpcClient( catch {} return true }, - call: mode.call, - callEvent: mode.callEvent, - callOptional: mode.callOptional, + call: gateOnBootstrapAuth(mode.call), + callEvent: gateOnBootstrapAuth(mode.callEvent), + callOptional: gateOnBootstrapAuth(mode.callOptional), client: clientRpc, sharedState: undefined!, streaming: undefined!, @@ -522,7 +546,14 @@ export async function getDevframeRpcClient( return await runSimpleAuthPrompt() } - void bootstrapAuth() + // Always resolves (never rejects) regardless of `bootstrapAuth`'s outcome — + // the gate only cares that the first attempt is *over*, not whether it + // succeeded; a rejection here must never leak into unrelated calls waiting + // on `bootstrapAuthPromise`. + bootstrapAuthPromise = bootstrapAuth().then( + () => { bootstrapAuthSettled = true }, + () => { bootstrapAuthSettled = true }, + ) // Listen for auth updates from other tabs (e.g., the auth page, or another // tab that just completed a code exchange). diff --git a/packages/devframe/src/node/auth/handler.ts b/packages/devframe/src/node/auth/handler.ts index 5b4650ab..83057788 100644 --- a/packages/devframe/src/node/auth/handler.ts +++ b/packages/devframe/src/node/auth/handler.ts @@ -40,4 +40,13 @@ export interface DevframeAuthHandler { * call repeatedly; it only prints once per code. */ printBanner: () => void + /** + * Rewrite a URL the CLI is about to open in the browser (`--open`) so the + * tab lands already authenticated — e.g. appending the current OTP as a + * query param. Called with the fully-resolved target URL; return it + * unchanged to opt out. Optional: a handler that doesn't need this (e.g. + * one gated by a pre-shared bearer token instead of an interactive code) + * can omit it and the URL opens as-is. + */ + buildOpenUrl?: (url: string) => string } diff --git a/packages/devframe/src/recipes/interactive-auth.ts b/packages/devframe/src/recipes/interactive-auth.ts index 74d2f88c..a52ea783 100644 --- a/packages/devframe/src/recipes/interactive-auth.ts +++ b/packages/devframe/src/recipes/interactive-auth.ts @@ -166,10 +166,15 @@ export function createInteractiveAuth( verifyAuthToken(token, session, storage) } + function buildOpenUrl(url: string): string { + return buildOtpAuthUrl(url) + } + return { rpcFunctions: [anonymousAuth, anonymousAuthExchange, revoke], authorize, onConnect: onConnect as DevframeAuthHandler['onConnect'], printBanner, + buildOpenUrl, } } diff --git a/plugins/code-server/src/index.ts b/plugins/code-server/src/index.ts index f7f54020..e371fd1b 100644 --- a/plugins/code-server/src/index.ts +++ b/plugins/code-server/src/index.ts @@ -55,10 +55,10 @@ export function createCodeServerDevframe(options: CodeServerOptions = {}): Devfr portRange: options.portRange, random: options.random, distDir: existsSync(resolvedDist) ? resolvedDist : undefined, - // Single-user localhost tool: auto-trust the connection so the launcher's - // shared-state subscription and the auth handoff work without a manual - // round-trip. Hosted adapters supply their own auth layer. - auth: false, + // Gate the standalone launcher by default; `maybeOpenBrowser` folds the + // current OTP into the `--open` URL so the tab lands already trusted. + // Hosted adapters supply their own auth layer and ignore this. + auth: options.auth ?? true, }, spa: { loader: 'none' }, async setup(ctx) { diff --git a/plugins/code-server/src/types.ts b/plugins/code-server/src/types.ts index 43c98dbb..8d42e920 100644 --- a/plugins/code-server/src/types.ts +++ b/plugins/code-server/src/types.ts @@ -174,6 +174,13 @@ export interface CodeServerOptions { portRange?: [number, number] /** Prefer a random port for the launcher SPA. */ random?: boolean + /** + * Require the trust handshake on the standalone launcher server. Enabled + * by default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. + */ + auth?: boolean } /** Options for `mode: 'tunnel'`. */ diff --git a/plugins/data-inspector/src/cli.ts b/plugins/data-inspector/src/cli.ts index 5d43e02c..3cfc572e 100644 --- a/plugins/data-inspector/src/cli.ts +++ b/plugins/data-inspector/src/cli.ts @@ -52,7 +52,10 @@ export function createDataInspectorCli() { .option('--open', 'Open the browser on start') .option('--no-open', 'Do not open the browser') .option('--no-example', 'Skip the built-in example source') - .action(async (files: string[], flags: { port?: number, host: string, open?: boolean, example?: boolean }) => { + // Standalone auth is on by default; `--no-auth` opts a one-off run out + // of the interactive OTP gate (see `createCac`'s equivalent flag). + .option('--no-auth', 'Disable the interactive authentication gate') + .action(async (files: string[], flags: { port?: number, host: string, open?: boolean, example?: boolean, auth?: boolean }) => { for (const file of files) registerDataSource(createFileDataSource(file)) const def = createDataInspectorDevframe({ exampleSource: flags.example }) diff --git a/plugins/data-inspector/src/index.ts b/plugins/data-inspector/src/index.ts index cda661bd..28050ea1 100644 --- a/plugins/data-inspector/src/index.ts +++ b/plugins/data-inspector/src/index.ts @@ -31,9 +31,11 @@ export interface DataInspectorDevframeOptions { /** Preferred standalone CLI port. */ port?: number /** - * Require the trust handshake on the standalone server. Defaults to - * `false` (auto-trust) for the single-user localhost CLI. The in-process - * agent (`@devframes/plugin-data-inspector/inject`) defaults to `true`. + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. The in-process + * agent (`@devframes/plugin-data-inspector/inject`) uses its own + * pre-shared-token scheme and is unaffected by this option. */ auth?: boolean /** @@ -72,7 +74,7 @@ export function createDataInspectorDevframe(options: DataInspectorDevframeOption command: 'data-inspector', port: options.port ?? DEFAULT_PORT, distDir: existsSync(distDir) ? distDir : undefined, - auth: options.auth ?? false, + auth: options.auth ?? true, }, spa: { loader: 'none' }, dock: { category: '~builtin' }, diff --git a/plugins/git/src/index.ts b/plugins/git/src/index.ts index 2dfd8059..5628f97c 100644 --- a/plugins/git/src/index.ts +++ b/plugins/git/src/index.ts @@ -37,6 +37,13 @@ export interface GitDevframeOptions { * default; the standalone CLI also accepts a `--write` flag. */ write?: boolean + /** + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. + */ + auth?: boolean } /** @@ -61,7 +68,9 @@ export function createGitDevframe(options: GitDevframeOptions = {}): DevframeDef command: 'devframe-git', port: options.port ?? 9710, distDir, - auth: false, + // Gate the standalone server by default; `maybeOpenBrowser` folds the + // current OTP into the `--open` URL so the tab lands already trusted. + auth: options.auth ?? true, configure(cli) { cli.option('--write', 'Enable staging, unstaging, and committing from the UI') }, diff --git a/plugins/inspect/src/index.ts b/plugins/inspect/src/index.ts index dbc79753..e9ac000f 100644 --- a/plugins/inspect/src/index.ts +++ b/plugins/inspect/src/index.ts @@ -28,9 +28,10 @@ export interface InspectDevframeOptions { /** Preferred standalone CLI port. */ port?: number /** - * Require the trust handshake on the standalone server. Defaults to - * `false` (auto-trust) since the inspector is a single-user localhost - * tool. Hosted adapters manage their own auth. + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. */ auth?: boolean } @@ -58,11 +59,10 @@ export function createInspectDevframe(options: InspectDevframeOptions = {}): Dev command: id, port: options.port ?? 9012, distDir: existsSync(distDir) ? distDir : undefined, - // A single-user localhost inspector: skip the trust handshake so - // the SPA's shared-state subscription initializes without a manual - // auth round-trip. Hosted adapters (Vite/hub) supply their own - // auth layer and ignore this. - auth: options.auth ?? false, + // Gate the standalone server by default; `maybeOpenBrowser` folds the + // current OTP into the `--open` URL so the tab lands already trusted. + // Hosted adapters (Vite/hub) supply their own auth layer and ignore this. + auth: options.auth ?? true, }, spa: { loader: 'none' }, dock: { diff --git a/plugins/messages/src/index.ts b/plugins/messages/src/index.ts index e31bb21d..ca8a5768 100644 --- a/plugins/messages/src/index.ts +++ b/plugins/messages/src/index.ts @@ -26,9 +26,10 @@ export interface MessagesDevframeOptions { /** Preferred standalone CLI port. */ port?: number /** - * Require the trust handshake on the standalone server. Defaults to - * `false` (auto-trust) since the panel is a single-user localhost tool. - * Hosted adapters manage their own auth. + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. */ auth?: boolean } @@ -58,10 +59,10 @@ export function createMessagesDevframe(options: MessagesDevframeOptions = {}): D command: id, port: options.port ?? DEFAULT_PORT, distDir: existsSync(distDir) ? distDir : undefined, - // A single-user localhost panel: skip the trust handshake so the SPA - // initializes without a manual auth round-trip. Hosted adapters - // (Vite/hub) supply their own auth layer and ignore this. - auth: options.auth ?? false, + // Gate the standalone server by default; `maybeOpenBrowser` folds the + // current OTP into the `--open` URL so the tab lands already trusted. + // Hosted adapters (Vite/hub) supply their own auth layer and ignore this. + auth: options.auth ?? true, }, dock: { category: '~builtin', diff --git a/plugins/terminals/src/index.ts b/plugins/terminals/src/index.ts index ed318d17..578777ed 100644 --- a/plugins/terminals/src/index.ts +++ b/plugins/terminals/src/index.ts @@ -56,9 +56,10 @@ export function createTerminalsDevframe(options: TerminalsOptions = {}): Devfram command: options.command ?? 'devframe-terminals', port: options.port ?? DEFAULT_PORT, distDir, - // Single-user localhost tool: auto-trust the connection so streaming - // and shared-state sync work without an auth round-trip. - auth: false, + // Gate the standalone server by default — shell access is sensitive. + // `maybeOpenBrowser` folds the current OTP into the `--open` URL so + // the tab lands already trusted. + auth: options.auth ?? true, }, spa: { loader: 'none' }, dock: { diff --git a/plugins/terminals/src/types.ts b/plugins/terminals/src/types.ts index 47a230c7..36c07bba 100644 --- a/plugins/terminals/src/types.ts +++ b/plugins/terminals/src/types.ts @@ -137,4 +137,11 @@ export interface TerminalsOptions { command?: string /** Preferred dev-server port. */ port?: number + /** + * Require the trust handshake on the standalone server. Enabled by + * default — `--open` embeds the current OTP in the opened URL, so the + * tab authenticates automatically without extra prompts. Hosted adapters + * manage their own auth and ignore this. + */ + auth?: boolean } diff --git a/skills/devframe/SKILL.md b/skills/devframe/SKILL.md index 1dfccebc..60d1b2d9 100644 --- a/skills/devframe/SKILL.md +++ b/skills/devframe/SKILL.md @@ -569,7 +569,7 @@ RPC handlers run with the full privileges of the host process, so the boundary t - **`auth` defaults to `true`** — dev-mode connections must authenticate before calls are accepted. Devframe ships the node primitives (`exchangeTempAuthCode`, `verifyAuthToken` in `devframe/node/auth`); the host adapter (e.g. Vite DevTools) provides the interactive `devframe:anonymous:auth` + `devframe:auth:exchange` handlers and auth UI. - **`auth: false` trusts every reachable connection.** Use it only for single-user `localhost` tools. Never combine it with a non-loopback bind host, a tunnel, or a shared/CI environment. The default bind host is already `localhost`. - **Authentication** exchanges a 6-digit one-time code (shown in the developer's terminal) for a node-issued bearer token via `requestTrustWithCode(code)`. The code is single-use, expires in 5 min, compared in constant time, and rotates after repeated failures — show it only in the terminal, never over the network. -- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. +- **Magic-link (optional):** print `buildOtpAuthUrl(origin)` — `/?devframe_otp=`. `connectDevframe` reads the code, exchanges it, and strips it from the URL. Integrations can opt out (`otpParam: false`) and drive it via the exposed `authenticateWithUrlOtp(rpc)` / `consumeOtpFromUrl()` client utilities. Only the single-use code rides the URL, never the bearer; treat the printed link like the code itself. The standalone CLI's `--open` does this automatically via `DevframeAuthHandler.buildOpenUrl` — the launched tab already carries the OTP, no prompt needed. - **Tokens are secrets.** The bearer token rides the WS URL (`?devframe_auth_token=…`) — serve over `wss://`/`https://` beyond loopback. Never log the token or code, never bake them into build output. Revoke via `revokeAuthToken(...)`; clients drop to untrusted on `devframe:auth:revoked`. - **Authorize handlers.** Any trusted client can call any registered function — validate inputs, and mark state-changing functions `type: 'destructive'` so MCP/agent clients prompt first. - **Origin-lock remote docks** (`originLock`) so a dock token is honored only from its expected origin. diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-code-server/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-code-server/types.snapshot.d.ts index 0d202e4c..8930d7a2 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-code-server/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-code-server/types.snapshot.d.ts @@ -42,6 +42,7 @@ export interface CodeServerOptions { port?: number; portRange?: [number, number]; random?: boolean; + auth?: boolean; } export interface CodeServerServerInfo { status: CodeServerStatus; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts index 4249f94d..07560110 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-git/index.snapshot.d.ts @@ -81,6 +81,7 @@ export interface GitDevframeOptions { distDir?: string; port?: number; write?: boolean; + auth?: boolean; } export interface GitDiff { isRepo: boolean; diff --git a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts index 3d1af5e0..7808c701 100644 --- a/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/plugin-terminals/types.snapshot.d.ts @@ -59,6 +59,7 @@ export interface TerminalsOptions { distDir?: string; command?: string; port?: number; + auth?: boolean; } export interface TerminalsSharedState { sessions: TerminalSessionInfo[];