Skip to content

Commit 9ed9874

Browse files
antfubotopencode
andcommitted
feat(cli): enable auth by default and auto-authorize --open via OTP
Every built-in plugin's standalone CLI now gates the dev server behind devframe's interactive OTP handshake by default (code-server, git, inspect, messages, terminals, data-inspector previously opted out; og/a11y already defaulted on). Each plugin keeps an `auth` option to opt back out for single-user/CI use. To keep that gate from adding friction, `--open` now asks the resolved auth handler to rewrite the launched URL — the interactive handler embeds the current OTP (via buildOtpAuthUrl), so the opened tab authenticates automatically instead of prompting for a code. Adds DevframeAuthHandler.buildOpenUrl (optional) as the seam, wires it through maybeOpenBrowser in the dev adapter, and updates docs/skill guidance + tsnapi snapshots accordingly. Co-authored-by: opencode <noreply@opencode.ai>
1 parent 5d82369 commit 9ed9874

21 files changed

Lines changed: 149 additions & 39 deletions

File tree

docs/adapters/cac.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -75,8 +75,7 @@ defineDevframe({
7575
portRange: [7777, 9000], // passed through to get-port-please
7676
random: false, // passed through to get-port-please
7777
host: '127.0.0.1', // default host; --host overrides
78-
open: true, // auto-open the browser on dev start
79-
auth: false, // skip the trust handshake (single-user localhost)
78+
open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated
8079
configure(cli) { // contribute capability flags/commands
8180
cli.option('--config <file>', 'Custom config file')
8281
.option('--no-files', 'Skip file matching')

docs/guide/devframe-definition.md

Lines changed: 2 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -185,8 +185,7 @@ defineDevframe({
185185
portRange: [9876, 10000], // forwarded to get-port-please
186186
random: false, // forwarded to get-port-please
187187
host: 'localhost', // default host; --host overrides
188-
open: true, // auto-open the browser on dev start
189-
auth: false, // skip the trust handshake (single-user localhost)
188+
open: true, // auto-open the browser on dev start; embeds the current OTP so the tab lands authenticated
190189
configure(cli) { // contribute capability flags/commands
191190
cli
192191
.option('--my-flag <value>', 'Tool-specific flag')
@@ -208,7 +207,7 @@ defineDevframe({
208207
| `portRange` | `[number, number]` | Port scan range, passed through to `get-port-please`. |
209208
| `random` | `boolean` | Prefer a random open port. |
210209
| `host` | `string` | Default bind host. |
211-
| `open` | `boolean \| string` | `true` opens the origin, a string opens a specific path, `false` disables. Matches the `--open` / `--no-open` flags. |
210+
| `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. |
212211
| `auth` | `boolean` | Disable the WS trust flow when the tool is localhost-only and single-user. Default `true`. |
213212
| `configure` | `(cli: CAC) => void` | Contribute capability flags/commands. Runs before `createCac`'s `configureCli` option so the final tool author always has the last word. |
214213

docs/guide/security.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -79,7 +79,7 @@ Client methods (`devframe/client`): `requestTrustWithCode(code)` (exchange a cod
7979

8080
### Magic-link authentication
8181

82-
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):
82+
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):
8383

8484
```
8585
Devtools ready — authenticate this browser: http://localhost:3000/?devframe_otp=123456

docs/guide/standalone-cli.md

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -43,8 +43,7 @@ const devframe = defineDevframe({
4343
distDir,
4444
port: 7777,
4545
portRange: [7777, 9000],
46-
open: true,
47-
auth: false, // single-user localhost — skip the trust handshake
46+
open: true, // auth defaults to on; `--open` embeds the current OTP so the tab lands authenticated
4847
configure(cli) {
4948
cli
5049
.option('--config <file>', 'Config file path')

packages/devframe/src/adapters/__tests__/dev.test.ts

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,18 @@ import { tmpdir } from 'node:os'
44
import { join } from 'node:path'
55
import { createRpcClient } from 'devframe/rpc/client'
66
import { createWsRpcChannel } from 'devframe/rpc/transports/ws-client'
7+
import { open } from 'devframe/utils/open'
78
import { getPort } from 'get-port-please'
89
import { describe, expect, it, vi } from 'vitest'
910
import { WebSocket } from 'ws'
1011
import { getTempAuthCode } from '../../node/auth/state'
1112
import { defineDevframe } from '../../types/devframe'
1213
import { createDevServer, resolveDevServerPort } from '../dev'
1314

15+
// `--open` is exercised directly (asserting the URL handed to the OS opener)
16+
// rather than actually launching a browser in CI.
17+
vi.mock('devframe/utils/open', () => ({ open: vi.fn(async () => {}) }))
18+
1419
function connectWsClient(host: string, port: number, authToken?: string) {
1520
return createRpcClient<DevframeRpcServerFunctions, DevframeRpcClientFunctions>(
1621
{} as DevframeRpcClientFunctions,
@@ -323,6 +328,62 @@ describe('adapters/dev', () => {
323328
}
324329
})
325330

331+
it('`--open` embeds the current OTP so the opened tab authenticates automatically', async () => {
332+
const devframe = defineDevframe({
333+
id: 'devframe-auth-open',
334+
name: 'Auth Open',
335+
version: '0.0.0',
336+
packageName: 'devframe-test',
337+
homepage: 'https://example.test',
338+
description: 'Test devframe.',
339+
setup: () => {},
340+
})
341+
const host = '127.0.0.1'
342+
const port = await getPort({ port: 19415, host })
343+
const spy = vi.spyOn(console, 'log').mockImplementation(() => {})
344+
const mockedOpen = vi.mocked(open)
345+
mockedOpen.mockClear()
346+
const code = getTempAuthCode()
347+
const handle = await createDevServer(devframe, { host, port, openBrowser: true })
348+
349+
try {
350+
expect(mockedOpen).toHaveBeenCalledTimes(1)
351+
const [target] = mockedOpen.mock.calls[0]
352+
expect(target).toBe(`http://localhost:${port}/?devframe_otp=${code}`)
353+
}
354+
finally {
355+
spy.mockRestore()
356+
await handle.close()
357+
}
358+
})
359+
360+
it('`--open` opens the plain origin when `auth: false` (no handler to embed a code)', async () => {
361+
const devframe = defineDevframe({
362+
id: 'devframe-auth-open-off',
363+
name: 'Auth Open Off',
364+
version: '0.0.0',
365+
packageName: 'devframe-test',
366+
homepage: 'https://example.test',
367+
description: 'Test devframe.',
368+
cli: { auth: false },
369+
setup: () => {},
370+
})
371+
const host = '127.0.0.1'
372+
const port = await getPort({ port: 19416, host })
373+
const mockedOpen = vi.mocked(open)
374+
mockedOpen.mockClear()
375+
const handle = await createDevServer(devframe, { host, port, openBrowser: true })
376+
377+
try {
378+
expect(mockedOpen).toHaveBeenCalledTimes(1)
379+
const [target] = mockedOpen.mock.calls[0]
380+
expect(target).toBe(`http://localhost:${port}/`)
381+
}
382+
finally {
383+
await handle.close()
384+
}
385+
})
386+
326387
it('opts out with `auth: false`: the server auto-trusts and skips the gate', async () => {
327388
const devframe = defineDevframe({
328389
id: 'devframe-auth-off',

packages/devframe/src/adapters/dev.ts

Lines changed: 7 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -241,7 +241,7 @@ export async function createDevServer(
241241
// so the code is on screen by the time a browser lands on the page.
242242
authHandler?.printBanner()
243243
await options.onReady?.(info)
244-
await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser)
244+
await maybeOpenBrowser(def, flags, `${info.origin}${basePath}`, options.openBrowser, authHandler)
245245
},
246246
})
247247

@@ -303,6 +303,7 @@ async function maybeOpenBrowser(
303303
flags: Record<string, unknown>,
304304
origin: string,
305305
override: boolean | string | undefined,
306+
authHandler: DevframeAuthHandler | undefined,
306307
): Promise<void> {
307308
const flagsOpen = flags.open as boolean | string | undefined
308309
const cliOpen = def.cli?.open
@@ -314,8 +315,12 @@ async function maybeOpenBrowser(
314315
const target = typeof resolved === 'string'
315316
? withBase(resolved, origin)
316317
: origin
318+
// When the server is auth-gated, let the handler embed a one-time
319+
// credential (e.g. the OTP query param) in the opened URL so the tab
320+
// lands already authorized instead of prompting the user.
321+
const authorizedTarget = authHandler?.buildOpenUrl?.(target) ?? target
317322
try {
318-
await open(target)
323+
await open(authorizedTarget)
319324
}
320325
catch {
321326
// Failing to launch a browser shouldn't break the dev server.

packages/devframe/src/node/auth/handler.ts

Lines changed: 9 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -40,4 +40,13 @@ export interface DevframeAuthHandler {
4040
* call repeatedly; it only prints once per code.
4141
*/
4242
printBanner: () => void
43+
/**
44+
* Rewrite a URL the CLI is about to open in the browser (`--open`) so the
45+
* tab lands already authenticated — e.g. appending the current OTP as a
46+
* query param. Called with the fully-resolved target URL; return it
47+
* unchanged to opt out. Optional: a handler that doesn't need this (e.g.
48+
* one gated by a pre-shared bearer token instead of an interactive code)
49+
* can omit it and the URL opens as-is.
50+
*/
51+
buildOpenUrl?: (url: string) => string
4352
}

packages/devframe/src/recipes/interactive-auth.ts

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -166,10 +166,15 @@ export function createInteractiveAuth(
166166
verifyAuthToken(token, session, storage)
167167
}
168168

169+
function buildOpenUrl(url: string): string {
170+
return buildOtpAuthUrl(url)
171+
}
172+
169173
return {
170174
rpcFunctions: [anonymousAuth, anonymousAuthExchange, revoke],
171175
authorize,
172176
onConnect: onConnect as DevframeAuthHandler['onConnect'],
173177
printBanner,
178+
buildOpenUrl,
174179
}
175180
}

plugins/code-server/src/index.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -55,10 +55,10 @@ export function createCodeServerDevframe(options: CodeServerOptions = {}): Devfr
5555
portRange: options.portRange,
5656
random: options.random,
5757
distDir: existsSync(resolvedDist) ? resolvedDist : undefined,
58-
// Single-user localhost tool: auto-trust the connection so the launcher's
59-
// shared-state subscription and the auth handoff work without a manual
60-
// round-trip. Hosted adapters supply their own auth layer.
61-
auth: false,
58+
// Gate the standalone launcher by default; `maybeOpenBrowser` folds the
59+
// current OTP into the `--open` URL so the tab lands already trusted.
60+
// Hosted adapters supply their own auth layer and ignore this.
61+
auth: options.auth ?? true,
6262
},
6363
spa: { loader: 'none' },
6464
async setup(ctx) {

plugins/code-server/src/types.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -174,6 +174,13 @@ export interface CodeServerOptions {
174174
portRange?: [number, number]
175175
/** Prefer a random port for the launcher SPA. */
176176
random?: boolean
177+
/**
178+
* Require the trust handshake on the standalone launcher server. Enabled
179+
* by default — `--open` embeds the current OTP in the opened URL, so the
180+
* tab authenticates automatically without extra prompts. Hosted adapters
181+
* manage their own auth and ignore this.
182+
*/
183+
auth?: boolean
177184
}
178185

179186
/** Options for `mode: 'tunnel'`. */

0 commit comments

Comments
 (0)