From 3dc269ec29814e888b6fbcb70cd7cdd0acedacc7 Mon Sep 17 00:00:00 2001 From: Ninad Sinha Date: Tue, 14 Apr 2026 14:01:45 -0700 Subject: [PATCH 1/7] Add ability to use hx creds if api key not provided --- src/client.ts | 59 ++-- src/control-auth.ts | 680 ++++++++++++++++++++++++++++++++++++++ src/services/base.ts | 65 +++- src/services/sandboxes.ts | 10 +- src/services/scrape.ts | 7 +- src/services/sessions.ts | 7 +- src/services/web/index.ts | 9 +- src/types/config.ts | 4 + 8 files changed, 789 insertions(+), 52 deletions(-) create mode 100644 src/control-auth.ts diff --git a/src/client.ts b/src/client.ts index 5325859..104f84b 100644 --- a/src/client.ts +++ b/src/client.ts @@ -1,4 +1,5 @@ import { HyperbrowserConfig } from "./types/config"; +import { ControlAuthError, resolveControlPlaneConfig } from "./control-auth"; import { SessionsService } from "./services/sessions"; import { ScrapeService } from "./services/scrape"; import { CrawlService } from "./services/crawl"; @@ -74,34 +75,46 @@ export class HyperbrowserClient { public readonly volumes: VolumesService; constructor(config: HyperbrowserConfig = {}) { - const apiKey = config.apiKey || process.env["HYPERBROWSER_API_KEY"]; - const baseUrl = config.baseUrl || "https://api.hyperbrowser.ai"; + let authManager: ReturnType["authManager"]; + let baseUrl = ""; + try { + const resolved = resolveControlPlaneConfig(config); + authManager = resolved.authManager; + baseUrl = resolved.baseUrl; + } catch (error) { + if (error instanceof ControlAuthError) { + throw new HyperbrowserError(error.message, { + statusCode: error.statusCode, + code: error.code, + retryable: error.retryable, + service: "control", + details: error.details, + cause: error.cause ?? error, + }); + } + throw error; + } const timeout = config.timeout || 30000; const runtimeProxyOverride = config.runtimeProxyOverride?.trim() || undefined; - if (!apiKey) { - throw new HyperbrowserError( - "API key is required - either pass it in config or set HYPERBROWSER_API_KEY environment variable" - ); - } - this.sessions = new SessionsService(apiKey, baseUrl, timeout); - this.scrape = new ScrapeService(apiKey, baseUrl, timeout); - this.crawl = new CrawlService(apiKey, baseUrl, timeout); - this.extract = new ExtractService(apiKey, baseUrl, timeout); - this.profiles = new ProfilesService(apiKey, baseUrl, timeout); - this.extensions = new ExtensionService(apiKey, baseUrl, timeout); - this.web = new WebService(apiKey, baseUrl, timeout); - this.team = new TeamService(apiKey, baseUrl, timeout); - this.computerAction = new ComputerActionService(apiKey, baseUrl, timeout); - this.sandboxes = new SandboxesService(apiKey, baseUrl, timeout, runtimeProxyOverride); - this.volumes = new VolumesService(apiKey, baseUrl, timeout); + this.sessions = new SessionsService(authManager, baseUrl, timeout); + this.scrape = new ScrapeService(authManager, baseUrl, timeout); + this.crawl = new CrawlService(authManager, baseUrl, timeout); + this.extract = new ExtractService(authManager, baseUrl, timeout); + this.profiles = new ProfilesService(authManager, baseUrl, timeout); + this.extensions = new ExtensionService(authManager, baseUrl, timeout); + this.web = new WebService(authManager, baseUrl, timeout); + this.team = new TeamService(authManager, baseUrl, timeout); + this.computerAction = new ComputerActionService(authManager, baseUrl, timeout); + this.sandboxes = new SandboxesService(authManager, baseUrl, timeout, runtimeProxyOverride); + this.volumes = new VolumesService(authManager, baseUrl, timeout); this.agents = { - browserUse: new BrowserUseService(apiKey, baseUrl, timeout), - claudeComputerUse: new ClaudeComputerUseService(apiKey, baseUrl, timeout), - cua: new CuaService(apiKey, baseUrl, timeout), - hyperAgent: new HyperAgentService(apiKey, baseUrl, timeout), - geminiComputerUse: new GeminiComputerUseService(apiKey, baseUrl, timeout), + browserUse: new BrowserUseService(authManager, baseUrl, timeout), + claudeComputerUse: new ClaudeComputerUseService(authManager, baseUrl, timeout), + cua: new CuaService(authManager, baseUrl, timeout), + hyperAgent: new HyperAgentService(authManager, baseUrl, timeout), + geminiComputerUse: new GeminiComputerUseService(authManager, baseUrl, timeout), }; } } diff --git a/src/control-auth.ts b/src/control-auth.ts new file mode 100644 index 0000000..54ddb2c --- /dev/null +++ b/src/control-auth.ts @@ -0,0 +1,680 @@ +import { randomUUID } from "crypto"; +import { promises as fs, readFileSync } from "fs"; +import { homedir } from "os"; +import * as path from "path"; +import fetch, { RequestInit, Response } from "node-fetch"; +import { HyperbrowserConfig } from "./types/config"; + +const DEFAULT_PROFILE = "default"; +const DEFAULT_BASE_URL = "https://api.hyperbrowser.ai"; +const DEFAULT_LOCK_TIMEOUT_MS = 30_000; +const DEFAULT_LOCK_POLL_INTERVAL_MS = 125; +const DEFAULT_LOCK_STALE_MS = 120_000; +const OAUTH_REFRESH_EARLY_EXPIRY_MS = 30_000; +const ENV_PROFILE = "HYPERBROWSER_PROFILE"; +const ENV_API_KEY = "HYPERBROWSER_API_KEY"; +const ENV_BASE_URL = "HYPERBROWSER_BASE_URL"; +const ENV_LOCK_TIMEOUT_MS = "HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS"; +const ENV_LOCK_POLL_INTERVAL_MS = "HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS"; +const ENV_LOCK_STALE_MS = "HYPERBROWSER_AUTH_LOCK_STALE_MS"; + +export interface ControlAuthErrorOptions { + statusCode?: number; + code?: string; + retryable?: boolean; + details?: unknown; + cause?: unknown; +} + +export class ControlAuthError extends Error { + public readonly statusCode?: number; + public readonly code?: string; + public readonly retryable: boolean; + public readonly details?: unknown; + public readonly cause?: unknown; + + constructor(message: string, options: ControlAuthErrorOptions = {}) { + super(message); + this.name = "ControlAuthError"; + this.statusCode = options.statusCode; + this.code = options.code; + this.retryable = options.retryable ?? false; + this.details = options.details; + this.cause = options.cause; + } +} + +type OAuthSessionFile = { + version: number; + base_url: string; + client_id: string; + token_type?: string; + access_token: string; + refresh_token: string; + expiry: string; + scope?: string; + refresh_token_expiry?: string; +}; + +type ResolvedControlPlaneConfig = { + baseUrl: string; + authManager: ControlPlaneAuthManager; +}; + +type ControlPlaneAuthMode = + | { + kind: "api_key"; + apiKey: string; + } + | { + kind: "oauth"; + profile: string; + sessionPath: string; + lockPath: string; + baseUrl: string; + lockTimeoutMs: number; + lockPollIntervalMs: number; + lockStaleMs: number; + }; + +type AuthorizedHeaders = { + headers: Record; + accessToken?: string; +}; + +export function resolveControlPlaneConfig( + config: HyperbrowserConfig = {} +): ResolvedControlPlaneConfig { + const profile = normalizeProfile(config.profile || process.env[ENV_PROFILE] || DEFAULT_PROFILE); + const explicitApiKey = normalizeText(config.apiKey); + const envApiKey = normalizeText(process.env[ENV_API_KEY]); + const explicitBaseUrl = normalizeBaseUrl(config.baseUrl); + const envBaseUrl = normalizeBaseUrl(process.env[ENV_BASE_URL]); + const sessionPath = resolveOAuthSessionPath(profile); + const session = !explicitApiKey && !envApiKey ? tryLoadOAuthSessionSync(sessionPath) : null; + const resolvedBaseUrl = + explicitBaseUrl || envBaseUrl || normalizeBaseUrl(session?.base_url) || DEFAULT_BASE_URL; + + if (explicitApiKey || envApiKey) { + return { + baseUrl: resolvedBaseUrl, + authManager: new ControlPlaneAuthManager({ + kind: "api_key", + apiKey: explicitApiKey || envApiKey || "", + }), + }; + } + + if (!session) { + throw new ControlAuthError( + "API key is required - either pass it in config, set HYPERBROWSER_API_KEY, or save an OAuth session with hx auth login", + { + code: "missing_auth", + retryable: false, + } + ); + } + + if (normalizeBaseUrl(session.base_url) !== resolvedBaseUrl) { + throw new ControlAuthError( + `Saved OAuth session for profile ${profile} targets ${normalizeBaseUrl(session.base_url)}, not ${resolvedBaseUrl}`, + { + code: "oauth_base_url_mismatch", + retryable: false, + } + ); + } + + return { + baseUrl: resolvedBaseUrl, + authManager: new ControlPlaneAuthManager({ + kind: "oauth", + profile, + sessionPath, + lockPath: `${sessionPath}.refresh.lock`, + baseUrl: resolvedBaseUrl, + lockTimeoutMs: normalizePositiveInteger( + config.authLockTimeoutMs, + process.env[ENV_LOCK_TIMEOUT_MS], + DEFAULT_LOCK_TIMEOUT_MS + ), + lockPollIntervalMs: normalizePositiveInteger( + config.authLockPollIntervalMs, + process.env[ENV_LOCK_POLL_INTERVAL_MS], + DEFAULT_LOCK_POLL_INTERVAL_MS + ), + lockStaleMs: normalizePositiveInteger( + config.authLockStaleMs, + process.env[ENV_LOCK_STALE_MS], + DEFAULT_LOCK_STALE_MS + ), + }), + }; +} + +export class ControlPlaneAuthManager { + constructor(private readonly mode: ControlPlaneAuthMode) {} + + get isOAuth(): boolean { + return this.mode.kind === "oauth"; + } + + async fetch(url: string, init: RequestInit = {}, timeout: number): Promise { + const firstAttempt = await this.execute(url, init, timeout, false); + if (firstAttempt.response.status !== 401 || firstAttempt.accessToken === undefined) { + return firstAttempt.response; + } + + (firstAttempt.response.body as { destroy?: () => void } | null)?.destroy?.(); + const retryAttempt = await this.execute(url, init, timeout, true, firstAttempt.accessToken); + return retryAttempt.response; + } + + private async execute( + url: string, + init: RequestInit, + timeout: number, + forceRefresh: boolean, + rejectedAccessToken?: string + ): Promise<{ response: Response; accessToken?: string }> { + const authorization = await this.getAuthorizedHeaders(forceRefresh, rejectedAccessToken); + return { + response: await fetch(url, { + ...init, + timeout, + headers: { + ...(init.headers as Record | undefined), + ...authorization.headers, + }, + }), + accessToken: authorization.accessToken, + }; + } + + private async getAuthorizedHeaders( + forceRefresh: boolean, + rejectedAccessToken?: string + ): Promise { + if (this.mode.kind === "api_key") { + return { + headers: { + "x-api-key": this.mode.apiKey, + }, + }; + } + + const accessToken = await this.resolveOAuthAccessToken(forceRefresh, rejectedAccessToken); + return { + headers: { + Authorization: `Bearer ${accessToken}`, + }, + accessToken, + }; + } + + private async resolveOAuthAccessToken( + forceRefresh: boolean, + rejectedAccessToken?: string + ): Promise { + if (this.mode.kind !== "oauth") { + throw new ControlAuthError("OAuth auth is not configured", { + code: "missing_auth", + retryable: false, + }); + } + + let session = await this.loadOAuthSession(); + if (shouldUseOAuthSession(session, forceRefresh, rejectedAccessToken)) { + return session.access_token.trim(); + } + + const deadline = Date.now() + this.mode.lockTimeoutMs; + while (true) { + const lockHandle = await this.tryAcquireRotationLock(); + if (lockHandle) { + try { + session = await this.loadOAuthSession(); + if (shouldUseOAuthSession(session, forceRefresh, rejectedAccessToken)) { + return session.access_token.trim(); + } + if (isRefreshTokenExpired(session)) { + throw new ControlAuthError("OAuth session refresh token expired", { + code: "oauth_session_expired", + retryable: false, + }); + } + const refreshed = await this.refreshOAuthSession(session); + return refreshed.access_token.trim(); + } finally { + await releaseRotationLock(this.mode.lockPath, lockHandle); + } + } + + await this.clearStaleRotationLock(); + if (Date.now() > deadline) { + throw new ControlAuthError("Timed out waiting for OAuth rotation lock", { + code: "auth_rotation_timeout", + retryable: false, + }); + } + + await sleep(this.mode.lockPollIntervalMs); + session = await this.loadOAuthSession(); + if (shouldUseOAuthSession(session, true, rejectedAccessToken)) { + return session.access_token.trim(); + } + if (isRefreshTokenExpired(session)) { + throw new ControlAuthError("OAuth session refresh token expired", { + code: "oauth_session_expired", + retryable: false, + }); + } + } + } + + private async loadOAuthSession(): Promise { + if (this.mode.kind !== "oauth") { + throw new ControlAuthError("OAuth auth is not configured", { + code: "missing_auth", + retryable: false, + }); + } + + let raw: string; + try { + raw = await fs.readFile(this.mode.sessionPath, "utf8"); + } catch (error) { + throw new ControlAuthError("Failed to read saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } + + let parsed: OAuthSessionFile; + try { + parsed = JSON.parse(raw) as OAuthSessionFile; + } catch (error) { + throw new ControlAuthError("Saved OAuth session is invalid JSON", { + code: "oauth_session_invalid", + retryable: false, + cause: error, + }); + } + + validateOAuthSession(parsed, this.mode.baseUrl); + return parsed; + } + + private async refreshOAuthSession(session: OAuthSessionFile): Promise { + if (this.mode.kind !== "oauth") { + throw new ControlAuthError("OAuth auth is not configured", { + code: "missing_auth", + retryable: false, + }); + } + + const body = new URLSearchParams(); + body.set("grant_type", "refresh_token"); + body.set("client_id", normalizeText(session.client_id) || "hyperbrowser-cli"); + body.set("refresh_token", normalizeText(session.refresh_token)); + + let response: Response; + try { + response = await fetch(`${this.mode.baseUrl}/oauth/token`, { + method: "POST", + headers: { + "content-type": "application/x-www-form-urlencoded", + }, + body: body.toString(), + timeout: this.mode.lockTimeoutMs, + }); + } catch (error) { + throw new ControlAuthError("Failed to refresh OAuth session", { + code: "oauth_refresh_failed", + retryable: true, + cause: error, + }); + } + + const rawText = await response.text(); + let payload: Record = {}; + if (rawText) { + try { + payload = JSON.parse(rawText) as Record; + } catch { + payload = {}; + } + } + + if (!response.ok) { + const message = + normalizeText(typeof payload.message === "string" ? payload.message : "") || + normalizeText(typeof payload.error === "string" ? payload.error : "") || + `OAuth refresh failed with status ${response.status}`; + throw new ControlAuthError(message, { + statusCode: response.status, + code: + normalizeText(typeof payload.code === "string" ? payload.code : "") || + "oauth_refresh_failed", + retryable: false, + details: payload, + }); + } + + const refreshed = buildRefreshedOAuthSession(session, payload); + await writeOAuthSessionAtomic(this.mode.sessionPath, refreshed); + return refreshed; + } + + private async tryAcquireRotationLock() { + if (this.mode.kind !== "oauth") { + return null; + } + + await fs.mkdir(path.dirname(this.mode.lockPath), { + recursive: true, + mode: 0o700, + }); + await fs.chmod(path.dirname(this.mode.lockPath), 0o700).catch(() => undefined); + + try { + const handle = await fs.open(this.mode.lockPath, "wx", 0o600); + await handle.writeFile( + `pid=${process.pid}\ncreated_at=${new Date().toISOString()}\n`, + "utf8" + ); + await handle.sync(); + return handle; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return null; + } + throw new ControlAuthError("Failed to create OAuth rotation lock", { + code: "auth_rotation_lock_failed", + retryable: false, + cause: error, + }); + } + } + + private async clearStaleRotationLock(): Promise { + if (this.mode.kind !== "oauth") { + return; + } + + let info; + try { + info = await fs.stat(this.mode.lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw new ControlAuthError("Failed to inspect OAuth rotation lock", { + code: "auth_rotation_lock_failed", + retryable: false, + cause: error, + }); + } + + if (Date.now() - info.mtimeMs < this.mode.lockStaleMs) { + return; + } + + await fs.rm(this.mode.lockPath, { + force: true, + }); + } +} + +function tryLoadOAuthSessionSync(sessionPath: string): OAuthSessionFile | null { + let raw: string; + try { + raw = readFileSync(sessionPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw new ControlAuthError("Failed to read saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } + + let parsed: OAuthSessionFile; + try { + parsed = JSON.parse(raw) as OAuthSessionFile; + } catch (error) { + throw new ControlAuthError("Saved OAuth session is invalid JSON", { + code: "oauth_session_invalid", + retryable: false, + cause: error, + }); + } + + validateOAuthSession(parsed); + return parsed; +} + +function validateOAuthSession(session: OAuthSessionFile, expectedBaseUrl?: string): void { + if (!session || typeof session !== "object") { + throw new ControlAuthError("Saved OAuth session is missing", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if (normalizeText(session.access_token) === "" || normalizeText(session.refresh_token) === "") { + throw new ControlAuthError("Saved OAuth session is missing tokens", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if (!parseTimestamp(session.expiry)) { + throw new ControlAuthError("Saved OAuth session has an invalid expiry", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if ( + normalizeText(session.refresh_token_expiry) !== "" && + !parseTimestamp(session.refresh_token_expiry) + ) { + throw new ControlAuthError("Saved OAuth session has an invalid refresh token expiry", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if (expectedBaseUrl && normalizeBaseUrl(session.base_url) !== normalizeBaseUrl(expectedBaseUrl)) { + throw new ControlAuthError("Saved OAuth session targets a different base URL", { + code: "oauth_base_url_mismatch", + retryable: false, + }); + } +} + +function shouldUseOAuthSession( + session: OAuthSessionFile, + forceRefresh: boolean, + rejectedAccessToken?: string +): boolean { + if (!isAccessTokenUsable(session)) { + return false; + } + if (!forceRefresh) { + return true; + } + return normalizeText(session.access_token) !== normalizeText(rejectedAccessToken); +} + +function isAccessTokenUsable(session: OAuthSessionFile): boolean { + const expiry = parseTimestamp(session.expiry); + if (!expiry || normalizeText(session.access_token) === "") { + return false; + } + return expiry.getTime() - Date.now() > OAUTH_REFRESH_EARLY_EXPIRY_MS; +} + +function isRefreshTokenExpired(session: OAuthSessionFile): boolean { + const refreshExpiry = parseTimestamp(session.refresh_token_expiry); + if (!refreshExpiry) { + return false; + } + return refreshExpiry.getTime() <= Date.now(); +} + +function buildRefreshedOAuthSession( + previous: OAuthSessionFile, + payload: Record +): OAuthSessionFile { + const nextAccessToken = normalizeText( + typeof payload.access_token === "string" ? payload.access_token : "" + ); + if (!nextAccessToken) { + throw new ControlAuthError("OAuth refresh response did not include an access token", { + code: "oauth_refresh_failed", + retryable: false, + details: payload, + }); + } + + const nextRefreshToken = + normalizeText(typeof payload.refresh_token === "string" ? payload.refresh_token : "") || + normalizeText(previous.refresh_token); + const nextTokenType = + normalizeText(typeof payload.token_type === "string" ? payload.token_type : "") || + normalizeText(previous.token_type) || + "Bearer"; + const expiresAt = deriveOAuthExpiry(payload, "expires_in") || normalizeText(previous.expiry); + const refreshTokenExpiry = + deriveOAuthExpiry(payload, "refresh_token_expires_in") || + normalizeText(previous.refresh_token_expiry); + + return { + version: previous.version, + base_url: normalizeBaseUrl(previous.base_url), + client_id: normalizeText(previous.client_id) || "hyperbrowser-cli", + token_type: nextTokenType, + access_token: nextAccessToken, + refresh_token: nextRefreshToken, + expiry: expiresAt, + scope: + normalizeText(typeof payload.scope === "string" ? payload.scope : "") || + normalizeText(previous.scope), + refresh_token_expiry: refreshTokenExpiry || undefined, + }; +} + +async function writeOAuthSessionAtomic( + sessionPath: string, + session: OAuthSessionFile +): Promise { + const authDir = path.dirname(sessionPath); + await fs.mkdir(authDir, { + recursive: true, + mode: 0o700, + }); + await fs.chmod(authDir, 0o700).catch(() => undefined); + + const tempPath = path.join( + authDir, + `${path.basename(sessionPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp` + ); + const payload = `${JSON.stringify(session, null, 2)}\n`; + const handle = await fs.open(tempPath, "wx", 0o600); + let renamed = false; + + try { + await handle.writeFile(payload, "utf8"); + await handle.sync(); + await handle.close(); + await fs.rename(tempPath, sessionPath); + renamed = true; + await fs.chmod(sessionPath, 0o600).catch(() => undefined); + } finally { + if (!renamed) { + await handle.close().catch(() => undefined); + await fs + .rm(tempPath, { + force: true, + }) + .catch(() => undefined); + } + } +} + +async function releaseRotationLock(lockPath: string, handle: Awaited>) { + await handle.close().catch(() => undefined); + await fs + .rm(lockPath, { + force: true, + }) + .catch(() => undefined); +} + +function resolveOAuthSessionPath(profile: string): string { + return path.join(homedir(), ".hx_config", "auth", `${profile}.json`); +} + +function normalizeProfile(value: string): string { + const normalized = normalizeText(value); + return normalized || DEFAULT_PROFILE; +} + +function normalizeBaseUrl(value?: string | null): string { + const normalized = normalizeText(value); + return normalized.replace(/\/+$/, ""); +} + +function normalizeText(value?: string | null): string { + return (value || "").trim(); +} + +function parseTimestamp(value?: string | null): Date | null { + const normalized = normalizeText(value || ""); + if (!normalized) { + return null; + } + const parsed = new Date(normalized); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed; +} + +function deriveOAuthExpiry(payload: Record, key: string): string | undefined { + const raw = payload[key]; + if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { + return new Date(Date.now() + raw * 1000).toISOString(); + } + if (typeof raw === "string") { + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return new Date(Date.now() + parsed * 1000).toISOString(); + } + } + return undefined; +} + +function normalizePositiveInteger( + explicitValue: number | undefined, + envValue: string | undefined, + fallback: number +): number { + if (typeof explicitValue === "number" && Number.isFinite(explicitValue) && explicitValue > 0) { + return explicitValue; + } + if (envValue) { + const parsed = Number.parseInt(envValue, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return fallback; +} + +function sleep(durationMs: number): Promise { + return new Promise((resolve) => { + setTimeout(resolve, durationMs); + }); +} diff --git a/src/services/base.ts b/src/services/base.ts index f455424..3a50290 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -1,4 +1,5 @@ -import fetch, { HeadersInit, RequestInit, Response } from "node-fetch"; +import { HeadersInit, RequestInit, Response } from "node-fetch"; +import { ControlAuthError, ControlPlaneAuthManager } from "../control-auth"; import { HyperbrowserError } from "../client"; const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); @@ -27,9 +28,30 @@ const isRetryableNetworkError = (error: unknown): boolean => { ); }; +const toHeaderMap = (headers?: HeadersInit): Record => { + if (!headers) { + return {}; + } + if (Array.isArray(headers)) { + return Object.fromEntries(headers.map(([key, value]) => [key, String(value)])); + } + if (typeof (headers as { forEach?: unknown }).forEach === "function") { + const values: Record = {}; + (headers as { forEach: (callback: (value: string, key: string) => void) => void }).forEach( + (value, key) => { + values[key] = value; + } + ); + return values; + } + return Object.fromEntries( + Object.entries(headers).map(([key, value]) => [key, value === undefined ? "" : String(value)]) + ); +}; + export class BaseService { constructor( - protected readonly apiKey: string, + protected readonly auth: ControlPlaneAuthManager, protected readonly baseUrl: string, protected readonly timeout: number = 30000 ) {} @@ -57,22 +79,21 @@ export class BaseService { }); } - const headerKeys = Object.keys(init?.headers || {}); - const contentTypeKey = headerKeys.find( - (key) => key.toLowerCase() === "content-type" - ) as keyof HeadersInit; - - const response = await fetch(url.toString(), { - ...init, - timeout: this.timeout, - headers: { - "x-api-key": this.apiKey, - ...(contentTypeKey && init?.headers - ? { "content-type": init.headers[contentTypeKey] as string } - : { "content-type": "application/json" }), - ...init?.headers, + const requestHeaders = toHeaderMap(init?.headers); + const response = await this.auth.fetch( + url.toString(), + { + ...init, + headers: { + "content-type": + requestHeaders["content-type"] || + requestHeaders["Content-Type"] || + "application/json", + ...requestHeaders, + }, }, - }); + this.timeout + ); if (!response.ok) { let errorMessage: string; @@ -115,6 +136,16 @@ export class BaseService { if (error instanceof HyperbrowserError) { throw error; } + if (error instanceof ControlAuthError) { + throw new HyperbrowserError(error.message, { + statusCode: error.statusCode, + code: error.code, + retryable: error.retryable, + service: "control", + details: error.details, + cause: error.cause ?? error, + }); + } throw new HyperbrowserError( error instanceof Error ? error.message : "Unknown error occurred", diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 826b4c8..4073ce5 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -1,4 +1,5 @@ import { HyperbrowserError } from "../client"; +import { ControlPlaneAuthManager } from "../control-auth"; import { SandboxFilesApi } from "../sandbox/files"; import { RuntimeConnection, RuntimeTransport } from "../sandbox/base"; import { runtimeSessionIdFromPath } from "../sandbox/runtime-path"; @@ -443,8 +444,13 @@ export class SandboxesService extends BaseService { public readonly runtimeTimeout: number; public readonly runtimeProxyOverride?: string; - constructor(apiKey: string, baseUrl: string, timeout: number, runtimeProxyOverride?: string) { - super(apiKey, baseUrl, timeout); + constructor( + auth: ControlPlaneAuthManager, + baseUrl: string, + timeout: number, + runtimeProxyOverride?: string + ) { + super(auth, baseUrl, timeout); this.runtimeTimeout = timeout; this.runtimeProxyOverride = runtimeProxyOverride; } diff --git a/src/services/scrape.ts b/src/services/scrape.ts index 9d50ed7..bedb78c 100644 --- a/src/services/scrape.ts +++ b/src/services/scrape.ts @@ -9,6 +9,7 @@ import { StartScrapeJobParams, StartScrapeJobResponse, } from "../types/scrape"; +import { ControlPlaneAuthManager } from "../control-auth"; import { BaseService } from "./base"; import { sleep } from "../utils"; import { HyperbrowserError } from "../client"; @@ -168,9 +169,9 @@ export class BatchScrapeService extends BaseService { export class ScrapeService extends BaseService { public readonly batch: BatchScrapeService; - constructor(apiKey: string, baseUrl: string, timeout: number) { - super(apiKey, baseUrl, timeout); - this.batch = new BatchScrapeService(apiKey, baseUrl, timeout); + constructor(auth: ControlPlaneAuthManager, baseUrl: string, timeout: number) { + super(auth, baseUrl, timeout); + this.batch = new BatchScrapeService(auth, baseUrl, timeout); } /** diff --git a/src/services/sessions.ts b/src/services/sessions.ts index e8c7fe3..a3acdaa 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -21,6 +21,7 @@ import { UpdateSessionProxyParams, SessionGetParams, } from "../types/session"; +import { ControlPlaneAuthManager } from "../control-auth"; import { BaseService } from "./base"; import { HyperbrowserError } from "../client"; @@ -58,9 +59,9 @@ class SessionEventLogsService extends BaseService { export class SessionsService extends BaseService { public readonly eventLogs: SessionEventLogsService; - constructor(apiKey: string, baseUrl: string, timeout: number) { - super(apiKey, baseUrl, timeout); - this.eventLogs = new SessionEventLogsService(apiKey, baseUrl, timeout); + constructor(auth: ControlPlaneAuthManager, baseUrl: string, timeout: number) { + super(auth, baseUrl, timeout); + this.eventLogs = new SessionEventLogsService(auth, baseUrl, timeout); } /** diff --git a/src/services/web/index.ts b/src/services/web/index.ts index 6fb8d38..5fccab2 100644 --- a/src/services/web/index.ts +++ b/src/services/web/index.ts @@ -1,5 +1,6 @@ import { toJSONSchema } from "zod"; import zodToJsonSchema from "zod-to-json-schema"; +import { ControlPlaneAuthManager } from "../../control-auth"; import { BaseService } from "../base"; import { HyperbrowserError } from "../../client"; import { FetchParams, FetchResponse } from "../../types/web/fetch"; @@ -13,10 +14,10 @@ export class WebService extends BaseService { public readonly batchFetch: BatchFetchService; public readonly crawl: WebCrawlService; - constructor(apiKey: string, baseUrl: string, timeout: number) { - super(apiKey, baseUrl, timeout); - this.batchFetch = new BatchFetchService(apiKey, baseUrl, timeout); - this.crawl = new WebCrawlService(apiKey, baseUrl, timeout); + constructor(auth: ControlPlaneAuthManager, baseUrl: string, timeout: number) { + super(auth, baseUrl, timeout); + this.batchFetch = new BatchFetchService(auth, baseUrl, timeout); + this.crawl = new WebCrawlService(auth, baseUrl, timeout); } /** * Fetch a URL and extract content diff --git a/src/types/config.ts b/src/types/config.ts index c998375..a3f1344 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,6 +1,10 @@ export interface HyperbrowserConfig { apiKey?: string; baseUrl?: string; + profile?: string; timeout?: number; runtimeProxyOverride?: string; + authLockTimeoutMs?: number; + authLockPollIntervalMs?: number; + authLockStaleMs?: number; } From a7a2eff155ebd7a5fa04d9eafcc1a0d202c0754b Mon Sep 17 00:00:00 2001 From: Ninad Sinha Date: Wed, 15 Apr 2026 11:18:37 -0700 Subject: [PATCH 2/7] Add handling for retries in streams --- src/control-auth.ts | 60 ++++++++++++++++++++++++++++++++---- src/services/base.ts | 36 +++++++++++----------- src/services/extensions.ts | 29 ++++++++++-------- src/services/sessions.ts | 62 +++++++++++++++++--------------------- 4 files changed, 116 insertions(+), 71 deletions(-) diff --git a/src/control-auth.ts b/src/control-auth.ts index 54ddb2c..b4add19 100644 --- a/src/control-auth.ts +++ b/src/control-auth.ts @@ -82,6 +82,8 @@ type AuthorizedHeaders = { accessToken?: string; }; +export type RequestInitFactory = () => RequestInit | Promise; + export function resolveControlPlaneConfig( config: HyperbrowserConfig = {} ): ResolvedControlPlaneConfig { @@ -159,12 +161,20 @@ export class ControlPlaneAuthManager { return this.mode.kind === "oauth"; } - async fetch(url: string, init: RequestInit = {}, timeout: number): Promise { + async fetch( + url: string, + init: RequestInit | RequestInitFactory = {}, + timeout: number + ): Promise { const firstAttempt = await this.execute(url, init, timeout, false); if (firstAttempt.response.status !== 401 || firstAttempt.accessToken === undefined) { return firstAttempt.response; } + if (!this.canReplayRequest(init, firstAttempt.init)) { + return firstAttempt.response; + } + (firstAttempt.response.body as { destroy?: () => void } | null)?.destroy?.(); const retryAttempt = await this.execute(url, init, timeout, true, firstAttempt.accessToken); return retryAttempt.response; @@ -172,25 +182,37 @@ export class ControlPlaneAuthManager { private async execute( url: string, - init: RequestInit, + init: RequestInit | RequestInitFactory, timeout: number, forceRefresh: boolean, rejectedAccessToken?: string - ): Promise<{ response: Response; accessToken?: string }> { + ): Promise<{ response: Response; accessToken?: string; init: RequestInit }> { + const requestInit = await resolveRequestInit(init); const authorization = await this.getAuthorizedHeaders(forceRefresh, rejectedAccessToken); return { response: await fetch(url, { - ...init, + ...requestInit, timeout, headers: { - ...(init.headers as Record | undefined), + ...(requestInit.headers as Record | undefined), ...authorization.headers, }, }), accessToken: authorization.accessToken, + init: requestInit, }; } + private canReplayRequest( + init: RequestInit | RequestInitFactory, + resolvedInit: RequestInit + ): boolean { + if (typeof init === "function") { + return true; + } + return isReplayableBody(resolvedInit.body); + } + private async getAuthorizedHeaders( forceRefresh: boolean, rejectedAccessToken?: string @@ -378,8 +400,9 @@ export class ControlPlaneAuthManager { }); await fs.chmod(path.dirname(this.mode.lockPath), 0o700).catch(() => undefined); + let handle: Awaited> | undefined; try { - const handle = await fs.open(this.mode.lockPath, "wx", 0o600); + handle = await fs.open(this.mode.lockPath, "wx", 0o600); await handle.writeFile( `pid=${process.pid}\ncreated_at=${new Date().toISOString()}\n`, "utf8" @@ -390,6 +413,14 @@ export class ControlPlaneAuthManager { if ((error as NodeJS.ErrnoException).code === "EEXIST") { return null; } + await handle?.close().catch(() => undefined); + if (handle) { + await fs + .rm(this.mode.lockPath, { + force: true, + }) + .catch(() => undefined); + } throw new ControlAuthError("Failed to create OAuth rotation lock", { code: "auth_rotation_lock_failed", retryable: false, @@ -678,3 +709,20 @@ function sleep(durationMs: number): Promise { setTimeout(resolve, durationMs); }); } + +async function resolveRequestInit(init: RequestInit | RequestInitFactory): Promise { + return typeof init === "function" ? await init() : init; +} + +function isReplayableBody(body: RequestInit["body"]): boolean { + if (body == null) { + return true; + } + if (typeof body === "string" || Buffer.isBuffer(body) || body instanceof URLSearchParams) { + return true; + } + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + return true; + } + return false; +} diff --git a/src/services/base.ts b/src/services/base.ts index 3a50290..e0d05c7 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -1,5 +1,5 @@ import { HeadersInit, RequestInit, Response } from "node-fetch"; -import { ControlAuthError, ControlPlaneAuthManager } from "../control-auth"; +import { ControlAuthError, ControlPlaneAuthManager, RequestInitFactory } from "../control-auth"; import { HyperbrowserError } from "../client"; const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); @@ -49,6 +49,18 @@ const toHeaderMap = (headers?: HeadersInit): Record => { ); }; +const normalizeRequestInit = (init?: RequestInit): RequestInit => { + const requestHeaders = toHeaderMap(init?.headers); + return { + ...init, + headers: { + "content-type": + requestHeaders["content-type"] || requestHeaders["Content-Type"] || "application/json", + ...requestHeaders, + }, + }; +}; + export class BaseService { constructor( protected readonly auth: ControlPlaneAuthManager, @@ -58,7 +70,7 @@ export class BaseService { protected async request( path: string, - init?: RequestInit, + init?: RequestInit | RequestInitFactory, params?: Record, fullUrl: boolean = false ): Promise { @@ -79,21 +91,11 @@ export class BaseService { }); } - const requestHeaders = toHeaderMap(init?.headers); - const response = await this.auth.fetch( - url.toString(), - { - ...init, - headers: { - "content-type": - requestHeaders["content-type"] || - requestHeaders["Content-Type"] || - "application/json", - ...requestHeaders, - }, - }, - this.timeout - ); + const requestInit = + typeof init === "function" + ? async () => normalizeRequestInit(await init()) + : normalizeRequestInit(init); + const response = await this.auth.fetch(url.toString(), requestInit, this.timeout); if (!response.ok) { let errorMessage: string; diff --git a/src/services/extensions.ts b/src/services/extensions.ts index d387091..de15ff4 100644 --- a/src/services/extensions.ts +++ b/src/services/extensions.ts @@ -32,23 +32,26 @@ export class ExtensionService extends BaseService { async create(params: CreateExtensionParams): Promise { try { await checkFileExists(params.filePath); + const extensionBuffer = await fs.readFile(params.filePath); + const extensionName = path.basename(params.filePath); - const form = new FormData(); - form.append("file", await fs.readFile(params.filePath), { - filename: path.basename(params.filePath), - contentType: "application/zip", - }); + return await this.request("/extensions/add", () => { + const form = new FormData(); + form.append("file", extensionBuffer, { + filename: extensionName, + contentType: "application/zip", + }); - if (params.name) { - form.append("name", params.name); - } + if (params.name) { + form.append("name", params.name); + } - const response = await this.request("/extensions/add", { - method: "POST", - body: form, - headers: form.getHeaders(), + return { + method: "POST", + body: form, + headers: form.getHeaders(), + }; }); - return response; } catch (error) { if (error instanceof HyperbrowserError) { throw error; diff --git a/src/services/sessions.ts b/src/services/sessions.ts index a3acdaa..f2b3fda 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -208,7 +208,7 @@ export class SessionsService extends BaseService { const { fileInput, fileName } = fileOptions; try { - let fetchOptions: RequestInit; + let fetchOptions: RequestInit | (() => Promise | RequestInit); if (typeof fileInput === "string") { let stats: Stats; @@ -234,38 +234,29 @@ export class SessionsService extends BaseService { throw new HyperbrowserError(`Path is not a file: ${fileInput}`, undefined); } - const formData = new FormData(); - const fileStream = createReadStream(fileInput); const fileBaseName = fileName || path.basename(fileInput); - - fileStream.on("error", (error) => { - throw new HyperbrowserError( - `Failed to read file ${fileInput}: ${error.message}`, - undefined - ); - }); - - formData.append("file", fileStream, { - filename: fileBaseName, - }); - - fetchOptions = { - method: "POST", - body: formData, - headers: formData.getHeaders(), + fetchOptions = () => { + const formData = new FormData(); + formData.append("file", createReadStream(fileInput), { + filename: fileBaseName, + }); + return { + method: "POST", + body: formData, + headers: formData.getHeaders(), + }; }; } else if (this.isReadableStream(fileInput)) { - const formData = new FormData(); - - let tmpFileName = fileName || `file-${Date.now()}`; - if (fileInput.path && typeof fileInput.path === "string" && !fileName) { - tmpFileName = path.basename(fileInput.path); + const streamPath = typeof fileInput.path === "string" ? fileInput.path : ""; + let streamFileName = fileName || `file-${Date.now()}`; + if (streamPath && !fileName) { + streamFileName = path.basename(streamPath); } + const formData = new FormData(); formData.append("file", fileInput, { - filename: tmpFileName, + filename: streamFileName, }); - fetchOptions = { method: "POST", body: formData, @@ -276,15 +267,16 @@ export class SessionsService extends BaseService { throw new HyperbrowserError("fileName is required when uploading Buffer data", undefined); } - const formData = new FormData(); - formData.append("file", fileInput, { - filename: fileName, - }); - - fetchOptions = { - method: "POST", - body: formData, - headers: formData.getHeaders(), + fetchOptions = () => { + const formData = new FormData(); + formData.append("file", fileInput, { + filename: fileName, + }); + return { + method: "POST", + body: formData, + headers: formData.getHeaders(), + }; }; } else { throw new HyperbrowserError( From 068bb48f06ebc68b8a3742b352edcf34b31b8fac Mon Sep 17 00:00:00 2001 From: Ninad Sinha Date: Wed, 15 Apr 2026 12:30:28 -0700 Subject: [PATCH 3/7] Perform cleanup and fixes --- src/control-auth.ts | 127 ++++++++++++++++++++++---------------- src/services/base.ts | 29 +++++++-- src/services/sandboxes.ts | 2 +- src/services/scrape.ts | 4 +- src/services/sessions.ts | 4 +- src/services/web/index.ts | 6 +- 6 files changed, 106 insertions(+), 66 deletions(-) diff --git a/src/control-auth.ts b/src/control-auth.ts index b4add19..3557817 100644 --- a/src/control-auth.ts +++ b/src/control-auth.ts @@ -77,6 +77,8 @@ type ControlPlaneAuthMode = lockStaleMs: number; }; +type OAuthControlPlaneAuthMode = Extract; + type AuthorizedHeaders = { headers: Record; accessToken?: string; @@ -90,12 +92,15 @@ export function resolveControlPlaneConfig( const profile = normalizeProfile(config.profile || process.env[ENV_PROFILE] || DEFAULT_PROFILE); const explicitApiKey = normalizeText(config.apiKey); const envApiKey = normalizeText(process.env[ENV_API_KEY]); - const explicitBaseUrl = normalizeBaseUrl(config.baseUrl); - const envBaseUrl = normalizeBaseUrl(process.env[ENV_BASE_URL]); + const explicitBaseUrl = normalizeControlPlaneBaseUrl(config.baseUrl); + const envBaseUrl = normalizeControlPlaneBaseUrl(process.env[ENV_BASE_URL]); const sessionPath = resolveOAuthSessionPath(profile); const session = !explicitApiKey && !envApiKey ? tryLoadOAuthSessionSync(sessionPath) : null; const resolvedBaseUrl = - explicitBaseUrl || envBaseUrl || normalizeBaseUrl(session?.base_url) || DEFAULT_BASE_URL; + explicitBaseUrl || + envBaseUrl || + normalizeControlPlaneBaseUrl(session?.base_url) || + DEFAULT_BASE_URL; if (explicitApiKey || envApiKey) { return { @@ -117,9 +122,9 @@ export function resolveControlPlaneConfig( ); } - if (normalizeBaseUrl(session.base_url) !== resolvedBaseUrl) { + if (normalizeControlPlaneBaseUrl(session.base_url) !== resolvedBaseUrl) { throw new ControlAuthError( - `Saved OAuth session for profile ${profile} targets ${normalizeBaseUrl(session.base_url)}, not ${resolvedBaseUrl}`, + `Saved OAuth session for profile ${profile} targets ${normalizeControlPlaneBaseUrl(session.base_url)}, not ${resolvedBaseUrl}`, { code: "oauth_base_url_mismatch", retryable: false, @@ -238,24 +243,20 @@ export class ControlPlaneAuthManager { forceRefresh: boolean, rejectedAccessToken?: string ): Promise { - if (this.mode.kind !== "oauth") { - throw new ControlAuthError("OAuth auth is not configured", { - code: "missing_auth", - retryable: false, - }); - } - + const oauthMode = this.requireOAuthMode(); let session = await this.loadOAuthSession(); + let sessionMtimeMs = await this.tryReadOAuthSessionMtimeMs(oauthMode.sessionPath); if (shouldUseOAuthSession(session, forceRefresh, rejectedAccessToken)) { return session.access_token.trim(); } - const deadline = Date.now() + this.mode.lockTimeoutMs; + const deadline = Date.now() + oauthMode.lockTimeoutMs; while (true) { const lockHandle = await this.tryAcquireRotationLock(); if (lockHandle) { try { session = await this.loadOAuthSession(); + sessionMtimeMs = await this.tryReadOAuthSessionMtimeMs(oauthMode.sessionPath); if (shouldUseOAuthSession(session, forceRefresh, rejectedAccessToken)) { return session.access_token.trim(); } @@ -268,7 +269,7 @@ export class ControlPlaneAuthManager { const refreshed = await this.refreshOAuthSession(session); return refreshed.access_token.trim(); } finally { - await releaseRotationLock(this.mode.lockPath, lockHandle); + await releaseRotationLock(oauthMode.lockPath, lockHandle); } } @@ -280,7 +281,12 @@ export class ControlPlaneAuthManager { }); } - await sleep(this.mode.lockPollIntervalMs); + await sleep(oauthMode.lockPollIntervalMs); + const nextSessionMtimeMs = await this.tryReadOAuthSessionMtimeMs(oauthMode.sessionPath); + if (nextSessionMtimeMs === sessionMtimeMs) { + continue; + } + sessionMtimeMs = nextSessionMtimeMs; session = await this.loadOAuthSession(); if (shouldUseOAuthSession(session, true, rejectedAccessToken)) { return session.access_token.trim(); @@ -295,16 +301,10 @@ export class ControlPlaneAuthManager { } private async loadOAuthSession(): Promise { - if (this.mode.kind !== "oauth") { - throw new ControlAuthError("OAuth auth is not configured", { - code: "missing_auth", - retryable: false, - }); - } - + const oauthMode = this.requireOAuthMode(); let raw: string; try { - raw = await fs.readFile(this.mode.sessionPath, "utf8"); + raw = await fs.readFile(oauthMode.sessionPath, "utf8"); } catch (error) { throw new ControlAuthError("Failed to read saved OAuth session", { code: "oauth_session_read_failed", @@ -324,18 +324,12 @@ export class ControlPlaneAuthManager { }); } - validateOAuthSession(parsed, this.mode.baseUrl); + validateOAuthSession(parsed, oauthMode.baseUrl); return parsed; } private async refreshOAuthSession(session: OAuthSessionFile): Promise { - if (this.mode.kind !== "oauth") { - throw new ControlAuthError("OAuth auth is not configured", { - code: "missing_auth", - retryable: false, - }); - } - + const oauthMode = this.requireOAuthMode(); const body = new URLSearchParams(); body.set("grant_type", "refresh_token"); body.set("client_id", normalizeText(session.client_id) || "hyperbrowser-cli"); @@ -343,13 +337,13 @@ export class ControlPlaneAuthManager { let response: Response; try { - response = await fetch(`${this.mode.baseUrl}/oauth/token`, { + response = await fetch(resolveOAuthTokenUrl(oauthMode.baseUrl), { method: "POST", headers: { "content-type": "application/x-www-form-urlencoded", }, body: body.toString(), - timeout: this.mode.lockTimeoutMs, + timeout: oauthMode.lockTimeoutMs, }); } catch (error) { throw new ControlAuthError("Failed to refresh OAuth session", { @@ -385,24 +379,21 @@ export class ControlPlaneAuthManager { } const refreshed = buildRefreshedOAuthSession(session, payload); - await writeOAuthSessionAtomic(this.mode.sessionPath, refreshed); + await writeOAuthSessionAtomic(oauthMode.sessionPath, refreshed); return refreshed; } private async tryAcquireRotationLock() { - if (this.mode.kind !== "oauth") { - return null; - } - - await fs.mkdir(path.dirname(this.mode.lockPath), { + const oauthMode = this.requireOAuthMode(); + await fs.mkdir(path.dirname(oauthMode.lockPath), { recursive: true, mode: 0o700, }); - await fs.chmod(path.dirname(this.mode.lockPath), 0o700).catch(() => undefined); + await fs.chmod(path.dirname(oauthMode.lockPath), 0o700).catch(() => undefined); let handle: Awaited> | undefined; try { - handle = await fs.open(this.mode.lockPath, "wx", 0o600); + handle = await fs.open(oauthMode.lockPath, "wx", 0o600); await handle.writeFile( `pid=${process.pid}\ncreated_at=${new Date().toISOString()}\n`, "utf8" @@ -416,7 +407,7 @@ export class ControlPlaneAuthManager { await handle?.close().catch(() => undefined); if (handle) { await fs - .rm(this.mode.lockPath, { + .rm(oauthMode.lockPath, { force: true, }) .catch(() => undefined); @@ -430,13 +421,10 @@ export class ControlPlaneAuthManager { } private async clearStaleRotationLock(): Promise { - if (this.mode.kind !== "oauth") { - return; - } - + const oauthMode = this.requireOAuthMode(); let info; try { - info = await fs.stat(this.mode.lockPath); + info = await fs.stat(oauthMode.lockPath); } catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") { return; @@ -448,14 +436,40 @@ export class ControlPlaneAuthManager { }); } - if (Date.now() - info.mtimeMs < this.mode.lockStaleMs) { + if (Date.now() - info.mtimeMs < oauthMode.lockStaleMs) { return; } - await fs.rm(this.mode.lockPath, { + await fs.rm(oauthMode.lockPath, { force: true, }); } + + private requireOAuthMode(): OAuthControlPlaneAuthMode { + if (this.mode.kind !== "oauth") { + throw new ControlAuthError("OAuth auth is not configured", { + code: "missing_auth", + retryable: false, + }); + } + + return this.mode; + } + + private async tryReadOAuthSessionMtimeMs(sessionPath: string): Promise { + try { + return (await fs.stat(sessionPath)).mtimeMs; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw new ControlAuthError("Failed to inspect saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } + } } function tryLoadOAuthSessionSync(sessionPath: string): OAuthSessionFile | null { @@ -516,7 +530,10 @@ function validateOAuthSession(session: OAuthSessionFile, expectedBaseUrl?: strin retryable: false, }); } - if (expectedBaseUrl && normalizeBaseUrl(session.base_url) !== normalizeBaseUrl(expectedBaseUrl)) { + if ( + expectedBaseUrl && + normalizeControlPlaneBaseUrl(session.base_url) !== normalizeControlPlaneBaseUrl(expectedBaseUrl) + ) { throw new ControlAuthError("Saved OAuth session targets a different base URL", { code: "oauth_base_url_mismatch", retryable: false, @@ -583,7 +600,7 @@ function buildRefreshedOAuthSession( return { version: previous.version, - base_url: normalizeBaseUrl(previous.base_url), + base_url: normalizeControlPlaneBaseUrl(previous.base_url), client_id: normalizeText(previous.client_id) || "hyperbrowser-cli", token_type: nextTokenType, access_token: nextAccessToken, @@ -652,9 +669,9 @@ function normalizeProfile(value: string): string { return normalized || DEFAULT_PROFILE; } -function normalizeBaseUrl(value?: string | null): string { +export function normalizeControlPlaneBaseUrl(value?: string | null): string { const normalized = normalizeText(value); - return normalized.replace(/\/+$/, ""); + return normalized.replace(/\/+$/, "").replace(/\/api$/, ""); } function normalizeText(value?: string | null): string { @@ -714,6 +731,10 @@ async function resolveRequestInit(init: RequestInit | RequestInitFactory): Promi return typeof init === "function" ? await init() : init; } +function resolveOAuthTokenUrl(baseUrl: string): string { + return new URL("/oauth/token", `${baseUrl}/`).toString(); +} + function isReplayableBody(body: RequestInit["body"]): boolean { if (body == null) { return true; diff --git a/src/services/base.ts b/src/services/base.ts index e0d05c7..a826d3c 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -1,5 +1,10 @@ import { HeadersInit, RequestInit, Response } from "node-fetch"; -import { ControlAuthError, ControlPlaneAuthManager, RequestInitFactory } from "../control-auth"; +import { + ControlAuthError, + ControlPlaneAuthManager, + normalizeControlPlaneBaseUrl, + RequestInitFactory, +} from "../control-auth"; import { HyperbrowserError } from "../client"; const RETRYABLE_STATUS_CODES = new Set([429, 502, 503, 504]); @@ -62,11 +67,25 @@ const normalizeRequestInit = (init?: RequestInit): RequestInit => { }; export class BaseService { + protected readonly auth: ControlPlaneAuthManager; + protected readonly baseUrl: string; + protected readonly timeout: number; + constructor( - protected readonly auth: ControlPlaneAuthManager, - protected readonly baseUrl: string, - protected readonly timeout: number = 30000 - ) {} + auth: string | ControlPlaneAuthManager, + baseUrl: string, + timeout: number = 30000 + ) { + this.auth = + typeof auth === "string" + ? new ControlPlaneAuthManager({ + kind: "api_key", + apiKey: auth, + }) + : auth; + this.baseUrl = normalizeControlPlaneBaseUrl(baseUrl); + this.timeout = timeout; + } protected async request( path: string, diff --git a/src/services/sandboxes.ts b/src/services/sandboxes.ts index 4073ce5..ac722a8 100644 --- a/src/services/sandboxes.ts +++ b/src/services/sandboxes.ts @@ -445,7 +445,7 @@ export class SandboxesService extends BaseService { public readonly runtimeProxyOverride?: string; constructor( - auth: ControlPlaneAuthManager, + auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number, runtimeProxyOverride?: string diff --git a/src/services/scrape.ts b/src/services/scrape.ts index bedb78c..115d21d 100644 --- a/src/services/scrape.ts +++ b/src/services/scrape.ts @@ -169,9 +169,9 @@ export class BatchScrapeService extends BaseService { export class ScrapeService extends BaseService { public readonly batch: BatchScrapeService; - constructor(auth: ControlPlaneAuthManager, baseUrl: string, timeout: number) { + constructor(auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number) { super(auth, baseUrl, timeout); - this.batch = new BatchScrapeService(auth, baseUrl, timeout); + this.batch = new BatchScrapeService(this.auth, this.baseUrl, timeout); } /** diff --git a/src/services/sessions.ts b/src/services/sessions.ts index f2b3fda..deae09d 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -59,9 +59,9 @@ class SessionEventLogsService extends BaseService { export class SessionsService extends BaseService { public readonly eventLogs: SessionEventLogsService; - constructor(auth: ControlPlaneAuthManager, baseUrl: string, timeout: number) { + constructor(auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number) { super(auth, baseUrl, timeout); - this.eventLogs = new SessionEventLogsService(auth, baseUrl, timeout); + this.eventLogs = new SessionEventLogsService(this.auth, this.baseUrl, timeout); } /** diff --git a/src/services/web/index.ts b/src/services/web/index.ts index 5fccab2..b2c00a3 100644 --- a/src/services/web/index.ts +++ b/src/services/web/index.ts @@ -14,10 +14,10 @@ export class WebService extends BaseService { public readonly batchFetch: BatchFetchService; public readonly crawl: WebCrawlService; - constructor(auth: ControlPlaneAuthManager, baseUrl: string, timeout: number) { + constructor(auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number) { super(auth, baseUrl, timeout); - this.batchFetch = new BatchFetchService(auth, baseUrl, timeout); - this.crawl = new WebCrawlService(auth, baseUrl, timeout); + this.batchFetch = new BatchFetchService(this.auth, this.baseUrl, timeout); + this.crawl = new WebCrawlService(this.auth, this.baseUrl, timeout); } /** * Fetch a URL and extract content From 6298de2c868160bedece05bea47557d9fc0603e8 Mon Sep 17 00:00:00 2001 From: Ninad Sinha Date: Wed, 15 Apr 2026 12:41:01 -0700 Subject: [PATCH 4/7] Add explicit handling for stream rejections --- src/services/sessions.ts | 24 +++++++++++++++++++++++- 1 file changed, 23 insertions(+), 1 deletion(-) diff --git a/src/services/sessions.ts b/src/services/sessions.ts index deae09d..9dc3d33 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -2,6 +2,7 @@ import { promises as fs, Stats, createReadStream, ReadStream } from "fs"; import * as path from "path"; import FormData from "form-data"; import { RequestInit } from "node-fetch"; +import { Readable } from "stream"; import { BasicResponse, CreateSessionParams, @@ -25,6 +26,27 @@ import { ControlPlaneAuthManager } from "../control-auth"; import { BaseService } from "./base"; import { HyperbrowserError } from "../client"; +function wrapFileReadErrors(filePath: string, stream: ReadStream): Readable { + return Readable.from( + (async function*() { + try { + for await (const chunk of stream) { + yield chunk; + } + } catch (error) { + if (error instanceof HyperbrowserError) { + throw error; + } + + const message = error instanceof Error ? error.message : String(error); + throw new HyperbrowserError(`Failed to read file ${filePath}: ${message}`, { + cause: error, + }); + } + })() + ); +} + /** * Service for managing session event logs */ @@ -237,7 +259,7 @@ export class SessionsService extends BaseService { const fileBaseName = fileName || path.basename(fileInput); fetchOptions = () => { const formData = new FormData(); - formData.append("file", createReadStream(fileInput), { + formData.append("file", wrapFileReadErrors(fileInput, createReadStream(fileInput)), { filename: fileBaseName, }); return { From 7573829055f72b2425f1240e35e8aa261d34bef8 Mon Sep 17 00:00:00 2001 From: Ninad Sinha Date: Wed, 15 Apr 2026 13:00:39 -0700 Subject: [PATCH 5/7] Break control-auth into multiple files --- src/control-auth-errors.ts | 25 ++ src/control-auth-helpers.ts | 54 +++ src/control-auth-lock.ts | 89 +++++ src/control-auth-request.ts | 20 ++ src/control-auth-session-store.ts | 245 ++++++++++++++ src/control-auth-types.ts | 34 ++ src/control-auth.ts | 528 ++++-------------------------- 7 files changed, 531 insertions(+), 464 deletions(-) create mode 100644 src/control-auth-errors.ts create mode 100644 src/control-auth-helpers.ts create mode 100644 src/control-auth-lock.ts create mode 100644 src/control-auth-request.ts create mode 100644 src/control-auth-session-store.ts create mode 100644 src/control-auth-types.ts diff --git a/src/control-auth-errors.ts b/src/control-auth-errors.ts new file mode 100644 index 0000000..e846f46 --- /dev/null +++ b/src/control-auth-errors.ts @@ -0,0 +1,25 @@ +export interface ControlAuthErrorOptions { + statusCode?: number; + code?: string; + retryable?: boolean; + details?: unknown; + cause?: unknown; +} + +export class ControlAuthError extends Error { + public readonly statusCode?: number; + public readonly code?: string; + public readonly retryable: boolean; + public readonly details?: unknown; + public readonly cause?: unknown; + + constructor(message: string, options: ControlAuthErrorOptions = {}) { + super(message); + this.name = "ControlAuthError"; + this.statusCode = options.statusCode; + this.code = options.code; + this.retryable = options.retryable ?? false; + this.details = options.details; + this.cause = options.cause; + } +} diff --git a/src/control-auth-helpers.ts b/src/control-auth-helpers.ts new file mode 100644 index 0000000..64d9556 --- /dev/null +++ b/src/control-auth-helpers.ts @@ -0,0 +1,54 @@ +import { homedir } from "os"; +import * as path from "path"; + +export const DEFAULT_PROFILE = "default"; +export const DEFAULT_BASE_URL = "https://api.hyperbrowser.ai"; +export const DEFAULT_LOCK_TIMEOUT_MS = 30_000; +export const DEFAULT_LOCK_POLL_INTERVAL_MS = 125; +export const DEFAULT_LOCK_STALE_MS = 120_000; +export const OAUTH_REFRESH_EARLY_EXPIRY_MS = 30_000; +export const ENV_PROFILE = "HYPERBROWSER_PROFILE"; +export const ENV_API_KEY = "HYPERBROWSER_API_KEY"; +export const ENV_BASE_URL = "HYPERBROWSER_BASE_URL"; +export const ENV_LOCK_TIMEOUT_MS = "HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS"; +export const ENV_LOCK_POLL_INTERVAL_MS = "HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS"; +export const ENV_LOCK_STALE_MS = "HYPERBROWSER_AUTH_LOCK_STALE_MS"; + +export function resolveOAuthSessionPath(profile: string): string { + return path.join(homedir(), ".hx_config", "auth", `${profile}.json`); +} + +export function normalizeProfile(value: string): string { + const normalized = normalizeText(value); + return normalized || DEFAULT_PROFILE; +} + +export function normalizeControlPlaneBaseUrl(value?: string | null): string { + const normalized = normalizeText(value); + return normalized.replace(/\/+$/, "").replace(/\/api$/, ""); +} + +export function normalizeText(value?: string | null): string { + return (value || "").trim(); +} + +export function normalizePositiveInteger( + explicitValue: number | undefined, + envValue: string | undefined, + fallback: number +): number { + if (typeof explicitValue === "number" && Number.isFinite(explicitValue) && explicitValue > 0) { + return explicitValue; + } + if (envValue) { + const parsed = Number.parseInt(envValue, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return parsed; + } + } + return fallback; +} + +export function resolveOAuthTokenUrl(baseUrl: string): string { + return new URL("/oauth/token", `${baseUrl}/`).toString(); +} diff --git a/src/control-auth-lock.ts b/src/control-auth-lock.ts new file mode 100644 index 0000000..2fd4cc2 --- /dev/null +++ b/src/control-auth-lock.ts @@ -0,0 +1,89 @@ +import { promises as fs } from "fs"; +import * as path from "path"; +import { ControlAuthError } from "./control-auth-errors"; + +export type LockHandle = Awaited>; + +export async function tryAcquireRotationLock(lockPath: string): Promise { + await fs.mkdir(path.dirname(lockPath), { + recursive: true, + mode: 0o700, + }); + await fs.chmod(path.dirname(lockPath), 0o700).catch(() => undefined); + + let handle: LockHandle | undefined; + try { + handle = await fs.open(lockPath, "wx", 0o600); + await handle.writeFile(`pid=${process.pid}\ncreated_at=${new Date().toISOString()}\n`, "utf8"); + await handle.sync(); + return handle; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "EEXIST") { + return null; + } + await handle?.close().catch(() => undefined); + if (handle) { + await fs + .rm(lockPath, { + force: true, + }) + .catch(() => undefined); + } + throw new ControlAuthError("Failed to create OAuth rotation lock", { + code: "auth_rotation_lock_failed", + retryable: false, + cause: error, + }); + } +} + +export async function clearStaleRotationLock( + lockPath: string, + lockStaleMs: number +): Promise { + let info; + try { + info = await fs.stat(lockPath); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return; + } + throw new ControlAuthError("Failed to inspect OAuth rotation lock", { + code: "auth_rotation_lock_failed", + retryable: false, + cause: error, + }); + } + + if (Date.now() - info.mtimeMs < lockStaleMs) { + return; + } + + await fs.rm(lockPath, { + force: true, + }); +} + +export async function releaseRotationLock(lockPath: string, handle: LockHandle): Promise { + await handle.close().catch(() => undefined); + await fs + .rm(lockPath, { + force: true, + }) + .catch(() => undefined); +} + +export async function tryReadSessionMtimeMs(sessionPath: string): Promise { + try { + return (await fs.stat(sessionPath)).mtimeMs; + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw new ControlAuthError("Failed to inspect saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } +} diff --git a/src/control-auth-request.ts b/src/control-auth-request.ts new file mode 100644 index 0000000..a13ee30 --- /dev/null +++ b/src/control-auth-request.ts @@ -0,0 +1,20 @@ +import { RequestInit } from "node-fetch"; + +export type RequestInitFactory = () => RequestInit | Promise; + +export async function resolveRequestInit(init: RequestInit | RequestInitFactory): Promise { + return typeof init === "function" ? await init() : init; +} + +export function isReplayableBody(body: RequestInit["body"]): boolean { + if (body == null) { + return true; + } + if (typeof body === "string" || Buffer.isBuffer(body) || body instanceof URLSearchParams) { + return true; + } + if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { + return true; + } + return false; +} diff --git a/src/control-auth-session-store.ts b/src/control-auth-session-store.ts new file mode 100644 index 0000000..696a6a6 --- /dev/null +++ b/src/control-auth-session-store.ts @@ -0,0 +1,245 @@ +import { randomUUID } from "crypto"; +import { promises as fs, readFileSync } from "fs"; +import * as path from "path"; +import { ControlAuthError } from "./control-auth-errors"; +import { + normalizeControlPlaneBaseUrl, + normalizeText, + OAUTH_REFRESH_EARLY_EXPIRY_MS, +} from "./control-auth-helpers"; +import { type OAuthSessionFile } from "./control-auth-types"; + +export async function loadOAuthSession( + sessionPath: string, + expectedBaseUrl: string +): Promise { + let raw: string; + try { + raw = await fs.readFile(sessionPath, "utf8"); + } catch (error) { + throw new ControlAuthError("Failed to read saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } + + let parsed: OAuthSessionFile; + try { + parsed = JSON.parse(raw) as OAuthSessionFile; + } catch (error) { + throw new ControlAuthError("Saved OAuth session is invalid JSON", { + code: "oauth_session_invalid", + retryable: false, + cause: error, + }); + } + + validateOAuthSession(parsed, expectedBaseUrl); + return parsed; +} + +export function tryLoadOAuthSessionSync(sessionPath: string): OAuthSessionFile | null { + let raw: string; + try { + raw = readFileSync(sessionPath, "utf8"); + } catch (error) { + if ((error as NodeJS.ErrnoException).code === "ENOENT") { + return null; + } + throw new ControlAuthError("Failed to read saved OAuth session", { + code: "oauth_session_read_failed", + retryable: false, + cause: error, + }); + } + + let parsed: OAuthSessionFile; + try { + parsed = JSON.parse(raw) as OAuthSessionFile; + } catch (error) { + throw new ControlAuthError("Saved OAuth session is invalid JSON", { + code: "oauth_session_invalid", + retryable: false, + cause: error, + }); + } + + validateOAuthSession(parsed); + return parsed; +} + +export function shouldUseOAuthSession( + session: OAuthSessionFile, + forceRefresh: boolean, + rejectedAccessToken?: string +): boolean { + if (!isAccessTokenUsable(session)) { + return false; + } + if (!forceRefresh) { + return true; + } + return normalizeText(session.access_token) !== normalizeText(rejectedAccessToken); +} + +export function isRefreshTokenExpired(session: OAuthSessionFile): boolean { + const refreshExpiry = parseTimestamp(session.refresh_token_expiry); + if (!refreshExpiry) { + return false; + } + return refreshExpiry.getTime() <= Date.now(); +} + +export function buildRefreshedOAuthSession( + previous: OAuthSessionFile, + payload: Record +): OAuthSessionFile { + const nextAccessToken = normalizeText( + typeof payload.access_token === "string" ? payload.access_token : "" + ); + if (!nextAccessToken) { + throw new ControlAuthError("OAuth refresh response did not include an access token", { + code: "oauth_refresh_failed", + retryable: false, + details: payload, + }); + } + + const nextRefreshToken = + normalizeText(typeof payload.refresh_token === "string" ? payload.refresh_token : "") || + normalizeText(previous.refresh_token); + const nextTokenType = + normalizeText(typeof payload.token_type === "string" ? payload.token_type : "") || + normalizeText(previous.token_type) || + "Bearer"; + const expiresAt = deriveOAuthExpiry(payload, "expires_in") || normalizeText(previous.expiry); + const refreshTokenExpiry = + deriveOAuthExpiry(payload, "refresh_token_expires_in") || + normalizeText(previous.refresh_token_expiry); + + return { + version: previous.version, + base_url: normalizeControlPlaneBaseUrl(previous.base_url), + client_id: normalizeText(previous.client_id) || "hyperbrowser-cli", + token_type: nextTokenType, + access_token: nextAccessToken, + refresh_token: nextRefreshToken, + expiry: expiresAt, + scope: + normalizeText(typeof payload.scope === "string" ? payload.scope : "") || + normalizeText(previous.scope), + refresh_token_expiry: refreshTokenExpiry || undefined, + }; +} + +export async function writeOAuthSessionAtomic( + sessionPath: string, + session: OAuthSessionFile +): Promise { + const authDir = path.dirname(sessionPath); + await fs.mkdir(authDir, { + recursive: true, + mode: 0o700, + }); + await fs.chmod(authDir, 0o700).catch(() => undefined); + + const tempPath = path.join( + authDir, + `${path.basename(sessionPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp` + ); + const payload = `${JSON.stringify(session, null, 2)}\n`; + const handle = await fs.open(tempPath, "wx", 0o600); + let renamed = false; + + try { + await handle.writeFile(payload, "utf8"); + await handle.sync(); + await handle.close(); + await fs.rename(tempPath, sessionPath); + renamed = true; + await fs.chmod(sessionPath, 0o600).catch(() => undefined); + } finally { + if (!renamed) { + await handle.close().catch(() => undefined); + await fs + .rm(tempPath, { + force: true, + }) + .catch(() => undefined); + } + } +} + +function validateOAuthSession(session: OAuthSessionFile, expectedBaseUrl?: string): void { + if (!session || typeof session !== "object") { + throw new ControlAuthError("Saved OAuth session is missing", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if (normalizeText(session.access_token) === "" || normalizeText(session.refresh_token) === "") { + throw new ControlAuthError("Saved OAuth session is missing tokens", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if (!parseTimestamp(session.expiry)) { + throw new ControlAuthError("Saved OAuth session has an invalid expiry", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if ( + normalizeText(session.refresh_token_expiry) !== "" && + !parseTimestamp(session.refresh_token_expiry) + ) { + throw new ControlAuthError("Saved OAuth session has an invalid refresh token expiry", { + code: "oauth_session_invalid", + retryable: false, + }); + } + if ( + expectedBaseUrl && + normalizeControlPlaneBaseUrl(session.base_url) !== normalizeControlPlaneBaseUrl(expectedBaseUrl) + ) { + throw new ControlAuthError("Saved OAuth session targets a different base URL", { + code: "oauth_base_url_mismatch", + retryable: false, + }); + } +} + +function isAccessTokenUsable(session: OAuthSessionFile): boolean { + const expiry = parseTimestamp(session.expiry); + if (!expiry || normalizeText(session.access_token) === "") { + return false; + } + return expiry.getTime() - Date.now() > OAUTH_REFRESH_EARLY_EXPIRY_MS; +} + +function parseTimestamp(value?: string | null): Date | null { + const normalized = normalizeText(value || ""); + if (!normalized) { + return null; + } + const parsed = new Date(normalized); + if (Number.isNaN(parsed.getTime())) { + return null; + } + return parsed; +} + +function deriveOAuthExpiry(payload: Record, key: string): string | undefined { + const raw = payload[key]; + if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { + return new Date(Date.now() + raw * 1000).toISOString(); + } + if (typeof raw === "string") { + const parsed = Number.parseInt(raw, 10); + if (Number.isFinite(parsed) && parsed > 0) { + return new Date(Date.now() + parsed * 1000).toISOString(); + } + } + return undefined; +} diff --git a/src/control-auth-types.ts b/src/control-auth-types.ts new file mode 100644 index 0000000..e07b466 --- /dev/null +++ b/src/control-auth-types.ts @@ -0,0 +1,34 @@ +export type OAuthSessionFile = { + version: number; + base_url: string; + client_id: string; + token_type?: string; + access_token: string; + refresh_token: string; + expiry: string; + scope?: string; + refresh_token_expiry?: string; +}; + +export type ControlPlaneAuthMode = + | { + kind: "api_key"; + apiKey: string; + } + | { + kind: "oauth"; + profile: string; + sessionPath: string; + lockPath: string; + baseUrl: string; + lockTimeoutMs: number; + lockPollIntervalMs: number; + lockStaleMs: number; + }; + +export type OAuthControlPlaneAuthMode = Extract; + +export type AuthorizedHeaders = { + headers: Record; + accessToken?: string; +}; diff --git a/src/control-auth.ts b/src/control-auth.ts index 3557817..ae2cedf 100644 --- a/src/control-auth.ts +++ b/src/control-auth.ts @@ -1,95 +1,64 @@ -import { randomUUID } from "crypto"; -import { promises as fs, readFileSync } from "fs"; -import { homedir } from "os"; -import * as path from "path"; import fetch, { RequestInit, Response } from "node-fetch"; import { HyperbrowserConfig } from "./types/config"; - -const DEFAULT_PROFILE = "default"; -const DEFAULT_BASE_URL = "https://api.hyperbrowser.ai"; -const DEFAULT_LOCK_TIMEOUT_MS = 30_000; -const DEFAULT_LOCK_POLL_INTERVAL_MS = 125; -const DEFAULT_LOCK_STALE_MS = 120_000; -const OAUTH_REFRESH_EARLY_EXPIRY_MS = 30_000; -const ENV_PROFILE = "HYPERBROWSER_PROFILE"; -const ENV_API_KEY = "HYPERBROWSER_API_KEY"; -const ENV_BASE_URL = "HYPERBROWSER_BASE_URL"; -const ENV_LOCK_TIMEOUT_MS = "HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS"; -const ENV_LOCK_POLL_INTERVAL_MS = "HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS"; -const ENV_LOCK_STALE_MS = "HYPERBROWSER_AUTH_LOCK_STALE_MS"; - -export interface ControlAuthErrorOptions { - statusCode?: number; - code?: string; - retryable?: boolean; - details?: unknown; - cause?: unknown; -} - -export class ControlAuthError extends Error { - public readonly statusCode?: number; - public readonly code?: string; - public readonly retryable: boolean; - public readonly details?: unknown; - public readonly cause?: unknown; - - constructor(message: string, options: ControlAuthErrorOptions = {}) { - super(message); - this.name = "ControlAuthError"; - this.statusCode = options.statusCode; - this.code = options.code; - this.retryable = options.retryable ?? false; - this.details = options.details; - this.cause = options.cause; - } -} - -type OAuthSessionFile = { - version: number; - base_url: string; - client_id: string; - token_type?: string; - access_token: string; - refresh_token: string; - expiry: string; - scope?: string; - refresh_token_expiry?: string; -}; +import { ControlAuthError, type ControlAuthErrorOptions } from "./control-auth-errors"; +import { + DEFAULT_BASE_URL, + DEFAULT_LOCK_POLL_INTERVAL_MS, + DEFAULT_LOCK_STALE_MS, + DEFAULT_LOCK_TIMEOUT_MS, + ENV_API_KEY, + ENV_BASE_URL, + ENV_LOCK_POLL_INTERVAL_MS, + ENV_LOCK_STALE_MS, + ENV_LOCK_TIMEOUT_MS, + ENV_PROFILE, + normalizeControlPlaneBaseUrl, + normalizePositiveInteger, + normalizeProfile, + normalizeText, + resolveOAuthSessionPath, + resolveOAuthTokenUrl, +} from "./control-auth-helpers"; +import { + clearStaleRotationLock as clearRotationLockIfStale, + releaseRotationLock as releaseAcquiredRotationLock, + tryAcquireRotationLock as acquireRotationLock, + tryReadSessionMtimeMs, + type LockHandle, +} from "./control-auth-lock"; +import { + isReplayableBody, + RequestInitFactory, + resolveRequestInit, +} from "./control-auth-request"; +import { + buildRefreshedOAuthSession, + isRefreshTokenExpired, + loadOAuthSession, + shouldUseOAuthSession, + tryLoadOAuthSessionSync, + writeOAuthSessionAtomic, +} from "./control-auth-session-store"; +import { + type AuthorizedHeaders, + type ControlPlaneAuthMode, + type OAuthControlPlaneAuthMode, + type OAuthSessionFile, +} from "./control-auth-types"; type ResolvedControlPlaneConfig = { baseUrl: string; authManager: ControlPlaneAuthManager; }; -type ControlPlaneAuthMode = - | { - kind: "api_key"; - apiKey: string; - } - | { - kind: "oauth"; - profile: string; - sessionPath: string; - lockPath: string; - baseUrl: string; - lockTimeoutMs: number; - lockPollIntervalMs: number; - lockStaleMs: number; - }; - -type OAuthControlPlaneAuthMode = Extract; - -type AuthorizedHeaders = { - headers: Record; - accessToken?: string; -}; - -export type RequestInitFactory = () => RequestInit | Promise; +export { ControlAuthError, type ControlAuthErrorOptions }; +export { normalizeControlPlaneBaseUrl }; +export type { RequestInitFactory }; export function resolveControlPlaneConfig( config: HyperbrowserConfig = {} ): ResolvedControlPlaneConfig { - const profile = normalizeProfile(config.profile || process.env[ENV_PROFILE] || DEFAULT_PROFILE); + const profile = normalizeProfile(config.profile || process.env[ENV_PROFILE] || "default"); const explicitApiKey = normalizeText(config.apiKey); const envApiKey = normalizeText(process.env[ENV_API_KEY]); const explicitBaseUrl = normalizeControlPlaneBaseUrl(config.baseUrl); @@ -244,8 +213,8 @@ export class ControlPlaneAuthManager { rejectedAccessToken?: string ): Promise { const oauthMode = this.requireOAuthMode(); - let session = await this.loadOAuthSession(); - let sessionMtimeMs = await this.tryReadOAuthSessionMtimeMs(oauthMode.sessionPath); + let session = await loadOAuthSession(oauthMode.sessionPath, oauthMode.baseUrl); + let sessionMtimeMs = await tryReadSessionMtimeMs(oauthMode.sessionPath); if (shouldUseOAuthSession(session, forceRefresh, rejectedAccessToken)) { return session.access_token.trim(); } @@ -255,8 +224,8 @@ export class ControlPlaneAuthManager { const lockHandle = await this.tryAcquireRotationLock(); if (lockHandle) { try { - session = await this.loadOAuthSession(); - sessionMtimeMs = await this.tryReadOAuthSessionMtimeMs(oauthMode.sessionPath); + session = await loadOAuthSession(oauthMode.sessionPath, oauthMode.baseUrl); + sessionMtimeMs = await tryReadSessionMtimeMs(oauthMode.sessionPath); if (shouldUseOAuthSession(session, forceRefresh, rejectedAccessToken)) { return session.access_token.trim(); } @@ -266,14 +235,14 @@ export class ControlPlaneAuthManager { retryable: false, }); } - const refreshed = await this.refreshOAuthSession(session); + const refreshed = await this.refreshOAuthSession(oauthMode, session); return refreshed.access_token.trim(); } finally { - await releaseRotationLock(oauthMode.lockPath, lockHandle); + await releaseAcquiredRotationLock(oauthMode.lockPath, lockHandle); } } - await this.clearStaleRotationLock(); + await clearRotationLockIfStale(oauthMode.lockPath, oauthMode.lockStaleMs); if (Date.now() > deadline) { throw new ControlAuthError("Timed out waiting for OAuth rotation lock", { code: "auth_rotation_timeout", @@ -282,12 +251,12 @@ export class ControlPlaneAuthManager { } await sleep(oauthMode.lockPollIntervalMs); - const nextSessionMtimeMs = await this.tryReadOAuthSessionMtimeMs(oauthMode.sessionPath); + const nextSessionMtimeMs = await tryReadSessionMtimeMs(oauthMode.sessionPath); if (nextSessionMtimeMs === sessionMtimeMs) { continue; } sessionMtimeMs = nextSessionMtimeMs; - session = await this.loadOAuthSession(); + session = await loadOAuthSession(oauthMode.sessionPath, oauthMode.baseUrl); if (shouldUseOAuthSession(session, true, rejectedAccessToken)) { return session.access_token.trim(); } @@ -300,36 +269,10 @@ export class ControlPlaneAuthManager { } } - private async loadOAuthSession(): Promise { - const oauthMode = this.requireOAuthMode(); - let raw: string; - try { - raw = await fs.readFile(oauthMode.sessionPath, "utf8"); - } catch (error) { - throw new ControlAuthError("Failed to read saved OAuth session", { - code: "oauth_session_read_failed", - retryable: false, - cause: error, - }); - } - - let parsed: OAuthSessionFile; - try { - parsed = JSON.parse(raw) as OAuthSessionFile; - } catch (error) { - throw new ControlAuthError("Saved OAuth session is invalid JSON", { - code: "oauth_session_invalid", - retryable: false, - cause: error, - }); - } - - validateOAuthSession(parsed, oauthMode.baseUrl); - return parsed; - } - - private async refreshOAuthSession(session: OAuthSessionFile): Promise { - const oauthMode = this.requireOAuthMode(); + private async refreshOAuthSession( + oauthMode: OAuthControlPlaneAuthMode, + session: OAuthSessionFile + ): Promise { const body = new URLSearchParams(); body.set("grant_type", "refresh_token"); body.set("client_id", normalizeText(session.client_id) || "hyperbrowser-cli"); @@ -383,68 +326,6 @@ export class ControlPlaneAuthManager { return refreshed; } - private async tryAcquireRotationLock() { - const oauthMode = this.requireOAuthMode(); - await fs.mkdir(path.dirname(oauthMode.lockPath), { - recursive: true, - mode: 0o700, - }); - await fs.chmod(path.dirname(oauthMode.lockPath), 0o700).catch(() => undefined); - - let handle: Awaited> | undefined; - try { - handle = await fs.open(oauthMode.lockPath, "wx", 0o600); - await handle.writeFile( - `pid=${process.pid}\ncreated_at=${new Date().toISOString()}\n`, - "utf8" - ); - await handle.sync(); - return handle; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "EEXIST") { - return null; - } - await handle?.close().catch(() => undefined); - if (handle) { - await fs - .rm(oauthMode.lockPath, { - force: true, - }) - .catch(() => undefined); - } - throw new ControlAuthError("Failed to create OAuth rotation lock", { - code: "auth_rotation_lock_failed", - retryable: false, - cause: error, - }); - } - } - - private async clearStaleRotationLock(): Promise { - const oauthMode = this.requireOAuthMode(); - let info; - try { - info = await fs.stat(oauthMode.lockPath); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return; - } - throw new ControlAuthError("Failed to inspect OAuth rotation lock", { - code: "auth_rotation_lock_failed", - retryable: false, - cause: error, - }); - } - - if (Date.now() - info.mtimeMs < oauthMode.lockStaleMs) { - return; - } - - await fs.rm(oauthMode.lockPath, { - force: true, - }); - } - private requireOAuthMode(): OAuthControlPlaneAuthMode { if (this.mode.kind !== "oauth") { throw new ControlAuthError("OAuth auth is not configured", { @@ -456,269 +337,9 @@ export class ControlPlaneAuthManager { return this.mode; } - private async tryReadOAuthSessionMtimeMs(sessionPath: string): Promise { - try { - return (await fs.stat(sessionPath)).mtimeMs; - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw new ControlAuthError("Failed to inspect saved OAuth session", { - code: "oauth_session_read_failed", - retryable: false, - cause: error, - }); - } - } -} - -function tryLoadOAuthSessionSync(sessionPath: string): OAuthSessionFile | null { - let raw: string; - try { - raw = readFileSync(sessionPath, "utf8"); - } catch (error) { - if ((error as NodeJS.ErrnoException).code === "ENOENT") { - return null; - } - throw new ControlAuthError("Failed to read saved OAuth session", { - code: "oauth_session_read_failed", - retryable: false, - cause: error, - }); - } - - let parsed: OAuthSessionFile; - try { - parsed = JSON.parse(raw) as OAuthSessionFile; - } catch (error) { - throw new ControlAuthError("Saved OAuth session is invalid JSON", { - code: "oauth_session_invalid", - retryable: false, - cause: error, - }); - } - - validateOAuthSession(parsed); - return parsed; -} - -function validateOAuthSession(session: OAuthSessionFile, expectedBaseUrl?: string): void { - if (!session || typeof session !== "object") { - throw new ControlAuthError("Saved OAuth session is missing", { - code: "oauth_session_invalid", - retryable: false, - }); - } - if (normalizeText(session.access_token) === "" || normalizeText(session.refresh_token) === "") { - throw new ControlAuthError("Saved OAuth session is missing tokens", { - code: "oauth_session_invalid", - retryable: false, - }); - } - if (!parseTimestamp(session.expiry)) { - throw new ControlAuthError("Saved OAuth session has an invalid expiry", { - code: "oauth_session_invalid", - retryable: false, - }); - } - if ( - normalizeText(session.refresh_token_expiry) !== "" && - !parseTimestamp(session.refresh_token_expiry) - ) { - throw new ControlAuthError("Saved OAuth session has an invalid refresh token expiry", { - code: "oauth_session_invalid", - retryable: false, - }); - } - if ( - expectedBaseUrl && - normalizeControlPlaneBaseUrl(session.base_url) !== normalizeControlPlaneBaseUrl(expectedBaseUrl) - ) { - throw new ControlAuthError("Saved OAuth session targets a different base URL", { - code: "oauth_base_url_mismatch", - retryable: false, - }); - } -} - -function shouldUseOAuthSession( - session: OAuthSessionFile, - forceRefresh: boolean, - rejectedAccessToken?: string -): boolean { - if (!isAccessTokenUsable(session)) { - return false; - } - if (!forceRefresh) { - return true; - } - return normalizeText(session.access_token) !== normalizeText(rejectedAccessToken); -} - -function isAccessTokenUsable(session: OAuthSessionFile): boolean { - const expiry = parseTimestamp(session.expiry); - if (!expiry || normalizeText(session.access_token) === "") { - return false; - } - return expiry.getTime() - Date.now() > OAUTH_REFRESH_EARLY_EXPIRY_MS; -} - -function isRefreshTokenExpired(session: OAuthSessionFile): boolean { - const refreshExpiry = parseTimestamp(session.refresh_token_expiry); - if (!refreshExpiry) { - return false; - } - return refreshExpiry.getTime() <= Date.now(); -} - -function buildRefreshedOAuthSession( - previous: OAuthSessionFile, - payload: Record -): OAuthSessionFile { - const nextAccessToken = normalizeText( - typeof payload.access_token === "string" ? payload.access_token : "" - ); - if (!nextAccessToken) { - throw new ControlAuthError("OAuth refresh response did not include an access token", { - code: "oauth_refresh_failed", - retryable: false, - details: payload, - }); - } - - const nextRefreshToken = - normalizeText(typeof payload.refresh_token === "string" ? payload.refresh_token : "") || - normalizeText(previous.refresh_token); - const nextTokenType = - normalizeText(typeof payload.token_type === "string" ? payload.token_type : "") || - normalizeText(previous.token_type) || - "Bearer"; - const expiresAt = deriveOAuthExpiry(payload, "expires_in") || normalizeText(previous.expiry); - const refreshTokenExpiry = - deriveOAuthExpiry(payload, "refresh_token_expires_in") || - normalizeText(previous.refresh_token_expiry); - - return { - version: previous.version, - base_url: normalizeControlPlaneBaseUrl(previous.base_url), - client_id: normalizeText(previous.client_id) || "hyperbrowser-cli", - token_type: nextTokenType, - access_token: nextAccessToken, - refresh_token: nextRefreshToken, - expiry: expiresAt, - scope: - normalizeText(typeof payload.scope === "string" ? payload.scope : "") || - normalizeText(previous.scope), - refresh_token_expiry: refreshTokenExpiry || undefined, - }; -} - -async function writeOAuthSessionAtomic( - sessionPath: string, - session: OAuthSessionFile -): Promise { - const authDir = path.dirname(sessionPath); - await fs.mkdir(authDir, { - recursive: true, - mode: 0o700, - }); - await fs.chmod(authDir, 0o700).catch(() => undefined); - - const tempPath = path.join( - authDir, - `${path.basename(sessionPath)}.${process.pid}.${Date.now()}.${randomUUID()}.tmp` - ); - const payload = `${JSON.stringify(session, null, 2)}\n`; - const handle = await fs.open(tempPath, "wx", 0o600); - let renamed = false; - - try { - await handle.writeFile(payload, "utf8"); - await handle.sync(); - await handle.close(); - await fs.rename(tempPath, sessionPath); - renamed = true; - await fs.chmod(sessionPath, 0o600).catch(() => undefined); - } finally { - if (!renamed) { - await handle.close().catch(() => undefined); - await fs - .rm(tempPath, { - force: true, - }) - .catch(() => undefined); - } - } -} - -async function releaseRotationLock(lockPath: string, handle: Awaited>) { - await handle.close().catch(() => undefined); - await fs - .rm(lockPath, { - force: true, - }) - .catch(() => undefined); -} - -function resolveOAuthSessionPath(profile: string): string { - return path.join(homedir(), ".hx_config", "auth", `${profile}.json`); -} - -function normalizeProfile(value: string): string { - const normalized = normalizeText(value); - return normalized || DEFAULT_PROFILE; -} - -export function normalizeControlPlaneBaseUrl(value?: string | null): string { - const normalized = normalizeText(value); - return normalized.replace(/\/+$/, "").replace(/\/api$/, ""); -} - -function normalizeText(value?: string | null): string { - return (value || "").trim(); -} - -function parseTimestamp(value?: string | null): Date | null { - const normalized = normalizeText(value || ""); - if (!normalized) { - return null; - } - const parsed = new Date(normalized); - if (Number.isNaN(parsed.getTime())) { - return null; - } - return parsed; -} - -function deriveOAuthExpiry(payload: Record, key: string): string | undefined { - const raw = payload[key]; - if (typeof raw === "number" && Number.isFinite(raw) && raw > 0) { - return new Date(Date.now() + raw * 1000).toISOString(); - } - if (typeof raw === "string") { - const parsed = Number.parseInt(raw, 10); - if (Number.isFinite(parsed) && parsed > 0) { - return new Date(Date.now() + parsed * 1000).toISOString(); - } + private async tryAcquireRotationLock(): Promise { + return acquireRotationLock(this.requireOAuthMode().lockPath); } - return undefined; -} - -function normalizePositiveInteger( - explicitValue: number | undefined, - envValue: string | undefined, - fallback: number -): number { - if (typeof explicitValue === "number" && Number.isFinite(explicitValue) && explicitValue > 0) { - return explicitValue; - } - if (envValue) { - const parsed = Number.parseInt(envValue, 10); - if (Number.isFinite(parsed) && parsed > 0) { - return parsed; - } - } - return fallback; } function sleep(durationMs: number): Promise { @@ -726,24 +347,3 @@ function sleep(durationMs: number): Promise { setTimeout(resolve, durationMs); }); } - -async function resolveRequestInit(init: RequestInit | RequestInitFactory): Promise { - return typeof init === "function" ? await init() : init; -} - -function resolveOAuthTokenUrl(baseUrl: string): string { - return new URL("/oauth/token", `${baseUrl}/`).toString(); -} - -function isReplayableBody(body: RequestInit["body"]): boolean { - if (body == null) { - return true; - } - if (typeof body === "string" || Buffer.isBuffer(body) || body instanceof URLSearchParams) { - return true; - } - if (body instanceof ArrayBuffer || ArrayBuffer.isView(body)) { - return true; - } - return false; -} From b477d340ff03f1dc6216afa431d477826884788d Mon Sep 17 00:00:00 2001 From: Ninad Sinha Date: Wed, 15 Apr 2026 13:50:47 -0700 Subject: [PATCH 6/7] Fixes --- src/control-auth-helpers.ts | 41 +++++++++++++++++++++++++++++-- src/control-auth-session-store.ts | 3 ++- src/control-auth.ts | 28 +++++++++------------ src/services/base.ts | 22 +++++++++-------- src/services/sessions.ts | 3 ++- src/types/config.ts | 32 ++++++++++++++++++++++++ 6 files changed, 99 insertions(+), 30 deletions(-) diff --git a/src/control-auth-helpers.ts b/src/control-auth-helpers.ts index 64d9556..c3e6803 100644 --- a/src/control-auth-helpers.ts +++ b/src/control-auth-helpers.ts @@ -1,5 +1,6 @@ import { homedir } from "os"; import * as path from "path"; +import { ControlAuthError } from "./control-auth-errors"; export const DEFAULT_PROFILE = "default"; export const DEFAULT_BASE_URL = "https://api.hyperbrowser.ai"; @@ -14,13 +15,32 @@ export const ENV_LOCK_TIMEOUT_MS = "HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS"; export const ENV_LOCK_POLL_INTERVAL_MS = "HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS"; export const ENV_LOCK_STALE_MS = "HYPERBROWSER_AUTH_LOCK_STALE_MS"; +const PROFILE_NAME_PATTERN = /^[A-Za-z0-9._-]+$/; +const REDACTED_VALUE = "[REDACTED]"; +const SENSITIVE_DETAIL_KEYS = new Set(["access_token", "refresh_token"]); + export function resolveOAuthSessionPath(profile: string): string { return path.join(homedir(), ".hx_config", "auth", `${profile}.json`); } -export function normalizeProfile(value: string): string { +export function normalizeProfile(value?: string | null): string { const normalized = normalizeText(value); - return normalized || DEFAULT_PROFILE; + if (!normalized) { + return DEFAULT_PROFILE; + } + if (!PROFILE_NAME_PATTERN.test(normalized)) { + throw new ControlAuthError( + "Profile names may contain only letters, numbers, dots, underscores, and hyphens", + { + code: "invalid_profile", + retryable: false, + details: { + profile: normalized, + }, + } + ); + } + return normalized; } export function normalizeControlPlaneBaseUrl(value?: string | null): string { @@ -52,3 +72,20 @@ export function normalizePositiveInteger( export function resolveOAuthTokenUrl(baseUrl: string): string { return new URL("/oauth/token", `${baseUrl}/`).toString(); } + +export function redactSensitiveDetails(value: T): T { + if (Array.isArray(value)) { + return value.map((item) => redactSensitiveDetails(item)) as T; + } + + if (value && typeof value === "object") { + return Object.fromEntries( + Object.entries(value as Record).map(([key, nestedValue]) => [ + key, + SENSITIVE_DETAIL_KEYS.has(key) ? REDACTED_VALUE : redactSensitiveDetails(nestedValue), + ]) + ) as T; + } + + return value; +} diff --git a/src/control-auth-session-store.ts b/src/control-auth-session-store.ts index 696a6a6..698b90c 100644 --- a/src/control-auth-session-store.ts +++ b/src/control-auth-session-store.ts @@ -6,6 +6,7 @@ import { normalizeControlPlaneBaseUrl, normalizeText, OAUTH_REFRESH_EARLY_EXPIRY_MS, + redactSensitiveDetails, } from "./control-auth-helpers"; import { type OAuthSessionFile } from "./control-auth-types"; @@ -102,7 +103,7 @@ export function buildRefreshedOAuthSession( throw new ControlAuthError("OAuth refresh response did not include an access token", { code: "oauth_refresh_failed", retryable: false, - details: payload, + details: redactSensitiveDetails(payload), }); } diff --git a/src/control-auth.ts b/src/control-auth.ts index ae2cedf..a998db2 100644 --- a/src/control-auth.ts +++ b/src/control-auth.ts @@ -16,6 +16,7 @@ import { normalizePositiveInteger, normalizeProfile, normalizeText, + redactSensitiveDetails, resolveOAuthSessionPath, resolveOAuthTokenUrl, } from "./control-auth-helpers"; @@ -26,11 +27,7 @@ import { tryReadSessionMtimeMs, type LockHandle, } from "./control-auth-lock"; -import { - isReplayableBody, - RequestInitFactory, - resolveRequestInit, -} from "./control-auth-request"; +import { isReplayableBody, RequestInitFactory, resolveRequestInit } from "./control-auth-request"; import { buildRefreshedOAuthSession, isRefreshTokenExpired, @@ -58,22 +55,15 @@ export type { RequestInitFactory }; export function resolveControlPlaneConfig( config: HyperbrowserConfig = {} ): ResolvedControlPlaneConfig { - const profile = normalizeProfile(config.profile || process.env[ENV_PROFILE] || "default"); const explicitApiKey = normalizeText(config.apiKey); const envApiKey = normalizeText(process.env[ENV_API_KEY]); const explicitBaseUrl = normalizeControlPlaneBaseUrl(config.baseUrl); const envBaseUrl = normalizeControlPlaneBaseUrl(process.env[ENV_BASE_URL]); - const sessionPath = resolveOAuthSessionPath(profile); - const session = !explicitApiKey && !envApiKey ? tryLoadOAuthSessionSync(sessionPath) : null; - const resolvedBaseUrl = - explicitBaseUrl || - envBaseUrl || - normalizeControlPlaneBaseUrl(session?.base_url) || - DEFAULT_BASE_URL; + const configuredBaseUrl = explicitBaseUrl || envBaseUrl; if (explicitApiKey || envApiKey) { return { - baseUrl: resolvedBaseUrl, + baseUrl: configuredBaseUrl || DEFAULT_BASE_URL, authManager: new ControlPlaneAuthManager({ kind: "api_key", apiKey: explicitApiKey || envApiKey || "", @@ -81,6 +71,12 @@ export function resolveControlPlaneConfig( }; } + const profile = normalizeProfile(config.profile || process.env[ENV_PROFILE]); + const sessionPath = resolveOAuthSessionPath(profile); + const session = tryLoadOAuthSessionSync(sessionPath); + const resolvedBaseUrl = + configuredBaseUrl || normalizeControlPlaneBaseUrl(session?.base_url) || DEFAULT_BASE_URL; + if (!session) { throw new ControlAuthError( "API key is required - either pass it in config, set HYPERBROWSER_API_KEY, or save an OAuth session with hx auth login", @@ -91,7 +87,7 @@ export function resolveControlPlaneConfig( ); } - if (normalizeControlPlaneBaseUrl(session.base_url) !== resolvedBaseUrl) { + if (configuredBaseUrl && normalizeControlPlaneBaseUrl(session.base_url) !== resolvedBaseUrl) { throw new ControlAuthError( `Saved OAuth session for profile ${profile} targets ${normalizeControlPlaneBaseUrl(session.base_url)}, not ${resolvedBaseUrl}`, { @@ -317,7 +313,7 @@ export class ControlPlaneAuthManager { normalizeText(typeof payload.code === "string" ? payload.code : "") || "oauth_refresh_failed", retryable: false, - details: payload, + details: redactSensitiveDetails(payload), }); } diff --git a/src/services/base.ts b/src/services/base.ts index a826d3c..b5b9522 100644 --- a/src/services/base.ts +++ b/src/services/base.ts @@ -16,6 +16,8 @@ const RETRYABLE_NETWORK_CODES = new Set([ "ESOCKETTIMEDOUT", ]); +const normalizeHeaderKey = (key: string): string => key.toLowerCase(); + const getRequestId = (response: Response): string | undefined => { return response.headers.get("x-request-id") || response.headers.get("request-id") || undefined; }; @@ -38,19 +40,24 @@ const toHeaderMap = (headers?: HeadersInit): Record => { return {}; } if (Array.isArray(headers)) { - return Object.fromEntries(headers.map(([key, value]) => [key, String(value)])); + return Object.fromEntries( + headers.map(([key, value]) => [normalizeHeaderKey(key), String(value)]) + ); } if (typeof (headers as { forEach?: unknown }).forEach === "function") { const values: Record = {}; (headers as { forEach: (callback: (value: string, key: string) => void) => void }).forEach( (value, key) => { - values[key] = value; + values[normalizeHeaderKey(key)] = value; } ); return values; } return Object.fromEntries( - Object.entries(headers).map(([key, value]) => [key, value === undefined ? "" : String(value)]) + Object.entries(headers).map(([key, value]) => [ + normalizeHeaderKey(key), + value === undefined ? "" : String(value), + ]) ); }; @@ -59,9 +66,8 @@ const normalizeRequestInit = (init?: RequestInit): RequestInit => { return { ...init, headers: { - "content-type": - requestHeaders["content-type"] || requestHeaders["Content-Type"] || "application/json", ...requestHeaders, + "content-type": requestHeaders["content-type"] || "application/json", }, }; }; @@ -71,11 +77,7 @@ export class BaseService { protected readonly baseUrl: string; protected readonly timeout: number; - constructor( - auth: string | ControlPlaneAuthManager, - baseUrl: string, - timeout: number = 30000 - ) { + constructor(auth: string | ControlPlaneAuthManager, baseUrl: string, timeout: number = 30000) { this.auth = typeof auth === "string" ? new ControlPlaneAuthManager({ diff --git a/src/services/sessions.ts b/src/services/sessions.ts index 9dc3d33..e1ffe3c 100644 --- a/src/services/sessions.ts +++ b/src/services/sessions.ts @@ -28,7 +28,7 @@ import { HyperbrowserError } from "../client"; function wrapFileReadErrors(filePath: string, stream: ReadStream): Readable { return Readable.from( - (async function*() { + (async function* () { try { for await (const chunk of stream) { yield chunk; @@ -261,6 +261,7 @@ export class SessionsService extends BaseService { const formData = new FormData(); formData.append("file", wrapFileReadErrors(fileInput, createReadStream(fileInput)), { filename: fileBaseName, + knownLength: stats.size, }); return { method: "POST", diff --git a/src/types/config.ts b/src/types/config.ts index a3f1344..727d2d7 100644 --- a/src/types/config.ts +++ b/src/types/config.ts @@ -1,10 +1,42 @@ export interface HyperbrowserConfig { + /** API key used for control-plane requests. Falls back to `HYPERBROWSER_API_KEY`. */ apiKey?: string; + + /** + * Control-plane origin used for API and OAuth requests. + * Falls back to `HYPERBROWSER_BASE_URL`. + * A trailing `/api` is normalized away for compatibility with existing configs. + */ baseUrl?: string; + + /** + * Saved OAuth profile name from `~/.hx_config/auth/.json`. + * Falls back to `HYPERBROWSER_PROFILE`. + * Only letters, numbers, dots, underscores, and hyphens are allowed. + */ profile?: string; + + /** Request timeout in milliseconds. */ timeout?: number; + + /** Optional runtime proxy override used for sandbox transport endpoints. */ runtimeProxyOverride?: string; + + /** + * Maximum time in milliseconds to wait for the OAuth refresh lock. + * Falls back to `HYPERBROWSER_AUTH_LOCK_TIMEOUT_MS`. + */ authLockTimeoutMs?: number; + + /** + * Poll interval in milliseconds while waiting for the OAuth refresh lock. + * Falls back to `HYPERBROWSER_AUTH_LOCK_POLL_INTERVAL_MS`. + */ authLockPollIntervalMs?: number; + + /** + * Lock age in milliseconds after which a stale OAuth refresh lock can be cleared. + * Falls back to `HYPERBROWSER_AUTH_LOCK_STALE_MS`. + */ authLockStaleMs?: number; } From 16b69ee772aff83f76550e5bff873888be0ca764 Mon Sep 17 00:00:00 2001 From: Ninad Sinha Date: Mon, 20 Apr 2026 22:39:19 -0700 Subject: [PATCH 7/7] fixes --- package.json | 2 +- src/control-auth-helpers.ts | 29 +++++++++++++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/package.json b/package.json index a62d995..3ee29cf 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "@hyperbrowser/sdk", - "version": "0.89.4", + "version": "0.90.0", "description": "Node SDK for Hyperbrowser API", "author": "", "repository": { diff --git a/src/control-auth-helpers.ts b/src/control-auth-helpers.ts index c3e6803..608589f 100644 --- a/src/control-auth-helpers.ts +++ b/src/control-auth-helpers.ts @@ -44,8 +44,33 @@ export function normalizeProfile(value?: string | null): string { } export function normalizeControlPlaneBaseUrl(value?: string | null): string { - const normalized = normalizeText(value); - return normalized.replace(/\/+$/, "").replace(/\/api$/, ""); + const normalized = normalizeText(value).replace(/\/+$/, "").replace(/\/api$/, ""); + if (!normalized) { + return normalized; + } + + let parsed: URL; + try { + parsed = new URL(normalized); + } catch (cause) { + throw new ControlAuthError(`Invalid control-plane base URL: ${normalized}`, { + code: "invalid_base_url", + retryable: false, + cause, + }); + } + + if ((parsed.pathname && parsed.pathname !== "/") || parsed.search || parsed.hash) { + throw new ControlAuthError( + `Control-plane base URL must be an origin (scheme + host [+ port]) with no path, query, or fragment: ${normalized}`, + { + code: "invalid_base_url", + retryable: false, + } + ); + } + + return normalized; } export function normalizeText(value?: string | null): string {