Skip to content
Merged
Show file tree
Hide file tree
Changes from 4 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
fd67305
feat: add remote skill download and auto-sync
shivammittal274 Mar 17, 2026
08b07d6
feat: make skills catalog URL configurable and add generation script
shivammittal274 Mar 17, 2026
cc069f4
feat: add R2 upload script and use cdn.browseros.com for catalog URL
shivammittal274 Mar 17, 2026
df149ab
test: add E2E tests for remote skill sync against live CDN
shivammittal274 Mar 17, 2026
ed456ab
fix: address code review findings — security, validation, DRY
shivammittal274 Mar 17, 2026
bb7c95d
test: add flow tests for all four sync scenarios against live CDN
shivammittal274 Mar 17, 2026
62817ce
refactor: remove redundant scripts and inline catalog generation
shivammittal274 Mar 17, 2026
d7f785d
test: add full E2E server flow test against live CDN
shivammittal274 Mar 17, 2026
bba8726
chore: remove e2e-server-flow test
shivammittal274 Mar 17, 2026
09f9a73
fix: address Greptile review — entry validation, size limit, DRY, no-…
shivammittal274 Mar 17, 2026
4792c3a
fix: prevent bundled fallback from overwriting partial remote seeds
shivammittal274 Mar 17, 2026
f35a5de
fix: run sync immediately on startup, not just on interval
shivammittal274 Mar 17, 2026
25a58ed
refactor: simplify sync — remote always wins, remove manifest
shivammittal274 Mar 17, 2026
12a5436
fix: skip bundled skills already installed by partial remote seed
shivammittal274 Mar 17, 2026
f3051f7
chore: remove unreliable Content-Length check
shivammittal274 Mar 17, 2026
e23e850
chore: remove size limit checks, fetch timeout is sufficient
shivammittal274 Mar 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions packages/browseros-agent/apps/server/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export const INLINED_ENV = {
CODEGEN_SERVICE_URL: process.env.CODEGEN_SERVICE_URL,
POSTHOG_API_KEY: process.env.POSTHOG_API_KEY,
BROWSEROS_CONFIG_URL: process.env.BROWSEROS_CONFIG_URL,
SKILLS_CATALOG_URL: process.env.SKILLS_CATALOG_URL,
} as const

export const REQUIRED_FOR_PRODUCTION = [
Expand Down
2 changes: 2 additions & 0 deletions packages/browseros-agent/apps/server/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -28,6 +28,7 @@ import { fetchDailyRateLimit } from './lib/rate-limiter/fetch-config'
import { RateLimiter } from './lib/rate-limiter/rate-limiter'
import { Sentry } from './lib/sentry'
import { seedSoulTemplate } from './lib/soul'
import { startSkillSync } from './skills/remote-sync'
import { seedDefaultSkills } from './skills/seed'
import { registry } from './tools/registry'
import { VERSION } from './version'
Expand Down Expand Up @@ -111,6 +112,7 @@ export class Application {
)

this.logStartupSummary(controllerServerStarted)
startSkillSync()

metrics.log('http_server.started', { version: VERSION })
}
Expand Down
214 changes: 214 additions & 0 deletions packages/browseros-agent/apps/server/src/skills/remote-sync.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
import { createHash } from 'node:crypto'
import { mkdir, readFile, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
import { INLINED_ENV } from '../env'
import { getSkillsDir } from '../lib/browseros-dir'
import { logger } from '../lib/logger'
import type {
ManagedSkillRecord,
RemoteSkillCatalog,
RemoteSkillEntry,
SkillManifest,
} from './types'

const MANIFEST_FILE = '.remote-manifest.json'

let syncTimer: ReturnType<typeof setInterval> | null = null

function contentHash(content: string): string {
return createHash('sha256').update(content).digest('hex')
}

function getManifestPath(): string {
return join(getSkillsDir(), MANIFEST_FILE)
}

export async function loadManifest(): Promise<SkillManifest> {
try {
const raw = await readFile(getManifestPath(), 'utf-8')
return JSON.parse(raw) as SkillManifest
} catch {
return { lastSyncedAt: '', skills: {} }
}
}

async function saveManifest(manifest: SkillManifest): Promise<void> {
await writeFile(getManifestPath(), JSON.stringify(manifest, null, 2))
}

function getCatalogUrl(): string {
return INLINED_ENV.SKILLS_CATALOG_URL || EXTERNAL_URLS.SKILLS_CATALOG
}

export async function fetchRemoteCatalog(): Promise<RemoteSkillCatalog | null> {
try {
const response = await fetch(getCatalogUrl(), {
signal: AbortSignal.timeout(TIMEOUTS.SKILLS_FETCH),
})
if (!response.ok) {
logger.warn('Failed to fetch remote skill catalog', {
status: response.status,
})
return null
}
return (await response.json()) as RemoteSkillCatalog
} catch (err) {
logger.debug('Remote skill catalog unavailable', {
error: err instanceof Error ? err.message : String(err),
})
return null
}
}

function isSkillCustomized(
skillId: string,
currentContent: string,
manifest: SkillManifest,
): boolean {
const record = manifest.skills[skillId]
if (!record) return false
return contentHash(currentContent) !== record.contentHash
}

async function readSkillContent(skillId: string): Promise<string | null> {
try {
return await readFile(
join(getSkillsDir(), skillId, 'SKILL.md'),
'utf-8',
)
} catch {
return null
}
}

async function writeSkillFile(
skillId: string,
content: string,
): Promise<void> {
const targetDir = join(getSkillsDir(), skillId)
await mkdir(targetDir, { recursive: true })
await writeFile(join(targetDir, 'SKILL.md'), content)
}
Comment thread
shivammittal274 marked this conversation as resolved.
Outdated

async function installSkill(
skill: RemoteSkillEntry,
manifest: SkillManifest,
): Promise<void> {
await writeSkillFile(skill.id, skill.content)
manifest.skills[skill.id] = {
version: skill.version,
contentHash: contentHash(skill.content),
}
}

export async function syncRemoteSkills(): Promise<{
installed: number
updated: number
skipped: number
}> {
const result = { installed: 0, updated: 0, skipped: 0 }
const catalog = await fetchRemoteCatalog()
if (!catalog) return result

const manifest = await loadManifest()

for (const remoteSkill of catalog.skills) {
const localContent = await readSkillContent(remoteSkill.id)
const localRecord: ManagedSkillRecord | undefined =
manifest.skills[remoteSkill.id]

if (!localContent) {
await installSkill(remoteSkill, manifest)
result.installed++
continue
}

if (!localRecord) {
// Skill exists locally but isn't tracked — treat as user-managed
result.skipped++
continue
}

if (localRecord.version === remoteSkill.version) {
continue
}

if (isSkillCustomized(remoteSkill.id, localContent, manifest)) {
result.skipped++
continue
}

await installSkill(remoteSkill, manifest)
result.updated++
}

manifest.lastSyncedAt = new Date().toISOString()
await saveManifest(manifest)

return result
}

export async function seedFromRemote(): Promise<boolean> {
const catalog = await fetchRemoteCatalog()
if (!catalog || catalog.skills.length === 0) return false

const manifest = await loadManifest()
let seeded = 0

for (const skill of catalog.skills) {
try {
await writeSkillFile(skill.id, skill.content)
manifest.skills[skill.id] = {
version: skill.version,
contentHash: contentHash(skill.content),
}
seeded++
} catch (err) {
logger.warn('Failed to seed remote skill', {
id: skill.id,
error: err instanceof Error ? err.message : String(err),
})
}
}

if (seeded > 0) {
manifest.lastSyncedAt = new Date().toISOString()
await saveManifest(manifest)
logger.info(`Seeded ${seeded} skills from remote catalog`)
}

return seeded > 0
}

export function startSkillSync(): void {
if (syncTimer) return

syncTimer = setInterval(async () => {
try {
const { installed, updated, skipped } = await syncRemoteSkills()
if (installed > 0 || updated > 0) {
logger.info('Remote skill sync completed', {
installed,
updated,
skipped,
})
}
} catch (err) {
logger.warn('Skill sync failed', {
error: err instanceof Error ? err.message : String(err),
})
}
}, TIMEOUTS.SKILLS_SYNC_INTERVAL)

// Don't block process exit
syncTimer.unref()
}

export function stopSkillSync(): void {
if (syncTimer) {
clearInterval(syncTimer)
syncTimer = null
}
}
27 changes: 23 additions & 4 deletions packages/browseros-agent/apps/server/src/skills/seed.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,11 @@
import { createHash } from 'node:crypto'
import { mkdir, readdir, writeFile } from 'node:fs/promises'
import { join } from 'node:path'
import { getSkillsDir } from '../lib/browseros-dir'
import { logger } from '../lib/logger'
import { DEFAULT_SKILLS } from './defaults'
import { loadManifest, seedFromRemote } from './remote-sync'
import type { SkillManifest } from './types'

async function hasExistingSkills(skillsDir: string): Promise<boolean> {
try {
Expand All @@ -13,16 +16,18 @@ async function hasExistingSkills(skillsDir: string): Promise<boolean> {
}
}

export async function seedDefaultSkills(): Promise<void> {
async function seedFromBundled(manifest: SkillManifest): Promise<void> {
const skillsDir = getSkillsDir()
if (await hasExistingSkills(skillsDir)) return

let seeded = 0
for (const skill of DEFAULT_SKILLS) {
try {
const targetDir = join(skillsDir, skill.id)
await mkdir(targetDir, { recursive: true })
await writeFile(join(targetDir, 'SKILL.md'), skill.content)
manifest.skills[skill.id] = {
version: '1.0',
contentHash: createHash('sha256').update(skill.content).digest('hex'),
}
seeded++
} catch (err) {
logger.warn('Failed to seed skill', {
Expand All @@ -33,6 +38,20 @@ export async function seedDefaultSkills(): Promise<void> {
}

if (seeded > 0) {
logger.info(`Seeded ${seeded} default skills`)
logger.info(`Seeded ${seeded} default skills (bundled)`)
Comment thread
shivammittal274 marked this conversation as resolved.
}
}

export async function seedDefaultSkills(): Promise<void> {
const skillsDir = getSkillsDir()
if (await hasExistingSkills(skillsDir)) return

const remoteSucceeded = await seedFromRemote()
if (remoteSucceeded) return

const manifest = await loadManifest()
await seedFromBundled(manifest)

const manifestPath = join(skillsDir, '.remote-manifest.json')
await writeFile(manifestPath, JSON.stringify(manifest, null, 2))
}
21 changes: 21 additions & 0 deletions packages/browseros-agent/apps/server/src/skills/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -38,3 +38,24 @@ export type CreateSkillInput = {
export type UpdateSkillInput = Partial<CreateSkillInput> & {
enabled?: boolean
}

export type RemoteSkillEntry = {
id: string
version: string
content: string
}

export type RemoteSkillCatalog = {
version: number
skills: RemoteSkillEntry[]
}

export type ManagedSkillRecord = {
version: string
contentHash: string
}

export type SkillManifest = {
lastSyncedAt: string
skills: Record<string, ManagedSkillRecord>
}
Loading
Loading