Files
WeHub Mirror 6bf8bebf51
CI / Test and Build (push) Failing after 1s
CI / Migrate Dev DB (push) Has been skipped
CI / Migrate DB (push) Has been skipped
CodeQL / Analyze actions (push) Has been cancelled
CodeQL / Analyze javascript-typescript (push) Has been cancelled
CI / Detect Version (push) Has been cancelled
CI / Detect Desktop Changes (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/cron.Dockerfile, ubuntu-latest, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build AMD64 (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build AMD64 (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build AMD64 (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/cron.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/db.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/pii.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-4vcpu-ubuntu-2404-arm, ./docker/realtime.Dockerfile, ubuntu-24.04-arm, ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build ARM64 (GHCR Only) (blacksmith-8vcpu-ubuntu-2404-arm, ./docker/app.Dockerfile, linux-arm64-8-core, ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Check Docs Changes (push) Has been cancelled
Publish CLI Package / publish-npm (push) Has been cancelled
Publish Python SDK / publish-pypi (push) Has been cancelled
CI / Deploy Trigger.dev (Dev) (push) Has been cancelled
Helm Chart / Lint, test, and validate chart (push) Has been cancelled
Helm Chart / Chart version bumped (push) Has been cancelled
Publish TypeScript SDK / publish-npm (push) Has been cancelled
CI / Build Dev ECR (blacksmith-8vcpu-ubuntu-2404, ./docker/app.Dockerfile, ECR_APP, linux-x64-8-core) (push) Has been cancelled
CI / Promote Images (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/cron) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/migrations) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/pii) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/realtime) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-2vcpu-ubuntu-2404, ./docker/db.Dockerfile, ECR_MIGRATIONS, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/pii.Dockerfile, ECR_PII, ubuntu-latest) (push) Has been cancelled
CI / Build Dev ECR (blacksmith-4vcpu-ubuntu-2404, ./docker/realtime.Dockerfile, ECR_REALTIME, ubuntu-latest) (push) Has been cancelled
CI / Create GHCR Manifests (ghcr.io/simstudioai/simstudio) (push) Has been cancelled
CI / Process Docs (push) Has been cancelled
CI / Create GitHub Release (push) Has been cancelled
CI / Check Desktop Signing Secrets (push) Has been cancelled
CI / Desktop Release (push) Has been cancelled
CI / Create Desktop Prerelease (push) Has been cancelled
CI / Desktop Prerelease Build (push) Has been cancelled
CI / Publish Desktop Prerelease (push) Has been cancelled
CI / Prune Desktop Prereleases (push) Has been cancelled
Helm Chart / Install on kind and run helm test (push) Has been cancelled
WeHub snapshot of cb28d14c6f2c081de7a0d8729a8c816c9adef67a
2026-08-10 11:17:50 +08:00

127 lines
4.9 KiB
TypeScript

import { spawnSync } from 'node:child_process'
import { sha256Base64Url } from '@sim/security/hash'
import { generateSecureToken } from '@sim/security/tokens'
import { sleep } from '@sim/utils/helpers'
import { generateShortId } from '@sim/utils/id'
import { parseRetryAfter } from '@sim/utils/retry'
import * as p from './prompter.ts'
import { link, theme } from './theme.ts'
// Generous enough for a first-time user to create an account, wait for the email
// OTP, land back on /cli/auth, and approve — a few minutes is routine. The
// server-side approval record has its own short TTL, so a long client wait only
// costs cheap, rate-limited polls.
const WAIT_MS = 900_000
const POLL_INTERVAL_MS = 2000
function openBrowser(url: string): void {
if (process.env.SIM_SETUP_NO_BROWSER) return
if (process.platform === 'win32') {
// `start` is a cmd builtin, not an executable — spawning it directly ENOENTs.
// cmd re-parses the command line and would treat `&` in the query string as a
// command separator, truncating the URL; quote it (the query is URL-encoded, so
// it never contains a `"`) and pass args verbatim so Node doesn't re-quote them.
// `""` is start's window-title placeholder, required before the URL.
spawnSync('cmd', ['/c', 'start', '""', `"${url}"`], {
stdio: 'ignore',
windowsVerbatimArguments: true,
})
return
}
const command = process.platform === 'darwin' ? 'open' : 'xdg-open'
spawnSync(command, [url], { stdio: 'ignore' })
}
/** No O/0 or I/1 — this exists to be compared by eye against a browser tab. */
const PAIRING_ALPHABET = 'ABCDEFGHJKLMNPQRSTUVWXYZ23456789'
/**
* Short human-comparable code, shown in this terminal and on the approval page.
*
* The poll secret binds the *key* to this process, but nothing cryptographic
* tells the user whether the page they're approving belongs to their terminal
* or to a link someone sent them — an attacker supplies the request id and
* challenge. Comparing this code is the only thing that distinguishes the two.
*/
function createPairingCode(): string {
const chars = generateShortId(8, PAIRING_ALPHABET)
return `${chars.slice(0, 4)}-${chars.slice(4)}`
}
interface PollResponse {
status: 'pending' | 'complete'
key?: { apiKey?: string }
}
/**
* Device-flow handoff: open the approval page and poll for the key over TLS.
*
* No loopback listener — the terminal and browser need not share a machine, so
* this works over SSH and inside containers. The poll secret never leaves this
* process; only its digest reaches the server, so an observer of the request id
* cannot mint. Returns the key, or null on timeout / failed poll (re-run to
* retry). Ctrl-C exits setup via the SIGINT handler.
*/
export async function browserKeyFlow(origin: string): Promise<string | null> {
const request = generateSecureToken(32)
const pollSecret = generateSecureToken(32)
const challenge = sha256Base64Url(pollSecret)
const pairingCode = createPairingCode()
const query = new URLSearchParams({ request, challenge, pairing: pairingCode })
const authUrl = `${origin}/cli/auth?${query}`
p.note(
`${theme.heading(pairingCode)}\n\n${theme.muted('The page should show this code. If it shows a different one,\nthe request is not from this terminal — close the tab.')}`,
'Confirm this code in your browser'
)
p.log.info(
`Opening your browser — create your account (or sign in) and approve; the key comes back automatically.\n If it doesn't open: ${link(authUrl, authUrl)}`
)
openBrowser(authUrl)
const spin = p.spinner()
spin.start('Waiting for approval in your browser')
const deadline = Date.now() + WAIT_MS
while (Date.now() < deadline) {
await sleep(POLL_INTERVAL_MS)
// null means still pending or a transient error — either way, keep waiting.
const key = await pollOnce(origin, request, pollSecret)
if (key) {
spin.stop('Approved')
return key
}
}
spin.stop('Browser handoff timed out')
return null
}
/**
* One poll. Returns the key when the approval completes, `null` while pending or
* on a transient error (the caller keeps waiting until the deadline).
*/
async function pollOnce(origin: string, request: string, verifier: string): Promise<string | null> {
try {
const response = await fetch(`${origin}/api/cli/auth/poll`, {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ request, verifier }),
})
// Behind a shared NAT the per-IP bucket can be hit — honor Retry-After and
// back off instead of hammering the endpoint every interval.
if (response.status === 429) {
const retryMs = parseRetryAfter(response.headers.get('retry-after'))
if (retryMs) await sleep(retryMs)
return null
}
if (!response.ok) return null
const data = (await response.json()) as PollResponse
return data.status === 'complete' ? (data.key?.apiKey ?? null) : null
} catch {
return null
}
}