-
Notifications
You must be signed in to change notification settings - Fork 1.4k
feat: remote skill download and auto-sync #468
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Merged
Merged
Changes from 13 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 08b07d6
feat: make skills catalog URL configurable and add generation script
shivammittal274 cc069f4
feat: add R2 upload script and use cdn.browseros.com for catalog URL
shivammittal274 df149ab
test: add E2E tests for remote skill sync against live CDN
shivammittal274 ed456ab
fix: address code review findings — security, validation, DRY
shivammittal274 bb7c95d
test: add flow tests for all four sync scenarios against live CDN
shivammittal274 62817ce
refactor: remove redundant scripts and inline catalog generation
shivammittal274 d7f785d
test: add full E2E server flow test against live CDN
shivammittal274 bba8726
chore: remove e2e-server-flow test
shivammittal274 09f9a73
fix: address Greptile review — entry validation, size limit, DRY, no-…
shivammittal274 4792c3a
fix: prevent bundled fallback from overwriting partial remote seeds
shivammittal274 f35a5de
fix: run sync immediately on startup, not just on interval
shivammittal274 25a58ed
refactor: simplify sync — remote always wins, remove manifest
shivammittal274 12a5436
fix: skip bundled skills already installed by partial remote seed
shivammittal274 f3051f7
chore: remove unreliable Content-Length check
shivammittal274 e23e850
chore: remove size limit checks, fetch timeout is sufficient
shivammittal274 File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
188 changes: 188 additions & 0 deletions
188
packages/browseros-agent/apps/server/src/skills/remote-sync.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,188 @@ | ||
| import { mkdir, readFile, writeFile } from 'node:fs/promises' | ||
| import { join } from 'node:path' | ||
| import { SKILLS_LIMITS } from '@browseros/shared/constants/limits' | ||
| 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 { safeSkillDir } from './service' | ||
| import type { RemoteSkillCatalog, RemoteSkillEntry } from './types' | ||
|
|
||
| let syncTimer: ReturnType<typeof setInterval> | null = null | ||
|
|
||
| export function extractVersion(content: string): string { | ||
| const match = content.match(/^\s*version:\s*["']?([^"'\n]+)["']?/m) | ||
| return match?.[1]?.trim() || '1.0' | ||
| } | ||
|
|
||
| function isValidSkillEntry(entry: unknown): entry is RemoteSkillEntry { | ||
| if (typeof entry !== 'object' || entry === null) return false | ||
| const e = entry as Record<string, unknown> | ||
| return ( | ||
| typeof e.id === 'string' && | ||
| typeof e.version === 'string' && | ||
| typeof e.content === 'string' | ||
| ) | ||
| } | ||
|
|
||
| function isValidCatalog(data: unknown): data is RemoteSkillCatalog { | ||
| if (typeof data !== 'object' || data === null) return false | ||
| const d = data as Record<string, unknown> | ||
| return ( | ||
| typeof d.version === 'number' && | ||
| Array.isArray(d.skills) && | ||
| d.skills.every(isValidSkillEntry) | ||
| ) | ||
| } | ||
|
|
||
| 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 | ||
| } | ||
| const contentLength = Number(response.headers.get('content-length') ?? 0) | ||
| if (contentLength > SKILLS_LIMITS.MAX_CATALOG_BYTES) { | ||
| logger.warn('Remote skill catalog Content-Length too large', { | ||
| contentLength, | ||
| }) | ||
| return null | ||
| } | ||
| const text = await response.text() | ||
| if (text.length > SKILLS_LIMITS.MAX_CATALOG_BYTES) { | ||
| logger.warn('Remote skill catalog response too large', { | ||
| size: text.length, | ||
| }) | ||
| return null | ||
| } | ||
| const data: unknown = JSON.parse(text) | ||
| if (!isValidCatalog(data)) { | ||
| logger.warn('Remote skill catalog has invalid format') | ||
| return null | ||
| } | ||
| return data | ||
| } catch (err) { | ||
| logger.debug('Remote skill catalog unavailable', { | ||
| error: err instanceof Error ? err.message : String(err), | ||
| }) | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| async function getLocalVersion(skillId: string): Promise<string | null> { | ||
| try { | ||
| const safeDir = safeSkillDir(skillId) | ||
| const content = await readFile(join(safeDir, 'SKILL.md'), 'utf-8') | ||
| return extractVersion(content) | ||
| } catch { | ||
| return null | ||
| } | ||
| } | ||
|
|
||
| export async function writeSkillFile( | ||
| skillId: string, | ||
| content: string, | ||
| ): Promise<void> { | ||
| const safeDir = safeSkillDir(skillId) | ||
| await mkdir(safeDir, { recursive: true }) | ||
| await writeFile(join(safeDir, 'SKILL.md'), content) | ||
| } | ||
|
|
||
| export async function syncRemoteSkills(): Promise<{ | ||
| installed: number | ||
| updated: number | ||
| }> { | ||
| const result = { installed: 0, updated: 0 } | ||
| const catalog = await fetchRemoteCatalog() | ||
| if (!catalog) return result | ||
|
|
||
| for (const remoteSkill of catalog.skills) { | ||
| try { | ||
| const localVersion = await getLocalVersion(remoteSkill.id) | ||
|
|
||
| if (!localVersion) { | ||
| await writeSkillFile(remoteSkill.id, remoteSkill.content) | ||
| result.installed++ | ||
| continue | ||
| } | ||
|
|
||
| if (localVersion === remoteSkill.version) { | ||
| continue | ||
| } | ||
|
|
||
| await writeSkillFile(remoteSkill.id, remoteSkill.content) | ||
| result.updated++ | ||
| } catch (err) { | ||
| logger.warn('Failed to sync skill', { | ||
| id: remoteSkill.id, | ||
| error: err instanceof Error ? err.message : String(err), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| return result | ||
| } | ||
|
|
||
| export async function seedFromRemote(): Promise<boolean> { | ||
| const catalog = await fetchRemoteCatalog() | ||
| if (!catalog || catalog.skills.length === 0) return false | ||
|
|
||
| let seeded = 0 | ||
|
|
||
| for (const skill of catalog.skills) { | ||
| try { | ||
| await writeSkillFile(skill.id, 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) { | ||
| logger.info(`Seeded ${seeded}/${catalog.skills.length} skills from remote catalog`) | ||
| } | ||
|
|
||
| return seeded === catalog.skills.length | ||
| } | ||
|
|
||
| async function runSync(): Promise<void> { | ||
| try { | ||
| const { installed, updated } = await syncRemoteSkills() | ||
| if (installed > 0 || updated > 0) { | ||
| logger.info('Remote skill sync completed', { installed, updated }) | ||
| } | ||
| } catch (err) { | ||
| logger.warn('Skill sync failed', { | ||
| error: err instanceof Error ? err.message : String(err), | ||
| }) | ||
| } | ||
| } | ||
|
|
||
| export function startSkillSync(): void { | ||
| if (syncTimer) return | ||
|
|
||
| runSync() | ||
|
|
||
| syncTimer = setInterval(runSync, TIMEOUTS.SKILLS_SYNC_INTERVAL) | ||
| syncTimer.unref() | ||
| } | ||
|
|
||
| export function stopSkillSync(): void { | ||
| if (syncTimer) { | ||
| clearInterval(syncTimer) | ||
| syncTimer = null | ||
| } | ||
| } | ||
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
90 changes: 90 additions & 0 deletions
90
packages/browseros-agent/apps/server/tests/skills/flows.test.ts
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,90 @@ | ||
| /** | ||
| * E2E flow tests against live CDN. | ||
| */ | ||
|
|
||
| import { afterAll, beforeAll, describe, it, mock } from 'bun:test' | ||
| import assert from 'node:assert' | ||
| import { mkdir, readdir, readFile, rm, writeFile } from 'node:fs/promises' | ||
| import { tmpdir } from 'node:os' | ||
| import { join } from 'node:path' | ||
|
|
||
| let testDir: string | ||
|
|
||
| mock.module('../../src/lib/browseros-dir', () => ({ | ||
| getSkillsDir: () => testDir, | ||
| })) | ||
|
|
||
| mock.module('../../src/env', () => ({ | ||
| INLINED_ENV: { | ||
| SKILLS_CATALOG_URL: 'https://cdn.browseros.com/skills/v1/catalog.json', | ||
| }, | ||
| })) | ||
|
|
||
| const { seedFromRemote, syncRemoteSkills } = | ||
| await import('../../src/skills/remote-sync') | ||
|
|
||
| async function listSkills(): Promise<string[]> { | ||
| const entries = await readdir(testDir) | ||
| return entries.filter((e) => !e.startsWith('.')).sort() | ||
| } | ||
|
|
||
| beforeAll(async () => { | ||
| testDir = join(tmpdir(), `flow-test-${Date.now()}`) | ||
| await mkdir(testDir, { recursive: true }) | ||
| }) | ||
|
|
||
| afterAll(async () => { | ||
| await rm(testDir, { recursive: true, force: true }) | ||
| }) | ||
|
|
||
| describe('Flow tests against live CDN', () => { | ||
| it('seeds all skills from CDN on fresh install', async () => { | ||
| const result = await seedFromRemote() | ||
| assert.strictEqual(result, true) | ||
| const skills = await listSkills() | ||
| assert.strictEqual(skills.length, 12) | ||
| }) | ||
|
|
||
| it('sync does nothing when already up to date', async () => { | ||
| const result = await syncRemoteSkills() | ||
| assert.strictEqual(result.installed, 0) | ||
| assert.strictEqual(result.updated, 0) | ||
| }) | ||
|
|
||
| it('remote overwrites local edits when version differs', async () => { | ||
| const skillPath = join(testDir, 'summarize-page', 'SKILL.md') | ||
| const original = await readFile(skillPath, 'utf-8') | ||
|
|
||
| // User edits the file AND we fake a version mismatch | ||
| const edited = original.replace(/version: "1.0"/, 'version: "0.9"') + '\n## My Notes\n' | ||
| await writeFile(skillPath, edited) | ||
|
|
||
| const result = await syncRemoteSkills() | ||
| assert.strictEqual(result.updated >= 1, true) | ||
|
|
||
| const afterSync = await readFile(skillPath, 'utf-8') | ||
| assert.ok(!afterSync.includes('My Notes')) | ||
| }) | ||
|
|
||
| it('installs skill deleted locally', async () => { | ||
| await rm(join(testDir, 'save-page'), { recursive: true }) | ||
|
|
||
| const result = await syncRemoteSkills() | ||
| assert.strictEqual(result.installed, 1) | ||
|
|
||
| const content = await readFile(join(testDir, 'save-page', 'SKILL.md'), 'utf-8') | ||
| assert.ok(content.includes('name: save-page')) | ||
| }) | ||
|
|
||
| it('user-created skill is never touched', async () => { | ||
| const customDir = join(testDir, 'my-workflow') | ||
| await mkdir(customDir, { recursive: true }) | ||
| const custom = '---\nname: my-workflow\ndescription: custom\n---\n# Mine\n' | ||
| await writeFile(join(customDir, 'SKILL.md'), custom) | ||
|
|
||
| await syncRemoteSkills() | ||
|
|
||
| const afterSync = await readFile(join(customDir, 'SKILL.md'), 'utf-8') | ||
| assert.strictEqual(afterSync, custom) | ||
| }) | ||
| }) |
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
Uh oh!
There was an error while loading. Please reload this page.