Compare commits

...

10 Commits

Author SHA1 Message Date
Nikhil Sonti
a38e39ed63 fix: address review feedback for PR 919 2026-05-02 14:17:13 -07:00
Nikhil Sonti
ed3a42e717 chore: ignore server runtime state 2026-05-02 13:45:42 -07:00
Nikhil Sonti
f499d3967a fix: handle empty install id 2026-05-02 13:35:35 -07:00
Nikhil Sonti
97dcfdfe8c feat: restore oauth token storage 2026-05-02 13:18:34 -07:00
Nikhil Sonti
4662703747 refactor: remove sqlite oauth token store 2026-05-02 12:43:16 -07:00
Nikhil Sonti
55bf4c8840 build: package db migrations 2026-05-02 12:40:48 -07:00
Nikhil Sonti
06b147b7a5 feat: store harness agents in sqlite 2026-05-02 12:40:09 -07:00
Nikhil Sonti
399acb8e15 refactor: remove old sql identity dependency 2026-05-02 12:38:50 -07:00
Nikhil Sonti
6199390ae8 feat: run sqlite drizzle migrations 2026-05-02 12:37:23 -07:00
Nikhil Sonti
2a59589138 feat: add drizzle agent schema 2026-05-02 12:36:34 -07:00
38 changed files with 1647 additions and 479 deletions

View File

@@ -1,3 +1,5 @@
tmp-shot-*/
tmp-upload-*/
.devtools
db/
identity/

View File

@@ -0,0 +1,7 @@
import { defineConfig } from 'drizzle-kit'
export default defineConfig({
dialect: 'sqlite',
schema: './src/lib/db/schema/index.ts',
out: './src/lib/db/migrations',
})

View File

@@ -11,6 +11,7 @@
"start": "bun --watch --env-file=.env.development src/index.ts",
"start:ci": "bun --env-file=.env.development src/index.ts",
"build": "bun ../../scripts/build/server.ts --target=all",
"db:generate": "drizzle-kit generate --config drizzle.config.ts",
"test": "bun run test:all",
"test:all": "bun run ./tests/__helpers__/run-test-group.ts all",
"test:agent": "bun run ./tests/__helpers__/run-test-group.ts agent",
@@ -100,6 +101,7 @@
"commander": "^14.0.1",
"core-js": "3.45.1",
"debug": "4.4.3",
"drizzle-orm": "^0.45.2",
"eventsource-parser": "^3.0.0",
"fuse.js": "^7.1.0",
"gray-matter": "^4.0.3",
@@ -122,6 +124,7 @@
"@types/sinon": "^21.0.0",
"@types/ws": "^8.5.13",
"async-mutex": "^0.5.0",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.0.0",
"puppeteer": "24.23.0",
"sinon": "^21.0.1",

View File

@@ -18,7 +18,7 @@ import type { ContentfulStatusCode } from 'hono/utils/http-status'
import { HttpAgentError } from '../agent/errors'
import { INLINED_ENV } from '../env'
import { KlavisClient } from '../lib/clients/klavis/klavis-client'
import { initializeOAuth } from '../lib/clients/oauth'
import { initializeOAuth, shutdownOAuth } from '../lib/clients/oauth'
import { getDb } from '../lib/db'
import { logger } from '../lib/logger'
import { Sentry } from '../lib/sentry'
@@ -88,11 +88,10 @@ export async function createHttpServer(config: HttpServerConfig) {
} = config
const { onShutdown } = config
// Initialize OAuth token manager (callback server binds lazily on first PKCE login)
const tokenManager = browserosId
? initializeOAuth(getDb(), browserosId)
: null
if (!browserosId) shutdownOAuth()
const aclPolicyService = new GlobalAclPolicyService()
await aclPolicyService.load()
@@ -171,7 +170,7 @@ export async function createHttpServer(config: HttpServerConfig) {
'/shutdown',
createShutdownRoute({
onShutdown: () => {
tokenManager?.stopCallbackServer()
shutdownOAuth()
stopKlavisBackground()
klavisRef.handle?.close().catch((err) =>
logger.warn('Failed to close Klavis proxy transport', {

View File

@@ -13,11 +13,12 @@ import {
type TurnFrame,
TurnRegistry,
} from '../../../lib/agents/active-turn-registry'
import type {
AgentStore,
CreateAgentInput,
} from '../../../lib/agents/agent-store'
import type { AgentDefinition } from '../../../lib/agents/agent-types'
import {
type CreateAgentInput,
FileAgentStore,
} from '../../../lib/agents/file-agent-store'
import { DbAgentStore } from '../../../lib/agents/db-agent-store'
import {
FileMessageQueue,
type QueuedMessage,
@@ -152,7 +153,7 @@ export interface GatewayStatusSnapshot {
}
export class AgentHarnessService {
private readonly agentStore: FileAgentStore
private readonly agentStore: AgentStore
private readonly runtime: AgentRuntime
private readonly openclawProvisioner: OpenClawProvisioner | null
private readonly turnRegistry: TurnRegistry
@@ -169,7 +170,7 @@ export class AgentHarnessService {
constructor(
deps: {
agentStore?: FileAgentStore
agentStore?: AgentStore
runtime?: AgentRuntime
browserosServerPort?: number
openclawGateway?: OpenclawGatewayAccessor
@@ -179,7 +180,7 @@ export class AgentHarnessService {
messageQueue?: FileMessageQueue
} = {},
) {
this.agentStore = deps.agentStore ?? new FileAgentStore()
this.agentStore = deps.agentStore ?? new DbAgentStore()
this.runtime =
deps.runtime ??
new AcpxRuntime({

View File

@@ -0,0 +1,37 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { AgentAdapter, AgentDefinition } from './agent-types'
export interface CreateAgentInput {
name: string
adapter: AgentAdapter
modelId?: string
reasoningEffort?: string
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
supportsImages?: boolean
}
export interface AgentStore {
list(): Promise<AgentDefinition[]>
get(id: string): Promise<AgentDefinition | null>
create(input: CreateAgentInput): Promise<AgentDefinition>
upsertExisting(input: {
id: string
name: string
adapter: AgentAdapter
modelId?: string
reasoningEffort?: string
}): Promise<AgentDefinition>
update(
id: string,
patch: Partial<Pick<AgentDefinition, 'name' | 'pinned'>>,
): Promise<AgentDefinition | null>
delete(id: string): Promise<boolean>
}

View File

@@ -0,0 +1,201 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { randomUUID } from 'node:crypto'
import { desc, eq } from 'drizzle-orm'
import { type BrowserOsDatabase, getDb } from '../db'
import { type AgentDefinitionRow, agentDefinitions } from '../db/schema'
import { logger } from '../logger'
import {
resolveDefaultModelId,
resolveDefaultReasoningEffort,
} from './agent-catalog'
import type { AgentStore, CreateAgentInput } from './agent-store'
import type { AgentDefinition } from './agent-types'
/** Persists BrowserOS-owned harness agent definitions in the process SQLite database. */
export class DbAgentStore implements AgentStore {
private readonly db: BrowserOsDatabase
private writeQueue: Promise<unknown> = Promise.resolve()
constructor(options: { db?: BrowserOsDatabase } = {}) {
this.db = options.db ?? getDb()
}
async list(): Promise<AgentDefinition[]> {
const rows = this.db
.select()
.from(agentDefinitions)
.orderBy(desc(agentDefinitions.updatedAt))
.all()
const agents = rows.map(toAgentDefinition)
logger.debug('Agent harness store listed agents', {
count: agents.length,
store: 'sqlite',
})
return agents
}
async get(id: string): Promise<AgentDefinition | null> {
const row =
this.db
.select()
.from(agentDefinitions)
.where(eq(agentDefinitions.id, id))
.get() ?? null
return row ? toAgentDefinition(row) : null
}
async create(input: CreateAgentInput): Promise<AgentDefinition> {
return this.withWriteLock(async () => {
const now = Date.now()
const id =
input.adapter === 'openclaw' ? `oc-${randomUUID()}` : randomUUID()
const row: AgentDefinitionRow = {
id,
name: input.name.trim(),
adapter: input.adapter,
modelId: input.modelId ?? resolveDefaultModelId(input.adapter),
reasoningEffort:
input.reasoningEffort ?? resolveDefaultReasoningEffort(input.adapter),
permissionMode: 'approve-all',
sessionKey: `agent:${id}:main`,
pinned: false,
adapterConfigJson: serializeAdapterConfig(input),
createdAt: now,
updatedAt: now,
}
this.db.insert(agentDefinitions).values(row).run()
const agent = toAgentDefinition(row)
logger.info('Agent harness store created agent', {
agentId: agent.id,
name: agent.name,
adapter: agent.adapter,
modelId: agent.modelId,
reasoningEffort: agent.reasoningEffort,
sessionKey: agent.sessionKey,
store: 'sqlite',
})
return agent
})
}
/** Backfills a harness record for gateway-side OpenClaw agents discovered during reconciliation. */
async upsertExisting(input: {
id: string
name: string
adapter: AgentDefinition['adapter']
modelId?: string
reasoningEffort?: string
}): Promise<AgentDefinition> {
return this.withWriteLock(async () => {
const existing = await this.get(input.id)
if (existing) return existing
const now = Date.now()
const row: AgentDefinitionRow = {
id: input.id,
name: input.name.trim(),
adapter: input.adapter,
modelId: input.modelId ?? resolveDefaultModelId(input.adapter),
reasoningEffort:
input.reasoningEffort ?? resolveDefaultReasoningEffort(input.adapter),
permissionMode: 'approve-all',
sessionKey: `agent:${input.id}:main`,
pinned: false,
adapterConfigJson: null,
createdAt: now,
updatedAt: now,
}
this.db.insert(agentDefinitions).values(row).run()
const agent = toAgentDefinition(row)
logger.info('Agent harness store backfilled agent', {
agentId: agent.id,
name: agent.name,
adapter: agent.adapter,
sessionKey: agent.sessionKey,
store: 'sqlite',
})
return agent
})
}
async update(
id: string,
patch: Partial<Pick<AgentDefinition, 'name' | 'pinned'>>,
): Promise<AgentDefinition | null> {
return this.withWriteLock(async () => {
const current = await this.get(id)
if (!current) return null
const values = {
...(patch.name !== undefined ? { name: patch.name.trim() } : {}),
...(patch.pinned !== undefined ? { pinned: patch.pinned } : {}),
updatedAt: Date.now(),
}
this.db
.update(agentDefinitions)
.set(values)
.where(eq(agentDefinitions.id, id))
.run()
return this.get(id)
})
}
async delete(id: string): Promise<boolean> {
return this.withWriteLock(async () => {
const existing = await this.get(id)
if (!existing) return false
this.db.delete(agentDefinitions).where(eq(agentDefinitions.id, id)).run()
logger.info('Agent harness store deleted agent', {
agentId: id,
store: 'sqlite',
})
return true
})
}
private withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
const result = this.writeQueue.then(fn, fn)
this.writeQueue = result.then(
() => undefined,
() => undefined,
)
return result
}
}
function toAgentDefinition(row: AgentDefinitionRow): AgentDefinition {
return {
id: row.id,
name: row.name,
adapter: row.adapter,
modelId: row.modelId,
reasoningEffort: row.reasoningEffort,
permissionMode: row.permissionMode,
sessionKey: row.sessionKey,
pinned: row.pinned,
createdAt: row.createdAt,
updatedAt: row.updatedAt,
}
}
function serializeAdapterConfig(input: CreateAgentInput): string | null {
const config = {
...(input.providerType !== undefined
? { providerType: input.providerType }
: {}),
...(input.providerName !== undefined
? { providerName: input.providerName }
: {}),
...(input.baseUrl !== undefined ? { baseUrl: input.baseUrl } : {}),
...(input.apiKey !== undefined ? { apiKey: input.apiKey } : {}),
...(input.supportsImages !== undefined
? { supportsImages: input.supportsImages }
: {}),
}
return Object.keys(config).length > 0 ? JSON.stringify(config) : null
}

View File

@@ -1,243 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { randomUUID } from 'node:crypto'
import { mkdir, readFile, rename, writeFile } from 'node:fs/promises'
import { dirname, join } from 'node:path'
import { getBrowserosDir } from '../browseros-dir'
import { logger } from '../logger'
import {
resolveDefaultModelId,
resolveDefaultReasoningEffort,
} from './agent-catalog'
import type { AgentAdapter, AgentDefinition } from './agent-types'
interface AgentStoreFile {
version: 1
agents: AgentDefinition[]
}
export interface CreateAgentInput {
name: string
adapter: AgentAdapter
modelId?: string
reasoningEffort?: string
/**
* Provider fields used only when `adapter === 'openclaw'`. They are
* forwarded to the gateway-side createAgent call by the harness
* service. Other adapters ignore them.
*/
providerType?: string
providerName?: string
baseUrl?: string
apiKey?: string
supportsImages?: boolean
}
export class FileAgentStore {
private readonly filePath: string
private writeQueue: Promise<unknown> = Promise.resolve()
constructor(options: { filePath?: string } = {}) {
this.filePath =
options.filePath ??
join(getBrowserosDir(), 'agents', 'harness', 'agents.json')
}
async list(): Promise<AgentDefinition[]> {
const file = await this.read()
const agents = [...file.agents].sort((a, b) => b.updatedAt - a.updatedAt)
logger.debug('Agent harness store listed agents', {
count: agents.length,
filePath: this.filePath,
})
return agents
}
async get(id: string): Promise<AgentDefinition | null> {
const file = await this.read()
const agent = file.agents.find((entry) => entry.id === id) ?? null
logger.debug('Agent harness store loaded agent', {
agentId: id,
found: Boolean(agent),
adapter: agent?.adapter,
filePath: this.filePath,
})
return agent
}
async create(input: CreateAgentInput): Promise<AgentDefinition> {
return this.withWriteLock(async () => {
const now = Date.now()
// OpenClaw agent names must match ^[a-z][a-z0-9-]*$, so prefix with
// a fixed letter to guarantee a valid name when the harness id is
// also used as the gateway-side agent name. Other adapters keep
// raw UUIDs to preserve compatibility with existing records.
const id =
input.adapter === 'openclaw' ? `oc-${randomUUID()}` : randomUUID()
const agent: AgentDefinition = {
id,
name: input.name.trim(),
adapter: input.adapter,
modelId: input.modelId ?? resolveDefaultModelId(input.adapter),
reasoningEffort:
input.reasoningEffort ?? resolveDefaultReasoningEffort(input.adapter),
permissionMode: 'approve-all',
sessionKey: `agent:${id}:main`,
createdAt: now,
updatedAt: now,
}
const file = await this.read()
await this.write({ ...file, agents: [...file.agents, agent] })
logger.info('Agent harness store created agent', {
agentId: agent.id,
name: agent.name,
adapter: agent.adapter,
modelId: agent.modelId,
reasoningEffort: agent.reasoningEffort,
sessionKey: agent.sessionKey,
filePath: this.filePath,
})
return agent
})
}
/**
* Inserts a harness record using a caller-provided id. Used to backfill
* harness records for gateway-side OpenClaw agents that pre-date the
* dual-creation flow (or were created directly via the legacy
* `/claw/agents` API). No-ops when an entry with this id already
* exists, so the call is safe to run on every server start.
*/
async upsertExisting(input: {
id: string
name: string
adapter: AgentAdapter
modelId?: string
reasoningEffort?: string
}): Promise<AgentDefinition> {
return this.withWriteLock(async () => {
const file = await this.read()
const existing = file.agents.find((entry) => entry.id === input.id)
if (existing) return existing
const now = Date.now()
const agent: AgentDefinition = {
id: input.id,
name: input.name.trim(),
adapter: input.adapter,
modelId: input.modelId ?? resolveDefaultModelId(input.adapter),
reasoningEffort:
input.reasoningEffort ?? resolveDefaultReasoningEffort(input.adapter),
permissionMode: 'approve-all',
sessionKey: `agent:${input.id}:main`,
createdAt: now,
updatedAt: now,
}
await this.write({ ...file, agents: [...file.agents, agent] })
logger.info('Agent harness store backfilled agent', {
agentId: agent.id,
name: agent.name,
adapter: agent.adapter,
sessionKey: agent.sessionKey,
filePath: this.filePath,
})
return agent
})
}
/**
* Apply a partial update to an agent record. Returns the updated
* record, or `null` if no agent matches `id`. Atomic via the same
* temp-file + rename + write-queue rules as `create`. Bumps
* `updatedAt` so the rail's recency sort reflects the change.
*
* Currently consumed by the pin-toggle mutation; the rename UI will
* use the same patch surface.
*/
async update(
id: string,
patch: Partial<Pick<AgentDefinition, 'name' | 'pinned'>>,
): Promise<AgentDefinition | null> {
return this.withWriteLock(async () => {
const file = await this.read()
const index = file.agents.findIndex((agent) => agent.id === id)
if (index < 0) return null
const current = file.agents[index]
const next: AgentDefinition = {
...current,
...(patch.name !== undefined ? { name: patch.name.trim() } : {}),
...(patch.pinned !== undefined ? { pinned: patch.pinned } : {}),
updatedAt: Date.now(),
}
const agents = [...file.agents]
agents[index] = next
await this.write({ ...file, agents })
logger.info('Agent harness store updated agent', {
agentId: id,
patchedFields: Object.keys(patch),
filePath: this.filePath,
})
return next
})
}
async delete(id: string): Promise<boolean> {
return this.withWriteLock(async () => {
const file = await this.read()
const agents = file.agents.filter((agent) => agent.id !== id)
if (agents.length === file.agents.length) return false
await this.write({ ...file, agents })
logger.info('Agent harness store deleted agent', {
agentId: id,
filePath: this.filePath,
})
return true
})
}
private async read(): Promise<AgentStoreFile> {
try {
const raw = await readFile(this.filePath, 'utf8')
const parsed = JSON.parse(raw) as AgentStoreFile
if (parsed.version !== 1 || !Array.isArray(parsed.agents)) {
return emptyStoreFile()
}
return parsed
} catch (err) {
if (isNotFoundError(err)) return emptyStoreFile()
throw err
}
}
private async write(file: AgentStoreFile): Promise<void> {
await mkdir(dirname(this.filePath), { recursive: true })
const tmpPath = `${this.filePath}.${process.pid}.${Date.now()}.tmp`
await writeFile(tmpPath, `${JSON.stringify(file, null, 2)}\n`, 'utf8')
await rename(tmpPath, this.filePath)
}
private withWriteLock<T>(fn: () => Promise<T>): Promise<T> {
const result = this.writeQueue.then(fn, fn)
this.writeQueue = result.then(
() => undefined,
() => undefined,
)
return result
}
}
function emptyStoreFile(): AgentStoreFile {
return { version: 1, agents: [] }
}
function isNotFoundError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'ENOENT'
)
}

View File

@@ -4,20 +4,23 @@
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Database } from 'bun:sqlite'
import type { BrowserOsDatabase } from '../../db'
import { OAuthCallbackServer } from './callback-server'
import { OAuthTokenManager } from './token-manager'
import type { OAuthTokenManager } from './token-manager'
import { OAuthTokenManager as OAuthTokenManagerImpl } from './token-manager'
import { OAuthTokenStore } from './token-store'
let tokenManager: OAuthTokenManager | null = null
/** Initializes the process OAuth manager using the BrowserOS Drizzle database. */
export function initializeOAuth(
db: Database,
db: BrowserOsDatabase,
browserosId: string,
): OAuthTokenManager {
shutdownOAuth()
const store = new OAuthTokenStore(db)
const callbackServer = new OAuthCallbackServer()
tokenManager = new OAuthTokenManager(store, browserosId, callbackServer)
tokenManager = new OAuthTokenManagerImpl(store, browserosId, callbackServer)
callbackServer.setTokenManager(tokenManager)
return tokenManager
}
@@ -25,3 +28,9 @@ export function initializeOAuth(
export function getOAuthTokenManager(): OAuthTokenManager | null {
return tokenManager
}
/** Stops the process OAuth manager and clears global access to provider tokens. */
export function shutdownOAuth(): void {
tokenManager?.stopCallbackServer()
tokenManager = null
}

View File

@@ -9,7 +9,31 @@ import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
import { logger } from '../../logger'
import type { OAuthCallbackServer } from './callback-server'
import { getOAuthProvider, type OAuthProviderConfig } from './providers'
import type { OAuthTokenStore, StoredOAuthTokens } from './token-store'
export interface StoredOAuthTokens {
accessToken: string
refreshToken: string
expiresAt: number
email?: string
accountId?: string
}
export interface OAuthStatus {
authenticated: boolean
email?: string
provider: string
}
export interface OAuthTokenStore {
upsertTokens(
browserosId: string,
provider: string,
tokens: StoredOAuthTokens,
): void
getTokens(browserosId: string, provider: string): StoredOAuthTokens | null
deleteTokens(browserosId: string, provider: string): void
getStatus(browserosId: string, provider: string): OAuthStatus
}
interface PendingOAuthFlow {
provider: string
@@ -455,7 +479,7 @@ export class OAuthTokenManager {
}
private stopCallbackIfIdle(): void {
const hasPkceFlows = [...this.pendingFlows.values()].some(() => true)
const hasPkceFlows = this.pendingFlows.size > 0
if (!hasPkceFlows) {
this.callbackServer.stop()
}

View File

@@ -2,98 +2,85 @@
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*
* SQLite storage for OAuth tokens.
*/
import type { Database } from 'bun:sqlite'
import { and, eq } from 'drizzle-orm'
import type { BrowserOsDatabase } from '../../db'
import { type OAuthTokenRow, oauthTokens } from '../../db/schema'
import type {
OAuthStatus,
OAuthTokenStore as OAuthTokenStoreContract,
StoredOAuthTokens,
} from './token-manager'
export interface StoredOAuthTokens {
accessToken: string
refreshToken: string
expiresAt: number
email?: string
accountId?: string
}
export interface OAuthStatus {
authenticated: boolean
email?: string
provider: string
}
export class OAuthTokenStore {
constructor(private readonly db: Database) {}
/** Persists OAuth tokens in the BrowserOS Drizzle database for server-managed LLM providers. */
export class OAuthTokenStore implements OAuthTokenStoreContract {
constructor(private readonly db: BrowserOsDatabase) {}
upsertTokens(
browserosId: string,
provider: string,
tokens: StoredOAuthTokens,
): void {
const stmt = this.db.prepare(`
INSERT INTO oauth_tokens (browseros_id, provider, access_token, refresh_token, expires_at, email, account_id, updated_at)
VALUES (?, ?, ?, ?, ?, ?, ?, datetime('now'))
ON CONFLICT (browseros_id, provider) DO UPDATE SET
access_token = excluded.access_token,
refresh_token = excluded.refresh_token,
expires_at = excluded.expires_at,
email = excluded.email,
account_id = excluded.account_id,
updated_at = datetime('now')
`)
stmt.run(
const row: OAuthTokenRow = {
browserosId,
provider,
tokens.accessToken,
tokens.refreshToken,
tokens.expiresAt,
tokens.email ?? null,
tokens.accountId ?? null,
)
accessToken: tokens.accessToken,
refreshToken: tokens.refreshToken,
expiresAt: tokens.expiresAt,
email: tokens.email ?? null,
accountId: tokens.accountId ?? null,
updatedAt: Date.now(),
}
this.db
.insert(oauthTokens)
.values(row)
.onConflictDoUpdate({
target: [oauthTokens.browserosId, oauthTokens.provider],
set: row,
})
.run()
}
getTokens(browserosId: string, provider: string): StoredOAuthTokens | null {
const row = this.db
.prepare(
'SELECT access_token, refresh_token, expires_at, email, account_id FROM oauth_tokens WHERE browseros_id = ? AND provider = ?',
)
.get(browserosId, provider) as {
access_token: string
refresh_token: string
expires_at: number
email: string | null
account_id: string | null
} | null
const row = this.findRow(browserosId, provider)
if (!row) return null
return {
accessToken: row.access_token,
refreshToken: row.refresh_token,
expiresAt: row.expires_at,
accessToken: row.accessToken,
refreshToken: row.refreshToken,
expiresAt: row.expiresAt,
email: row.email ?? undefined,
accountId: row.account_id ?? undefined,
accountId: row.accountId ?? undefined,
}
}
deleteTokens(browserosId: string, provider: string): void {
this.db
.prepare(
'DELETE FROM oauth_tokens WHERE browseros_id = ? AND provider = ?',
)
.run(browserosId, provider)
this.db.delete(oauthTokens).where(tokenKey(browserosId, provider)).run()
}
getStatus(browserosId: string, provider: string): OAuthStatus {
const row = this.db
.prepare(
'SELECT email FROM oauth_tokens WHERE browseros_id = ? AND provider = ?',
)
.get(browserosId, provider) as { email: string | null } | null
const row = this.findRow(browserosId, provider)
return {
authenticated: row !== null,
email: row?.email ?? undefined,
provider,
}
}
private findRow(browserosId: string, provider: string): OAuthTokenRow | null {
return (
this.db
.select()
.from(oauthTokens)
.where(tokenKey(browserosId, provider))
.get() ?? null
)
}
}
function tokenKey(browserosId: string, provider: string) {
return and(
eq(oauthTokens.browserosId, browserosId),
eq(oauthTokens.provider, provider),
)
}

View File

@@ -0,0 +1,82 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { Database as BunDatabase } from 'bun:sqlite'
import { existsSync, mkdirSync } from 'node:fs'
import { dirname, join } from 'node:path'
import { fileURLToPath } from 'node:url'
import { type BunSQLiteDatabase, drizzle } from 'drizzle-orm/bun-sqlite'
import { migrate } from 'drizzle-orm/bun-sqlite/migrator'
import * as schema from './schema'
export type BrowserOsDatabase = BunSQLiteDatabase<typeof schema>
export interface DbHandle {
path: string
migrationsDir: string
sqlite: BunDatabase
db: BrowserOsDatabase
}
export interface OpenDbOptions {
dbPath: string
resourcesDir?: string
migrationsDir?: string
runMigrations?: boolean
}
const sourceMigrationsDir = fileURLToPath(
new URL('./migrations', import.meta.url),
)
/** Opens BrowserOS SQLite and applies checked-in Drizzle migrations before callers use the DB. */
export function openBrowserOsDatabase(options: OpenDbOptions): DbHandle {
const migrationsDir = resolveMigrationsDir(options)
mkdirSync(dirname(options.dbPath), { recursive: true })
const sqlite = new BunDatabase(options.dbPath)
sqlite.exec('PRAGMA journal_mode = WAL')
sqlite.exec('PRAGMA foreign_keys = ON')
const db = drizzle(sqlite, { schema })
if (options.runMigrations !== false) {
migrate(db, { migrationsFolder: migrationsDir })
}
return {
path: options.dbPath,
migrationsDir,
sqlite,
db,
}
}
/** Resolves migrations from explicit test paths, packaged resources, or the source tree. */
export function resolveMigrationsDir(
options: Pick<OpenDbOptions, 'migrationsDir' | 'resourcesDir'> = {},
): string {
if (options.migrationsDir) {
if (existsSync(options.migrationsDir)) return options.migrationsDir
throw new Error(
`Drizzle migrations directory not found. Checked: ${options.migrationsDir}`,
)
}
const candidates = [
options.resourcesDir
? join(options.resourcesDir, 'db', 'migrations')
: null,
sourceMigrationsDir,
].filter((candidate): candidate is string => Boolean(candidate))
for (const candidate of candidates) {
if (existsSync(candidate)) return candidate
}
throw new Error(
`Drizzle migrations directory not found. Checked: ${candidates.join(', ')}`,
)
}

View File

@@ -3,31 +3,39 @@
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import { Database } from 'bun:sqlite'
import {
type BrowserOsDatabase,
type DbHandle,
type OpenDbOptions,
openBrowserOsDatabase,
} from './client'
import { initSchema } from './schema'
let handle: DbHandle | null = null
let db: Database | null = null
export function initializeDb(dbPath: string): Database {
if (!db) {
db = new Database(dbPath)
db.exec('PRAGMA journal_mode = WAL')
initSchema(db)
/** Initializes the process-wide BrowserOS database handle used by server services. */
export function initializeDb(options: OpenDbOptions): DbHandle {
if (!handle) {
handle = openBrowserOsDatabase(options)
}
return db
return handle
}
export function getDb(): Database {
if (!db) {
export function getDbHandle(): DbHandle {
if (!handle) {
throw new Error('Database not initialized. Call initializeDb() first.')
}
return db
return handle
}
export function getDb(): BrowserOsDatabase {
return getDbHandle().db
}
export function closeDb(): void {
if (db) {
db.close()
db = null
if (handle) {
handle.sqlite.close()
handle = null
}
}
export type { BrowserOsDatabase, DbHandle, OpenDbOptions }

View File

@@ -0,0 +1,17 @@
CREATE TABLE `agent_definitions` (
`id` text PRIMARY KEY NOT NULL,
`name` text NOT NULL,
`adapter` text NOT NULL,
`model_id` text NOT NULL,
`reasoning_effort` text NOT NULL,
`permission_mode` text DEFAULT 'approve-all' NOT NULL,
`session_key` text NOT NULL,
`pinned` integer DEFAULT false NOT NULL,
`adapter_config_json` text,
`created_at` integer NOT NULL,
`updated_at` integer NOT NULL
);
--> statement-breakpoint
CREATE UNIQUE INDEX `agent_definitions_session_key_unique` ON `agent_definitions` (`session_key`);--> statement-breakpoint
CREATE INDEX `agent_definitions_updated_at_idx` ON `agent_definitions` (`updated_at`);--> statement-breakpoint
CREATE INDEX `agent_definitions_adapter_updated_at_idx` ON `agent_definitions` (`adapter`,`updated_at`);

View File

@@ -0,0 +1,13 @@
CREATE TABLE `oauth_tokens` (
`browseros_id` text NOT NULL,
`provider` text NOT NULL,
`access_token` text NOT NULL,
`refresh_token` text NOT NULL,
`expires_at` integer NOT NULL,
`email` text,
`account_id` text,
`updated_at` integer NOT NULL,
PRIMARY KEY(`browseros_id`, `provider`)
);
--> statement-breakpoint
CREATE INDEX `oauth_tokens_browseros_id_idx` ON `oauth_tokens` (`browseros_id`);

View File

@@ -0,0 +1,123 @@
{
"version": "6",
"dialect": "sqlite",
"id": "faeb2b91-efc6-497a-9867-258fbcebd8b2",
"prevId": "00000000-0000-0000-0000-000000000000",
"tables": {
"agent_definitions": {
"name": "agent_definitions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"adapter": {
"name": "adapter",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reasoning_effort": {
"name": "reasoning_effort",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission_mode": {
"name": "permission_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'approve-all'"
},
"session_key": {
"name": "session_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"adapter_config_json": {
"name": "adapter_config_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"agent_definitions_session_key_unique": {
"name": "agent_definitions_session_key_unique",
"columns": ["session_key"],
"isUnique": true
},
"agent_definitions_updated_at_idx": {
"name": "agent_definitions_updated_at_idx",
"columns": ["updated_at"],
"isUnique": false
},
"agent_definitions_adapter_updated_at_idx": {
"name": "agent_definitions_adapter_updated_at_idx",
"columns": ["adapter", "updated_at"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,200 @@
{
"version": "6",
"dialect": "sqlite",
"id": "6be24444-91aa-492e-96e5-d84c0f020468",
"prevId": "faeb2b91-efc6-497a-9867-258fbcebd8b2",
"tables": {
"agent_definitions": {
"name": "agent_definitions",
"columns": {
"id": {
"name": "id",
"type": "text",
"primaryKey": true,
"notNull": true,
"autoincrement": false
},
"name": {
"name": "name",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"adapter": {
"name": "adapter",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"model_id": {
"name": "model_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"reasoning_effort": {
"name": "reasoning_effort",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"permission_mode": {
"name": "permission_mode",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": "'approve-all'"
},
"session_key": {
"name": "session_key",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"pinned": {
"name": "pinned",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false,
"default": false
},
"adapter_config_json": {
"name": "adapter_config_json",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"created_at": {
"name": "created_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"agent_definitions_session_key_unique": {
"name": "agent_definitions_session_key_unique",
"columns": ["session_key"],
"isUnique": true
},
"agent_definitions_updated_at_idx": {
"name": "agent_definitions_updated_at_idx",
"columns": ["updated_at"],
"isUnique": false
},
"agent_definitions_adapter_updated_at_idx": {
"name": "agent_definitions_adapter_updated_at_idx",
"columns": ["adapter", "updated_at"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {},
"uniqueConstraints": {},
"checkConstraints": {}
},
"oauth_tokens": {
"name": "oauth_tokens",
"columns": {
"browseros_id": {
"name": "browseros_id",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"provider": {
"name": "provider",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"access_token": {
"name": "access_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"refresh_token": {
"name": "refresh_token",
"type": "text",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"expires_at": {
"name": "expires_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
},
"email": {
"name": "email",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"account_id": {
"name": "account_id",
"type": "text",
"primaryKey": false,
"notNull": false,
"autoincrement": false
},
"updated_at": {
"name": "updated_at",
"type": "integer",
"primaryKey": false,
"notNull": true,
"autoincrement": false
}
},
"indexes": {
"oauth_tokens_browseros_id_idx": {
"name": "oauth_tokens_browseros_id_idx",
"columns": ["browseros_id"],
"isUnique": false
}
},
"foreignKeys": {},
"compositePrimaryKeys": {
"oauth_tokens_browseros_id_provider_pk": {
"columns": ["browseros_id", "provider"],
"name": "oauth_tokens_browseros_id_provider_pk"
}
},
"uniqueConstraints": {},
"checkConstraints": {}
}
},
"views": {},
"enums": {},
"_meta": {
"schemas": {},
"tables": {},
"columns": {}
},
"internal": {
"indexes": {}
}
}

View File

@@ -0,0 +1,20 @@
{
"version": "7",
"dialect": "sqlite",
"entries": [
{
"idx": 0,
"version": "6",
"when": 1777750582590,
"tag": "0000_zippy_psylocke",
"breakpoints": true
},
{
"idx": 1,
"version": "6",
"when": 1777752799806,
"tag": "0001_lazy_orphan",
"breakpoints": true
}
]
}

View File

@@ -1,32 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Database } from 'bun:sqlite'
const IDENTITY_TABLE = `
CREATE TABLE IF NOT EXISTS identity (
id INTEGER PRIMARY KEY CHECK (id = 1),
browseros_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT (datetime('now'))
)`
const OAUTH_TOKENS_TABLE = `
CREATE TABLE IF NOT EXISTS oauth_tokens (
browseros_id TEXT NOT NULL,
provider TEXT NOT NULL,
access_token TEXT NOT NULL,
refresh_token TEXT NOT NULL,
expires_at INTEGER NOT NULL,
email TEXT,
account_id TEXT,
created_at TEXT NOT NULL DEFAULT (datetime('now')),
updated_at TEXT NOT NULL DEFAULT (datetime('now')),
PRIMARY KEY (browseros_id, provider)
)`
export function initSchema(db: Database): void {
db.exec(IDENTITY_TABLE)
db.exec(OAUTH_TOKENS_TABLE)
}

View File

@@ -0,0 +1,48 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import {
index,
integer,
sqliteTable,
text,
uniqueIndex,
} from 'drizzle-orm/sqlite-core'
export const agentDefinitions = sqliteTable(
'agent_definitions',
{
id: text('id').primaryKey(),
name: text('name').notNull(),
adapter: text('adapter', {
enum: ['claude', 'codex', 'openclaw'],
}).notNull(),
modelId: text('model_id').notNull(),
reasoningEffort: text('reasoning_effort').notNull(),
permissionMode: text('permission_mode', {
enum: ['approve-all'],
})
.notNull()
.default('approve-all'),
sessionKey: text('session_key').notNull(),
pinned: integer('pinned', { mode: 'boolean' }).notNull().default(false),
adapterConfigJson: text('adapter_config_json'),
createdAt: integer('created_at').notNull(),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
uniqueIndex('agent_definitions_session_key_unique').on(table.sessionKey),
index('agent_definitions_updated_at_idx').on(table.updatedAt),
index('agent_definitions_adapter_updated_at_idx').on(
table.adapter,
table.updatedAt,
),
],
)
export type AgentDefinitionRow = InferSelectModel<typeof agentDefinitions>
export type NewAgentDefinitionRow = InferInsertModel<typeof agentDefinitions>

View File

@@ -0,0 +1,8 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
export * from './agents'
export * from './oauth'

View File

@@ -0,0 +1,35 @@
/**
* @license
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { InferInsertModel, InferSelectModel } from 'drizzle-orm'
import {
index,
integer,
primaryKey,
sqliteTable,
text,
} from 'drizzle-orm/sqlite-core'
export const oauthTokens = sqliteTable(
'oauth_tokens',
{
browserosId: text('browseros_id').notNull(),
provider: text('provider').notNull(),
accessToken: text('access_token').notNull(),
refreshToken: text('refresh_token').notNull(),
expiresAt: integer('expires_at').notNull(),
email: text('email'),
accountId: text('account_id'),
updatedAt: integer('updated_at').notNull(),
},
(table) => [
primaryKey({ columns: [table.browserosId, table.provider] }),
index('oauth_tokens_browseros_id_idx').on(table.browserosId),
],
)
export type OAuthTokenRow = InferSelectModel<typeof oauthTokens>
export type NewOAuthTokenRow = InferInsertModel<typeof oauthTokens>

View File

@@ -3,22 +3,27 @@
* Copyright 2025 BrowserOS
* SPDX-License-Identifier: AGPL-3.0-or-later
*/
import type { Database } from 'bun:sqlite'
import { mkdirSync, readFileSync, writeFileSync } from 'node:fs'
import { dirname } from 'node:path'
export interface IdentityConfig {
installId?: string
db: Database
statePath?: string
}
class IdentityService {
private browserOSId: string | null = null // Unique identifier for the BrowserOS instance
interface IdentityStateFile {
browserosId: string
}
export class IdentityService {
private browserOSId: string | null = null
/** Chooses the stable BrowserOS id without coupling it to the product SQLite schema. */
initialize(config: IdentityConfig): void {
const { installId, db } = config
// Priority: DB > config > generate new
this.browserOSId =
this.loadFromDb(db) || installId || this.generateAndSave(db)
normalizeInstallId(config.installId) ??
this.loadFromState(config.statePath) ??
this.generateAndSave(config.statePath)
}
getBrowserOSId(): string {
@@ -34,20 +39,43 @@ class IdentityService {
return this.browserOSId !== null
}
private loadFromDb(db: Database): string | null {
const stmt = db.prepare('SELECT browseros_id FROM identity WHERE id = 1')
const row = stmt.get() as { browseros_id: string } | null
return row?.browseros_id ?? null
private loadFromState(statePath: string | undefined): string | null {
if (!statePath) return null
try {
const parsed = JSON.parse(
readFileSync(statePath, 'utf8'),
) as Partial<IdentityStateFile>
return typeof parsed.browserosId === 'string' &&
parsed.browserosId.length > 0
? parsed.browserosId
: null
} catch (err) {
if (isNotFoundError(err)) return null
throw err
}
}
private generateAndSave(db: Database): string {
private generateAndSave(statePath: string | undefined): string {
const browserosId = crypto.randomUUID()
const stmt = db.prepare(
'INSERT OR REPLACE INTO identity (id, browseros_id) VALUES (1, ?)',
)
stmt.run(browserosId)
if (statePath) {
mkdirSync(dirname(statePath), { recursive: true })
writeFileSync(statePath, `${JSON.stringify({ browserosId })}\n`, 'utf8')
}
return browserosId
}
}
function normalizeInstallId(installId: string | undefined): string | null {
return installId && installId.length > 0 ? installId : null
}
function isNotFoundError(err: unknown): boolean {
return (
typeof err === 'object' &&
err !== null &&
'code' in err &&
err.code === 'ENOENT'
)
}
export const identity = new IdentityService()

View File

@@ -8,7 +8,6 @@
* Manages server lifecycle: initialization, startup, and shutdown.
*/
import type { Database } from 'bun:sqlite'
import fs from 'node:fs'
import path from 'node:path'
import { EXIT_CODES } from '@browseros/shared/constants/exit-codes'
@@ -46,7 +45,6 @@ import { VERSION } from './version'
export class Application {
private config: ServerConfig
private db: Database | null = null
constructor(config: ServerConfig) {
this.config = config
@@ -181,15 +179,19 @@ export class Application {
await migrateBuiltinSkills()
await syncBuiltinSkills()
const dbPath = path.join(
this.config.executionDir || this.config.resourcesDir,
'browseros.db',
)
this.db = initializeDb(dbPath)
const dbPath = path.join(this.config.executionDir, 'db', 'browseros.sqlite')
initializeDb({
dbPath,
resourcesDir: this.config.resourcesDir,
})
identity.initialize({
installId: this.config.instanceInstallId,
db: this.db,
statePath: path.join(
this.config.executionDir,
'identity',
'browseros-id.json',
),
})
const browserosId = identity.getBrowserOSId()

View File

@@ -5,8 +5,8 @@
import { describe, expect, it } from 'bun:test'
import { AgentHarnessService } from '../../../../src/api/services/agents/agent-harness-service'
import type { AgentStore } from '../../../../src/lib/agents/agent-store'
import type { AgentDefinition } from '../../../../src/lib/agents/agent-types'
import type { FileAgentStore } from '../../../../src/lib/agents/file-agent-store'
import type {
AgentRuntime,
AgentStreamEvent,
@@ -44,7 +44,7 @@ describe('AgentHarnessService', () => {
}
const service = new AgentHarnessService({
agentStore: agentStore as FileAgentStore,
agentStore: agentStore as AgentStore,
runtime,
})
@@ -128,7 +128,7 @@ describe('AgentHarnessService', () => {
},
}
const service = new AgentHarnessService({
agentStore: createAgentStore([agent]) as FileAgentStore,
agentStore: createAgentStore([agent]) as AgentStore,
runtime,
})
@@ -158,7 +158,7 @@ describe('AgentHarnessService', () => {
},
}
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as FileAgentStore,
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
openclawProvisioner: provisioner,
})
@@ -206,7 +206,7 @@ describe('AgentHarnessService', () => {
},
}
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as FileAgentStore,
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
openclawProvisioner: provisioner,
})
@@ -220,7 +220,7 @@ describe('AgentHarnessService', () => {
it('refuses to create an OpenClaw agent when no provisioner is wired', async () => {
const agents: AgentDefinition[] = []
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as FileAgentStore,
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
})
@@ -247,7 +247,7 @@ describe('AgentHarnessService', () => {
},
}
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as FileAgentStore,
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
openclawProvisioner: provisioner,
})
@@ -289,7 +289,7 @@ describe('AgentHarnessService', () => {
},
}
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as FileAgentStore,
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
openclawProvisioner: provisioner,
})
@@ -329,7 +329,7 @@ describe('AgentHarnessService', () => {
},
}
const service = new AgentHarnessService({
agentStore: createAgentStore(agents) as FileAgentStore,
agentStore: createAgentStore(agents) as AgentStore,
runtime: stubRuntime(),
openclawProvisioner: provisioner,
})
@@ -383,7 +383,7 @@ describe('AgentHarnessService', () => {
},
}
const service = new AgentHarnessService({
agentStore: createAgentStore([agent]) as FileAgentStore,
agentStore: createAgentStore([agent]) as AgentStore,
runtime,
})
@@ -432,7 +432,7 @@ describe('AgentHarnessService', () => {
},
}
const service = new AgentHarnessService({
agentStore: createAgentStore([agent]) as FileAgentStore,
agentStore: createAgentStore([agent]) as AgentStore,
runtime,
})
@@ -511,7 +511,7 @@ function createAgentStore(agents: AgentDefinition[]) {
agents.push(agent)
return agent
},
} satisfies Partial<FileAgentStore>
} satisfies Partial<AgentStore>
}
async function collectStream(

View File

@@ -0,0 +1,140 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { eq } from 'drizzle-orm'
import { DbAgentStore } from '../../../src/lib/agents/db-agent-store'
import { closeDb, initializeDb } from '../../../src/lib/db'
import { agentDefinitions } from '../../../src/lib/db/schema'
describe('DbAgentStore', () => {
const tempDirs: string[] = []
afterEach(async () => {
closeDb()
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('creates, lists, loads, updates, and deletes named agents', async () => {
const store = createStore()
const agent = await store.create({
name: ' Review bot ',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
})
expect(agent).toMatchObject({
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: `agent:${agent.id}:main`,
pinned: false,
})
const updated = await store.update(agent.id, {
name: 'Renamed bot',
pinned: true,
})
expect(updated).toMatchObject({
id: agent.id,
name: 'Renamed bot',
pinned: true,
})
expect(await store.get(agent.id)).toEqual(updated)
expect(await store.list()).toEqual([updated])
expect(await store.delete(agent.id)).toBe(true)
expect(await store.delete(agent.id)).toBe(false)
expect(await store.list()).toEqual([])
})
it('serializes concurrent creates without dropping agents', async () => {
const store = createStore()
const created = await Promise.all(
Array.from({ length: 10 }, (_, index) =>
store.create({
name: `Agent ${index}`,
adapter: index % 2 === 0 ? 'codex' : 'claude',
}),
),
)
const listed = await store.list()
expect(listed).toHaveLength(created.length)
expect(new Set(listed.map((agent) => agent.id)).size).toBe(created.length)
})
it('persists OpenClaw adapter config with the agent record', async () => {
const { db, store } = createStoreWithDb()
const agent = await store.create({
name: 'OpenClaw bot',
adapter: 'openclaw',
providerType: 'openai-compatible',
providerName: 'Kimi',
baseUrl: 'https://api.fireworks.ai/inference/v1',
apiKey: 'test-key',
supportsImages: true,
})
const row = db
.select()
.from(agentDefinitions)
.where(eq(agentDefinitions.id, agent.id))
.get()
expect(JSON.parse(row?.adapterConfigJson ?? '{}')).toEqual({
providerType: 'openai-compatible',
providerName: 'Kimi',
baseUrl: 'https://api.fireworks.ai/inference/v1',
apiKey: 'test-key',
supportsImages: true,
})
})
it('upserts gateway-owned OpenClaw records idempotently', async () => {
const store = createStore()
const first = await store.upsertExisting({
id: 'oc-existing',
name: 'Gateway agent',
adapter: 'openclaw',
modelId: 'openrouter/anthropic/claude-sonnet-4.5',
})
const second = await store.upsertExisting({
id: 'oc-existing',
name: 'Changed gateway name',
adapter: 'openclaw',
})
expect(second).toEqual(first)
expect(await store.list()).toEqual([first])
})
function createStore(): DbAgentStore {
return createStoreWithDb().store
}
function createStoreWithDb() {
const dir = mkdtempSync(join(tmpdir(), 'browseros-db-agents-test-'))
tempDirs.push(dir)
const handle = initializeDb({
dbPath: join(dir, 'db', 'browseros.sqlite'),
})
return { db: handle.db, store: new DbAgentStore({ db: handle.db }) }
}
})

View File

@@ -1,67 +0,0 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { FileAgentStore } from '../../../src/lib/agents/file-agent-store'
describe('FileAgentStore', () => {
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('creates, lists, loads, and deletes named agents', async () => {
const dir = await mkdtemp(join(tmpdir(), 'browseros-agents-'))
tempDirs.push(dir)
const store = new FileAgentStore({ filePath: join(dir, 'agents.json') })
const agent = await store.create({
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
})
expect(agent).toMatchObject({
name: 'Review bot',
adapter: 'codex',
modelId: 'gpt-5.5',
reasoningEffort: 'medium',
permissionMode: 'approve-all',
sessionKey: `agent:${agent.id}:main`,
})
expect(await store.list()).toEqual([agent])
expect(await store.get(agent.id)).toEqual(agent)
await store.delete(agent.id)
expect(await store.list()).toEqual([])
})
it('serializes concurrent creates without dropping agents', async () => {
const dir = await mkdtemp(join(tmpdir(), 'browseros-agents-'))
tempDirs.push(dir)
const store = new FileAgentStore({ filePath: join(dir, 'agents.json') })
const created = await Promise.all(
Array.from({ length: 10 }, (_, index) =>
store.create({
name: `Agent ${index}`,
adapter: index % 2 === 0 ? 'codex' : 'claude',
}),
),
)
const listed = await store.list()
expect(listed).toHaveLength(created.length)
expect(new Set(listed.map((agent) => agent.id)).size).toBe(created.length)
})
})

View File

@@ -0,0 +1,84 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it, spyOn } from 'bun:test'
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import {
getOAuthTokenManager,
initializeOAuth,
shutdownOAuth,
} from '../../../../src/lib/clients/oauth'
import { closeDb, initializeDb } from '../../../../src/lib/db'
describe('OAuth client setup', () => {
const tempDirs: string[] = []
afterEach(async () => {
shutdownOAuth()
closeDb()
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('initializes a process token manager backed by the BrowserOS database', () => {
const dir = mkdtempSync(join(tmpdir(), 'browseros-oauth-index-test-'))
tempDirs.push(dir)
const handle = initializeDb({
dbPath: join(dir, 'db', 'browseros.sqlite'),
})
const manager = initializeOAuth(handle.db, 'browseros-1')
expect(getOAuthTokenManager()).toBe(manager)
expect(manager.getStatus('qwen-code')).toEqual({
authenticated: false,
email: undefined,
provider: 'qwen-code',
})
manager.storeTokens('qwen-code', {
accessToken: 'access-token',
refreshToken: 'refresh-token',
expiresIn: 3600,
})
expect(manager.getStatus('qwen-code')).toEqual({
authenticated: true,
email: undefined,
provider: 'qwen-code',
})
})
it('stops and clears the current process token manager', () => {
const handle = initializeTestDb()
const firstManager = initializeOAuth(handle.db, 'browseros-1')
const stopFirst = spyOn(firstManager, 'stopCallbackServer')
const secondManager = initializeOAuth(handle.db, 'browseros-2')
expect(stopFirst).toHaveBeenCalledTimes(1)
expect(getOAuthTokenManager()).toBe(secondManager)
const stopSecond = spyOn(secondManager, 'stopCallbackServer')
shutdownOAuth()
expect(stopSecond).toHaveBeenCalledTimes(1)
expect(getOAuthTokenManager()).toBeNull()
})
function initializeTestDb() {
const dir = mkdtempSync(join(tmpdir(), 'browseros-oauth-index-test-'))
tempDirs.push(dir)
return initializeDb({
dbPath: join(dir, 'db', 'browseros.sqlite'),
})
}
})

View File

@@ -0,0 +1,81 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { OAuthTokenStore } from '../../../../src/lib/clients/oauth/token-store'
import { closeDb, initializeDb } from '../../../../src/lib/db'
describe('OAuthTokenStore', () => {
const tempDirs: string[] = []
afterEach(async () => {
closeDb()
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('stores, updates, reads, reports status, and deletes provider tokens', () => {
const store = createStore()
store.upsertTokens('browseros-1', 'github-copilot', {
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresAt: 1234,
email: 'user@example.com',
accountId: 'account-1',
})
expect(store.getTokens('browseros-1', 'github-copilot')).toEqual({
accessToken: 'access-1',
refreshToken: 'refresh-1',
expiresAt: 1234,
email: 'user@example.com',
accountId: 'account-1',
})
expect(store.getStatus('browseros-1', 'github-copilot')).toEqual({
authenticated: true,
email: 'user@example.com',
provider: 'github-copilot',
})
store.upsertTokens('browseros-1', 'github-copilot', {
accessToken: 'access-2',
refreshToken: '',
expiresAt: 0,
})
expect(store.getTokens('browseros-1', 'github-copilot')).toEqual({
accessToken: 'access-2',
refreshToken: '',
expiresAt: 0,
email: undefined,
accountId: undefined,
})
store.deleteTokens('browseros-1', 'github-copilot')
expect(store.getTokens('browseros-1', 'github-copilot')).toBeNull()
expect(store.getStatus('browseros-1', 'github-copilot')).toEqual({
authenticated: false,
email: undefined,
provider: 'github-copilot',
})
})
function createStore(): OAuthTokenStore {
const dir = mkdtempSync(join(tmpdir(), 'browseros-oauth-store-test-'))
tempDirs.push(dir)
const handle = initializeDb({
dbPath: join(dir, 'db', 'browseros.sqlite'),
})
return new OAuthTokenStore(handle.db)
}
})

View File

@@ -0,0 +1,62 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { existsSync, mkdtempSync } from 'node:fs'
import { rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { closeDb, initializeDb } from '../../../src/lib/db'
import { agentDefinitions } from '../../../src/lib/db/schema'
describe('database initialization', () => {
const tempDirs: string[] = []
afterEach(async () => {
closeDb()
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('creates the parent directory, opens sqlite, and runs migrations', () => {
const dir = mkTempDir()
const dbPath = join(dir, 'nested', 'browseros.sqlite')
const handle = initializeDb({ dbPath })
const rows = handle.db.select().from(agentDefinitions).all()
expect(existsSync(dbPath)).toBe(true)
expect(rows).toEqual([])
})
it('is idempotent when initialized twice for the same path', () => {
const dir = mkTempDir()
const dbPath = join(dir, 'browseros.sqlite')
const first = initializeDb({ dbPath })
const second = initializeDb({ dbPath })
expect(second).toBe(first)
})
it('fails clearly when an explicit migration directory is missing', () => {
const dir = mkTempDir()
expect(() =>
initializeDb({
dbPath: join(dir, 'browseros.sqlite'),
migrationsDir: join(dir, 'missing-migrations'),
}),
).toThrow(/Drizzle migrations directory not found/)
})
function mkTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'browseros-db-test-'))
tempDirs.push(dir)
return dir
}
})

View File

@@ -0,0 +1,63 @@
/**
* @license
* Copyright 2025 BrowserOS
*/
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtempSync } from 'node:fs'
import { readFile, rm } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { IdentityService } from '../../src/lib/identity'
describe('IdentityService', () => {
const tempDirs: string[] = []
afterEach(async () => {
await Promise.all(
tempDirs.map((dir) => rm(dir, { recursive: true, force: true })),
)
tempDirs.length = 0
})
it('uses the install id when config provides one', () => {
const service = new IdentityService()
service.initialize({ installId: 'install-123' })
expect(service.getBrowserOSId()).toBe('install-123')
})
it('ignores an empty install id and generates a fallback id', () => {
const dir = mkTempDir()
const statePath = join(dir, 'identity', 'browseros-id.json')
const service = new IdentityService()
service.initialize({ installId: '', statePath })
expect(service.getBrowserOSId()).not.toBe('')
})
it('persists a generated fallback id without using the database', async () => {
const dir = mkTempDir()
const statePath = join(dir, 'identity', 'browseros-id.json')
const first = new IdentityService()
first.initialize({ statePath })
const id = first.getBrowserOSId()
const second = new IdentityService()
second.initialize({ statePath })
expect(second.getBrowserOSId()).toBe(id)
expect(JSON.parse(await readFile(statePath, 'utf8'))).toEqual({
browserosId: id,
})
})
function mkTempDir(): string {
const dir = mkdtempSync(join(tmpdir(), 'browseros-identity-test-'))
tempDirs.push(dir)
return dir
}
})

View File

@@ -121,7 +121,15 @@ async function setupApplicationTest() {
spyOn(browserosDir, 'writeServerConfig').mockImplementation(async () => {})
spyOn(browserosDir, 'removeServerConfigSync').mockImplementation(() => {})
spyOn(dbModule, 'initializeDb').mockImplementation(() => ({}) as never)
spyOn(dbModule, 'initializeDb').mockImplementation(
() =>
({
path: '/tmp/browseros-execution/db/browseros.sqlite',
migrationsDir: '/tmp/browseros-resources/db/migrations',
sqlite: { close: () => {} },
db: {},
}) as never,
)
spyOn(identityModule.identity, 'initialize').mockImplementation(() => {})
spyOn(identityModule.identity, 'getBrowserOSId').mockImplementation(
() => 'browseros-id',

View File

@@ -187,6 +187,7 @@
"commander": "^14.0.1",
"core-js": "3.45.1",
"debug": "4.4.3",
"drizzle-orm": "^0.45.2",
"eventsource-parser": "^3.0.0",
"fuse.js": "^7.1.0",
"gray-matter": "^4.0.3",
@@ -209,6 +210,7 @@
"@types/sinon": "^21.0.0",
"@types/ws": "^8.5.13",
"async-mutex": "^0.5.0",
"drizzle-kit": "^0.31.10",
"pino-pretty": "^13.0.0",
"puppeteer": "24.23.0",
"sinon": "^21.0.1",
@@ -568,6 +570,8 @@
"@dnd-kit/utilities": ["@dnd-kit/utilities@3.2.2", "", { "dependencies": { "tslib": "^2.0.0" }, "peerDependencies": { "react": ">=16.8.0" } }, "sha512-+MKAJEOfaBe5SmV6t34p80MMKhjvUz0vRrvVJbPT0WElzaOJ/1xs+D+KDv+tD/NE5ujfrChEcshd4fLn0wpiqg=="],
"@drizzle-team/brocli": ["@drizzle-team/brocli@0.10.2", "", {}, "sha512-z33Il7l5dKjUgGULTqBsQBQwckHh5AbIuxhdsIxDDiZAzBOrZO6q9ogcWC65kU382AfynTfgNumVcNIjuIua6w=="],
"@emnapi/runtime": ["@emnapi/runtime@1.8.1", "", { "dependencies": { "tslib": "^2.4.0" } }, "sha512-mehfKSMWjjNol8659Z8KxEMrdSJDDot5SXMq00dM8BN4o+CLNXQ0xH2V7EchNHV4RmbZLmmPdEaXZc5H2FXmDg=="],
"@emoji-mart/data": ["@emoji-mart/data@1.2.1", "", {}, "sha512-no2pQMWiBy6gpBEiqGeU77/bFejDqUTRY7KX+0+iur13op3bqUsXdnwoZs6Xb1zbv0gAj5VvS1PWoUUckSr5Dw=="],
@@ -604,6 +608,10 @@
"@envelop/types": ["@envelop/types@5.2.1", "", { "dependencies": { "@whatwg-node/promise-helpers": "^1.0.0", "tslib": "^2.5.0" } }, "sha512-CsFmA3u3c2QoLDTfEpGr4t25fjMU31nyvse7IzWTvb0ZycuPjMjb0fjlheh+PbhBYb9YLugnT2uY6Mwcg1o+Zg=="],
"@esbuild-kit/core-utils": ["@esbuild-kit/core-utils@3.3.2", "", { "dependencies": { "esbuild": "~0.18.20", "source-map-support": "^0.5.21" } }, "sha512-sPRAnw9CdSsRmEtnsl2WXWdyquogVpB3yZ3dgwJfe8zrOzTsV7cJvmwrKVa+0ma5BoiGJ+BoqkMvawbayKUsqQ=="],
"@esbuild-kit/esm-loader": ["@esbuild-kit/esm-loader@2.6.5", "", { "dependencies": { "@esbuild-kit/core-utils": "^3.3.2", "get-tsconfig": "^4.7.0" } }, "sha512-FxEMIkJKnodyA1OaCUoEvbYRkoZlLZ4d/eXFu9Fh8CbBBgP5EmZxrfTRyN0qpXZ4vOvqnE5YdRdcrmUUXuU+dA=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.27.2", "", { "os": "aix", "cpu": "ppc64" }, "sha512-GZMB+a0mOMZs4MpDbj8RJp4cw+w1WV5NYD6xzgvzUJ5Ek2jerwfO2eADyI6ExDSUED+1X8aMbegahsJi+8mgpw=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.27.2", "", { "os": "android", "cpu": "arm" }, "sha512-DVNI8jlPa7Ujbr1yjU2PfUSRtAUZPG9I1RwW4F4xFB1Imiu2on0ADiI/c3td+KmDtVKNbi+nffGDQMfcIMkwIA=="],
@@ -2404,6 +2412,10 @@
"downshift": ["downshift@9.0.13", "", { "dependencies": { "@babel/runtime": "^7.24.5", "compute-scroll-into-view": "^3.1.0", "prop-types": "^15.8.1", "react-is": "18.2.0", "tslib": "^2.6.2" }, "peerDependencies": { "react": ">=16.12.0" } }, "sha512-fPV+K5jwEzfEAhNhprgCmpWQ23MKwKNzdbtK0QQFiw4hbFcKhMeGB+ccorfWJzmsLR5Dty+CmLDduWlIs74G/w=="],
"drizzle-kit": ["drizzle-kit@0.31.10", "", { "dependencies": { "@drizzle-team/brocli": "^0.10.2", "@esbuild-kit/esm-loader": "^2.5.5", "esbuild": "^0.25.4", "tsx": "^4.21.0" }, "bin": { "drizzle-kit": "bin.cjs" } }, "sha512-7OZcmQUrdGI+DUNNsKBn1aW8qSoKuTH7d0mYgSP8bAzdFzKoovxEFnoGQp2dVs82EOJeYycqRtciopszwUf8bw=="],
"drizzle-orm": ["drizzle-orm@0.45.2", "", { "peerDependencies": { "@aws-sdk/client-rds-data": ">=3", "@cloudflare/workers-types": ">=4", "@electric-sql/pglite": ">=0.2.0", "@libsql/client": ">=0.10.0", "@libsql/client-wasm": ">=0.10.0", "@neondatabase/serverless": ">=0.10.0", "@op-engineering/op-sqlite": ">=2", "@opentelemetry/api": "^1.4.1", "@planetscale/database": ">=1.13", "@prisma/client": "*", "@tidbcloud/serverless": "*", "@types/better-sqlite3": "*", "@types/pg": "*", "@types/sql.js": "*", "@upstash/redis": ">=1.34.7", "@vercel/postgres": ">=0.8.0", "@xata.io/client": "*", "better-sqlite3": ">=7", "bun-types": "*", "expo-sqlite": ">=14.0.0", "gel": ">=2", "knex": "*", "kysely": "*", "mysql2": ">=2", "pg": ">=8", "postgres": ">=3", "sql.js": ">=1", "sqlite3": ">=5" }, "optionalPeers": ["@aws-sdk/client-rds-data", "@cloudflare/workers-types", "@electric-sql/pglite", "@libsql/client", "@libsql/client-wasm", "@neondatabase/serverless", "@op-engineering/op-sqlite", "@opentelemetry/api", "@planetscale/database", "@prisma/client", "@tidbcloud/serverless", "@types/better-sqlite3", "@types/pg", "@types/sql.js", "@upstash/redis", "@vercel/postgres", "@xata.io/client", "better-sqlite3", "bun-types", "expo-sqlite", "gel", "knex", "kysely", "mysql2", "pg", "postgres", "sql.js", "sqlite3"] }, "sha512-kY0BSaTNYWnoDMVoyY8uxmyHjpJW1geOmBMdSSicKo9CIIWkSxMIj2rkeSR51b8KAPB7m+qysjuHme5nKP+E5Q=="],
"dset": ["dset@3.1.4", "", {}, "sha512-2QF/g9/zTaPDc3BjNcVTGoBbXBgYfMTTceLaYcFJ/W9kggFUkhxD/hMEeuLKbugyef9SqAx8cpgwlIP/jinUTA=="],
"dunder-proto": ["dunder-proto@1.0.1", "", { "dependencies": { "call-bind-apply-helpers": "^1.0.1", "es-errors": "^1.3.0", "gopd": "^1.2.0" } }, "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A=="],
@@ -4418,6 +4430,8 @@
"@emotion/serialize/@emotion/unitless": ["@emotion/unitless@0.10.0", "", {}, "sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg=="],
"@esbuild-kit/core-utils/esbuild": ["esbuild@0.18.20", "", { "optionalDependencies": { "@esbuild/android-arm": "0.18.20", "@esbuild/android-arm64": "0.18.20", "@esbuild/android-x64": "0.18.20", "@esbuild/darwin-arm64": "0.18.20", "@esbuild/darwin-x64": "0.18.20", "@esbuild/freebsd-arm64": "0.18.20", "@esbuild/freebsd-x64": "0.18.20", "@esbuild/linux-arm": "0.18.20", "@esbuild/linux-arm64": "0.18.20", "@esbuild/linux-ia32": "0.18.20", "@esbuild/linux-loong64": "0.18.20", "@esbuild/linux-mips64el": "0.18.20", "@esbuild/linux-ppc64": "0.18.20", "@esbuild/linux-riscv64": "0.18.20", "@esbuild/linux-s390x": "0.18.20", "@esbuild/linux-x64": "0.18.20", "@esbuild/netbsd-x64": "0.18.20", "@esbuild/openbsd-x64": "0.18.20", "@esbuild/sunos-x64": "0.18.20", "@esbuild/win32-arm64": "0.18.20", "@esbuild/win32-ia32": "0.18.20", "@esbuild/win32-x64": "0.18.20" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-ceqxoedUrcayh7Y7ZX6NdbbDzGROiyVBgC4PriJThBKSVPWnnFHZAkfI1lJT8QFkOwH4qOS2SJkS4wvpGl8BpA=="],
"@google/gemini-cli-core/@google/genai": ["@google/genai@1.16.0", "", { "dependencies": { "google-auth-library": "^9.14.2", "ws": "^8.18.0" }, "peerDependencies": { "@modelcontextprotocol/sdk": "^1.11.4" }, "optionalPeers": ["@modelcontextprotocol/sdk"] }, "sha512-hdTYu39QgDFxv+FB6BK2zi4UIJGWhx2iPc0pHQ0C5Q/RCi+m+4gsryIzTGO+riqWcUA8/WGYp6hpqckdOBNysw=="],
"@google/gemini-cli-core/@modelcontextprotocol/sdk": ["@modelcontextprotocol/sdk@1.26.0", "", { "dependencies": { "@hono/node-server": "^1.19.9", "ajv": "^8.17.1", "ajv-formats": "^3.0.1", "content-type": "^1.0.5", "cors": "^2.8.5", "cross-spawn": "^7.0.5", "eventsource": "^3.0.2", "eventsource-parser": "^3.0.0", "express": "^5.2.1", "express-rate-limit": "^8.2.1", "hono": "^4.11.4", "jose": "^6.1.3", "json-schema-typed": "^8.0.2", "pkce-challenge": "^5.0.0", "raw-body": "^3.0.0", "zod": "^3.25 || ^4.0", "zod-to-json-schema": "^3.25.1" }, "peerDependencies": { "@cfworker/json-schema": "^4.1.1" }, "optionalPeers": ["@cfworker/json-schema"] }, "sha512-Y5RmPncpiDtTXDbLKswIJzTqu2hyBKxTNsgKqKclDbhIgg1wgtf1fRuvxgTnRfcnxtvvgbIEcqUOzZrJ6iSReg=="],
@@ -4884,6 +4898,8 @@
"dotenv-expand/dotenv": ["dotenv@16.6.1", "", {}, "sha512-uBq4egWHTcTt33a72vpSG0z3HnPuIl6NqYcTrKEg2azoEyl2hpW0zqlxysq2pK9HlDIHyHyakeYaYnSAwd8bow=="],
"drizzle-kit/esbuild": ["esbuild@0.25.12", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.25.12", "@esbuild/android-arm": "0.25.12", "@esbuild/android-arm64": "0.25.12", "@esbuild/android-x64": "0.25.12", "@esbuild/darwin-arm64": "0.25.12", "@esbuild/darwin-x64": "0.25.12", "@esbuild/freebsd-arm64": "0.25.12", "@esbuild/freebsd-x64": "0.25.12", "@esbuild/linux-arm": "0.25.12", "@esbuild/linux-arm64": "0.25.12", "@esbuild/linux-ia32": "0.25.12", "@esbuild/linux-loong64": "0.25.12", "@esbuild/linux-mips64el": "0.25.12", "@esbuild/linux-ppc64": "0.25.12", "@esbuild/linux-riscv64": "0.25.12", "@esbuild/linux-s390x": "0.25.12", "@esbuild/linux-x64": "0.25.12", "@esbuild/netbsd-arm64": "0.25.12", "@esbuild/netbsd-x64": "0.25.12", "@esbuild/openbsd-arm64": "0.25.12", "@esbuild/openbsd-x64": "0.25.12", "@esbuild/openharmony-arm64": "0.25.12", "@esbuild/sunos-x64": "0.25.12", "@esbuild/win32-arm64": "0.25.12", "@esbuild/win32-ia32": "0.25.12", "@esbuild/win32-x64": "0.25.12" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-bbPBYYrtZbkt6Os6FiTLCTFxvq4tt3JKall1vRwshA3fdVztsLAatFaZobhkBC8/BrPetoa0oksYoKXoG4ryJg=="],
"duplexify/readable-stream": ["readable-stream@3.6.2", "", { "dependencies": { "inherits": "^2.0.3", "string_decoder": "^1.1.1", "util-deprecate": "^1.0.1" } }, "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA=="],
"ecdsa-sig-formatter/safe-buffer": ["safe-buffer@5.2.1", "", {}, "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ=="],
@@ -5348,6 +5364,50 @@
"@browseros/server/@types/bun/bun-types": ["bun-types@1.3.5", "", { "dependencies": { "@types/node": "*" } }, "sha512-inmAYe2PFLs0SUbFOWSVD24sg1jFlMPxOjOSSCYqUgn4Hsc3rDc7dFvfVYjFPNHtov6kgUeulV4SxbuIV/stPw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.18.20", "", { "os": "android", "cpu": "arm" }, "sha512-fyi7TDI/ijKKNZTUJAQqiG5T7YjJXgnzkURqmGj13C6dCqckZBLdl4h7bkhHt/t0WP+zO9/zwroDvANaOqO5Sw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.18.20", "", { "os": "android", "cpu": "arm64" }, "sha512-Nz4rJcchGDtENV0eMKUNa6L12zz2zBDXuhj/Vjh18zGqB44Bi7MBMSXjgunJgjRhCmKOjnPuZp4Mb6OKqtMHLQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.18.20", "", { "os": "android", "cpu": "x64" }, "sha512-8GDdlePJA8D6zlZYJV/jnrRAi6rOiNaCC/JclcXpB+KIuvfBN4owLtgzY2bsxnx666XjJx2kDPUmnTtR8qKQUg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.18.20", "", { "os": "darwin", "cpu": "arm64" }, "sha512-bxRHW5kHU38zS2lPTPOyuyTm+S+eobPUnTNkdJEfAddYgEcll4xkT8DB9d2008DtTbl7uJag2HuE5NZAZgnNEA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.18.20", "", { "os": "darwin", "cpu": "x64" }, "sha512-pc5gxlMDxzm513qPGbCbDukOdsGtKhfxD1zJKXjCCcU7ju50O7MeAZ8c4krSJcOIJGFR+qx21yMMVYwiQvyTyQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.18.20", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-yqDQHy4QHevpMAaxhhIwYPMv1NECwOvIpGCZkECn8w2WFHXjEwrBn3CeNIYsibZ/iZEUemj++M26W3cNR5h+Tw=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.18.20", "", { "os": "freebsd", "cpu": "x64" }, "sha512-tgWRPPuQsd3RmBZwarGVHZQvtzfEBOreNuxEMKFcd5DaDn2PbBxfwLcj4+aenoh7ctXcbXmOQIn8HI6mCSw5MQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.18.20", "", { "os": "linux", "cpu": "arm" }, "sha512-/5bHkMWnq1EgKr1V+Ybz3s1hWXok7mDFUMQ4cG10AfW3wL02PSZi5kFpYKrptDsgb2WAJIvRcDm+qIvXf/apvg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.18.20", "", { "os": "linux", "cpu": "arm64" }, "sha512-2YbscF+UL7SQAVIpnWvYwM+3LskyDmPhe31pE7/aoTMFKKzIc9lLbyGUpmmb8a8AixOL61sQ/mFh3jEjHYFvdA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.18.20", "", { "os": "linux", "cpu": "ia32" }, "sha512-P4etWwq6IsReT0E1KHU40bOnzMHoH73aXp96Fs8TIT6z9Hu8G6+0SHSw9i2isWrD2nbx2qo5yUqACgdfVGx7TA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-nXW8nqBTrOpDLPgPY9uV+/1DjxoQ7DoB2N8eocyq8I9XuqJ7BiAMDMf9n1xZM9TgW0J8zrquIb/A7s3BJv7rjg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-d5NeaXZcHp8PzYy5VnXV3VSd2D328Zb+9dEq5HE6bw6+N86JVPExrA6O68OPwobntbNJ0pzCpUFZTo3w0GyetQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.18.20", "", { "os": "linux", "cpu": "ppc64" }, "sha512-WHPyeScRNcmANnLQkq6AfyXRFr5D6N2sKgkFo2FqguP44Nw2eyDlbTdZwd9GYk98DZG9QItIiTlFLHJHjxP3FA=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.18.20", "", { "os": "linux", "cpu": "none" }, "sha512-WSxo6h5ecI5XH34KC7w5veNnKkju3zBRLEQNY7mv5mtBmrP/MjNBCAlsM2u5hDBlS3NGcTQpoBvRzqBcRtpq1A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.18.20", "", { "os": "linux", "cpu": "s390x" }, "sha512-+8231GMs3mAEth6Ja1iK0a1sQ3ohfcpzpRLH8uuc5/KVDFneH6jtAJLFGafpzpMRO6DzJ6AvXKze9LfFMrIHVQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.18.20", "", { "os": "linux", "cpu": "x64" }, "sha512-UYqiqemphJcNsFEskc73jQ7B9jgwjWrSayxawS6UVFZGWrAAtkzjxSqnoclCXxWtfwLdzU+vTpcNYhpn43uP1w=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.18.20", "", { "os": "none", "cpu": "x64" }, "sha512-iO1c++VP6xUBUmltHZoMtCUdPlnPGdBom6IrO4gyKPFFVBKioIImVooR5I83nTew5UOYrk3gIJhbZh8X44y06A=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.18.20", "", { "os": "openbsd", "cpu": "x64" }, "sha512-e5e4YSsuQfX4cxcygw/UCPIEP6wbIL+se3sxPdCiMbFLBWu0eiZOJ7WoD+ptCLrmjZBK1Wk7I6D/I3NglUGOxg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.18.20", "", { "os": "sunos", "cpu": "x64" }, "sha512-kDbFRFp0YpTQVVrqUd5FTYmWo45zGaXe0X8E1G/LKFC0v8x0vWrhOWSLITcCn63lmZIxfOMXtCfti/RxN/0wnQ=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.18.20", "", { "os": "win32", "cpu": "arm64" }, "sha512-ddYFR6ItYgoaq4v4JmQQaAI5s7npztfV4Ag6NrhiaW0RrnOXqBkgwZLofVTlq1daVTQNhtI5oieTvkRPfZrePg=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.18.20", "", { "os": "win32", "cpu": "ia32" }, "sha512-Wv7QBi3ID/rROT08SABTS7eV4hX26sVduqDOTe1MvGMjNd3EjOz4b7zeexIR62GTIEKrfJXKL9LFxTYgkyeu7g=="],
"@esbuild-kit/core-utils/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.18.20", "", { "os": "win32", "cpu": "x64" }, "sha512-kTdfRcSiDfQca/y9QIkng02avJ+NCaQvrMejlsB3RRv5sE9rRoeBPISaZpKxHELzRxZyLvNts1P27W3wV+8geQ=="],
"@google/gemini-cli-core/@modelcontextprotocol/sdk/zod": ["zod@4.3.5", "", {}, "sha512-k7Nwx6vuWx1IJ9Bjuf4Zt1PEllcwe7cls3VNzm4CQ1/hgtFUK2bRNG3rvnpPUhFjmqJKAKtjV576KnUkHocg/g=="],
"@google/gemini-cli-core/@opentelemetry/exporter-logs-otlp-http/@opentelemetry/api-logs": ["@opentelemetry/api-logs@0.203.0", "", { "dependencies": { "@opentelemetry/api": "^1.3.0" } }, "sha512-9B9RU0H7Ya1Dx/Rkyc4stuBZSGVQF27WigitInx2QQoj6KUpEFYPKoWjdFTunJYxmXmh17HeBvbMa1EhGyPmqQ=="],
@@ -5560,6 +5620,58 @@
"d3-sankey/d3-shape/d3-path": ["d3-path@1.0.9", "", {}, "sha512-VLaYcn81dtHVTjEHd8B+pbe9yHWpXKZUC87PzoFmsFrJqgFwDe/qxfp5MlfsfM1V5E/iVt0MmEbWQ7FVIXh/bg=="],
"drizzle-kit/esbuild/@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.25.12", "", { "os": "aix", "cpu": "ppc64" }, "sha512-Hhmwd6CInZ3dwpuGTF8fJG6yoWmsToE+vYgD4nytZVxcu1ulHpUQRAB1UJ8+N1Am3Mz4+xOByoQoSZf4D+CpkA=="],
"drizzle-kit/esbuild/@esbuild/android-arm": ["@esbuild/android-arm@0.25.12", "", { "os": "android", "cpu": "arm" }, "sha512-VJ+sKvNA/GE7Ccacc9Cha7bpS8nyzVv0jdVgwNDaR4gDMC/2TTRc33Ip8qrNYUcpkOHUT5OZ0bUcNNVZQ9RLlg=="],
"drizzle-kit/esbuild/@esbuild/android-arm64": ["@esbuild/android-arm64@0.25.12", "", { "os": "android", "cpu": "arm64" }, "sha512-6AAmLG7zwD1Z159jCKPvAxZd4y/VTO0VkprYy+3N2FtJ8+BQWFXU+OxARIwA46c5tdD9SsKGZ/1ocqBS/gAKHg=="],
"drizzle-kit/esbuild/@esbuild/android-x64": ["@esbuild/android-x64@0.25.12", "", { "os": "android", "cpu": "x64" }, "sha512-5jbb+2hhDHx5phYR2By8GTWEzn6I9UqR11Kwf22iKbNpYrsmRB18aX/9ivc5cabcUiAT/wM+YIZ6SG9QO6a8kg=="],
"drizzle-kit/esbuild/@esbuild/darwin-arm64": ["@esbuild/darwin-arm64@0.25.12", "", { "os": "darwin", "cpu": "arm64" }, "sha512-N3zl+lxHCifgIlcMUP5016ESkeQjLj/959RxxNYIthIg+CQHInujFuXeWbWMgnTo4cp5XVHqFPmpyu9J65C1Yg=="],
"drizzle-kit/esbuild/@esbuild/darwin-x64": ["@esbuild/darwin-x64@0.25.12", "", { "os": "darwin", "cpu": "x64" }, "sha512-HQ9ka4Kx21qHXwtlTUVbKJOAnmG1ipXhdWTmNXiPzPfWKpXqASVcWdnf2bnL73wgjNrFXAa3yYvBSd9pzfEIpA=="],
"drizzle-kit/esbuild/@esbuild/freebsd-arm64": ["@esbuild/freebsd-arm64@0.25.12", "", { "os": "freebsd", "cpu": "arm64" }, "sha512-gA0Bx759+7Jve03K1S0vkOu5Lg/85dou3EseOGUes8flVOGxbhDDh/iZaoek11Y8mtyKPGF3vP8XhnkDEAmzeg=="],
"drizzle-kit/esbuild/@esbuild/freebsd-x64": ["@esbuild/freebsd-x64@0.25.12", "", { "os": "freebsd", "cpu": "x64" }, "sha512-TGbO26Yw2xsHzxtbVFGEXBFH0FRAP7gtcPE7P5yP7wGy7cXK2oO7RyOhL5NLiqTlBh47XhmIUXuGciXEqYFfBQ=="],
"drizzle-kit/esbuild/@esbuild/linux-arm": ["@esbuild/linux-arm@0.25.12", "", { "os": "linux", "cpu": "arm" }, "sha512-lPDGyC1JPDou8kGcywY0YILzWlhhnRjdof3UlcoqYmS9El818LLfJJc3PXXgZHrHCAKs/Z2SeZtDJr5MrkxtOw=="],
"drizzle-kit/esbuild/@esbuild/linux-arm64": ["@esbuild/linux-arm64@0.25.12", "", { "os": "linux", "cpu": "arm64" }, "sha512-8bwX7a8FghIgrupcxb4aUmYDLp8pX06rGh5HqDT7bB+8Rdells6mHvrFHHW2JAOPZUbnjUpKTLg6ECyzvas2AQ=="],
"drizzle-kit/esbuild/@esbuild/linux-ia32": ["@esbuild/linux-ia32@0.25.12", "", { "os": "linux", "cpu": "ia32" }, "sha512-0y9KrdVnbMM2/vG8KfU0byhUN+EFCny9+8g202gYqSSVMonbsCfLjUO+rCci7pM0WBEtz+oK/PIwHkzxkyharA=="],
"drizzle-kit/esbuild/@esbuild/linux-loong64": ["@esbuild/linux-loong64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-h///Lr5a9rib/v1GGqXVGzjL4TMvVTv+s1DPoxQdz7l/AYv6LDSxdIwzxkrPW438oUXiDtwM10o9PmwS/6Z0Ng=="],
"drizzle-kit/esbuild/@esbuild/linux-mips64el": ["@esbuild/linux-mips64el@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-iyRrM1Pzy9GFMDLsXn1iHUm18nhKnNMWscjmp4+hpafcZjrr2WbT//d20xaGljXDBYHqRcl8HnxbX6uaA/eGVw=="],
"drizzle-kit/esbuild/@esbuild/linux-ppc64": ["@esbuild/linux-ppc64@0.25.12", "", { "os": "linux", "cpu": "ppc64" }, "sha512-9meM/lRXxMi5PSUqEXRCtVjEZBGwB7P/D4yT8UG/mwIdze2aV4Vo6U5gD3+RsoHXKkHCfSxZKzmDssVlRj1QQA=="],
"drizzle-kit/esbuild/@esbuild/linux-riscv64": ["@esbuild/linux-riscv64@0.25.12", "", { "os": "linux", "cpu": "none" }, "sha512-Zr7KR4hgKUpWAwb1f3o5ygT04MzqVrGEGXGLnj15YQDJErYu/BGg+wmFlIDOdJp0PmB0lLvxFIOXZgFRrdjR0w=="],
"drizzle-kit/esbuild/@esbuild/linux-s390x": ["@esbuild/linux-s390x@0.25.12", "", { "os": "linux", "cpu": "s390x" }, "sha512-MsKncOcgTNvdtiISc/jZs/Zf8d0cl/t3gYWX8J9ubBnVOwlk65UIEEvgBORTiljloIWnBzLs4qhzPkJcitIzIg=="],
"drizzle-kit/esbuild/@esbuild/linux-x64": ["@esbuild/linux-x64@0.25.12", "", { "os": "linux", "cpu": "x64" }, "sha512-uqZMTLr/zR/ed4jIGnwSLkaHmPjOjJvnm6TVVitAa08SLS9Z0VM8wIRx7gWbJB5/J54YuIMInDquWyYvQLZkgw=="],
"drizzle-kit/esbuild/@esbuild/netbsd-arm64": ["@esbuild/netbsd-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-xXwcTq4GhRM7J9A8Gv5boanHhRa/Q9KLVmcyXHCTaM4wKfIpWkdXiMog/KsnxzJ0A1+nD+zoecuzqPmCRyBGjg=="],
"drizzle-kit/esbuild/@esbuild/netbsd-x64": ["@esbuild/netbsd-x64@0.25.12", "", { "os": "none", "cpu": "x64" }, "sha512-Ld5pTlzPy3YwGec4OuHh1aCVCRvOXdH8DgRjfDy/oumVovmuSzWfnSJg+VtakB9Cm0gxNO9BzWkj6mtO1FMXkQ=="],
"drizzle-kit/esbuild/@esbuild/openbsd-arm64": ["@esbuild/openbsd-arm64@0.25.12", "", { "os": "openbsd", "cpu": "arm64" }, "sha512-fF96T6KsBo/pkQI950FARU9apGNTSlZGsv1jZBAlcLL1MLjLNIWPBkj5NlSz8aAzYKg+eNqknrUJ24QBybeR5A=="],
"drizzle-kit/esbuild/@esbuild/openbsd-x64": ["@esbuild/openbsd-x64@0.25.12", "", { "os": "openbsd", "cpu": "x64" }, "sha512-MZyXUkZHjQxUvzK7rN8DJ3SRmrVrke8ZyRusHlP+kuwqTcfWLyqMOE3sScPPyeIXN/mDJIfGXvcMqCgYKekoQw=="],
"drizzle-kit/esbuild/@esbuild/openharmony-arm64": ["@esbuild/openharmony-arm64@0.25.12", "", { "os": "none", "cpu": "arm64" }, "sha512-rm0YWsqUSRrjncSXGA7Zv78Nbnw4XL6/dzr20cyrQf7ZmRcsovpcRBdhD43Nuk3y7XIoW2OxMVvwuRvk9XdASg=="],
"drizzle-kit/esbuild/@esbuild/sunos-x64": ["@esbuild/sunos-x64@0.25.12", "", { "os": "sunos", "cpu": "x64" }, "sha512-3wGSCDyuTHQUzt0nV7bocDy72r2lI33QL3gkDNGkod22EsYl04sMf0qLb8luNKTOmgF/eDEDP5BFNwoBKH441w=="],
"drizzle-kit/esbuild/@esbuild/win32-arm64": ["@esbuild/win32-arm64@0.25.12", "", { "os": "win32", "cpu": "arm64" }, "sha512-rMmLrur64A7+DKlnSuwqUdRKyd3UE7oPJZmnljqEptesKM8wx9J8gx5u0+9Pq0fQQW8vqeKebwNXdfOyP+8Bsg=="],
"drizzle-kit/esbuild/@esbuild/win32-ia32": ["@esbuild/win32-ia32@0.25.12", "", { "os": "win32", "cpu": "ia32" }, "sha512-HkqnmmBoCbCwxUKKNPBixiWDGCpQGVsrQfJoVGYLPT41XWF8lHuE5N6WhVia2n4o5QK5M4tYr21827fNhi4byQ=="],
"drizzle-kit/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.25.12", "", { "os": "win32", "cpu": "x64" }, "sha512-alJC0uCZpTFrSL0CCDjcgleBXPnCrEAhTBILpeAp7M/OFgoqtAetfBzX0xM00MUsVVPpVjlPuMbREqnZCXaTnA=="],
"form-data/mime-types/mime-db": ["mime-db@1.52.0", "", {}, "sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg=="],
"fx-runner/which/is-absolute": ["is-absolute@0.1.7", "", { "dependencies": { "is-relative": "^0.1.0" } }, "sha512-Xi9/ZSn4NFapG8RP98iNPMOeaV3mXPisxKxzKtHVqr3g56j/fBn+yZmnxSVAA8lmZbl2J9b/a4kJvfU3hqQYgA=="],

View File

@@ -51,6 +51,17 @@
"destination": "resources/vm/browseros-vm.yaml",
"os": ["macos"],
"arch": ["arm64", "x64"]
},
{
"name": "Drizzle migrations",
"source": {
"type": "local",
"path": "apps/server/src/lib/db/migrations"
},
"destination": "resources/db/migrations",
"recursive": true,
"os": ["macos"],
"arch": ["arm64", "x64"]
}
]
}

View File

@@ -20,6 +20,11 @@ function validateRule(rule: ResourceRule): void {
`Manifest rule ${rule.name} is missing source path or destination`,
)
}
if (rule.recursive && rule.source.type !== 'local') {
throw new Error(
`Manifest rule ${rule.name} uses recursive with non-local source`,
)
}
}
function parseSource(raw: unknown): ResourceRule['source'] {
@@ -54,6 +59,7 @@ function parseRule(raw: unknown): ResourceRule {
source: parseSource(item.source),
destination: String(item.destination ?? ''),
executable: item.executable === true,
recursive: item.recursive === true,
}
if (isStringArray(item.os)) {
rule.os = item.os as ResourceRule['os']

View File

@@ -1,8 +1,10 @@
import { afterEach, describe, expect, it } from 'bun:test'
import { mkdtemp, rm, writeFile } from 'node:fs/promises'
import { mkdir, mkdtemp, readFile, rm, writeFile } from 'node:fs/promises'
import { tmpdir } from 'node:os'
import { join } from 'node:path'
import { loadManifest } from './manifest'
import { stageCompiledArtifact } from './stage'
import type { BuildTarget, ResourceRule } from './types'
describe('server artifact staging', () => {
let tempDir: string | null = null
@@ -23,4 +25,90 @@ describe('server artifact staging', () => {
resources: [],
})
})
it('parses recursive local-resource rules from the manifest', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
const manifestPath = join(tempDir, 'manifest.json')
await writeFile(
manifestPath,
JSON.stringify({
resources: [
{
name: 'Drizzle migrations',
source: {
type: 'local',
path: 'apps/server/src/lib/db/migrations',
},
destination: 'resources/db/migrations',
recursive: true,
os: ['macos'],
arch: ['arm64', 'x64'],
},
],
}),
)
expect(loadManifest(manifestPath).resources[0]).toMatchObject({
name: 'Drizzle migrations',
recursive: true,
})
})
it('copies recursive local resource directories', async () => {
tempDir = await mkdtemp(join(tmpdir(), 'browseros-stage-test-'))
const sourceRoot = join(tempDir, 'source')
const distRoot = join(tempDir, 'dist')
const binaryPath = join(tempDir, 'browseros-server')
const migrationsDir = join(sourceRoot, 'apps/server/src/lib/db/migrations')
await mkdir(join(migrationsDir, 'meta'), { recursive: true })
await writeFile(binaryPath, 'server')
await writeFile(join(migrationsDir, '0000_init.sql'), 'CREATE TABLE x;')
await writeFile(
join(migrationsDir, 'meta', '_journal.json'),
'{"entries":[]}',
)
const artifact = await stageCompiledArtifact(
distRoot,
binaryPath,
testTarget,
'0.0.0-test',
[migrationRule],
sourceRoot,
)
expect(
await readFile(
join(artifact.resourcesDir, 'db/migrations/0000_init.sql'),
'utf8',
),
).toBe('CREATE TABLE x;')
expect(
await readFile(
join(artifact.resourcesDir, 'db/migrations/meta/_journal.json'),
'utf8',
),
).toBe('{"entries":[]}')
})
})
const testTarget: BuildTarget = {
id: 'darwin-arm64',
name: 'macOS ARM64',
os: 'macos',
arch: 'arm64',
bunTarget: 'bun-darwin-arm64',
serverBinaryName: 'browseros-server',
}
const migrationRule: ResourceRule = {
name: 'Drizzle migrations',
source: {
type: 'local',
path: 'apps/server/src/lib/db/migrations',
},
destination: 'resources/db/migrations',
recursive: true,
os: ['macos'],
arch: ['arm64', 'x64'],
}

View File

@@ -108,7 +108,7 @@ async function stageLocalRule(
const sourcePath = isAbsolute(rule.source.path)
? rule.source.path
: resolve(sourceRoot, rule.source.path)
await cp(sourcePath, destinationPath)
await cp(sourcePath, destinationPath, { recursive: rule.recursive === true })
if (rule.executable && target.os !== 'windows') {
await chmod(destinationPath, 0o755)

View File

@@ -57,6 +57,7 @@ export interface ResourceRule {
source: ResourceSource
destination: string
executable?: boolean
recursive?: boolean
os?: TargetOs[]
arch?: TargetArch[]
}