Skip to content

Commit 09f9a73

Browse files
fix: address Greptile review — entry validation, size limit, DRY, no-op saves
- Validate individual skill entries in catalog (id, version, content must all be strings) not just the top-level shape - Add 1MB response size limit on catalog fetch to prevent resource exhaustion from compromised/misconfigured CDN - Skip manifest save when sync cycle had no changes (avoids unnecessary disk I/O every 45 minutes) - Share extractVersion via remote-sync.ts export, remove duplicate from seed.ts
1 parent bba8726 commit 09f9a73

4 files changed

Lines changed: 63 additions & 9 deletions

File tree

packages/browseros-agent/apps/server/src/skills/remote-sync.ts

Lines changed: 33 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { createHash } from 'node:crypto'
22
import { mkdir, readFile, writeFile } from 'node:fs/promises'
33
import { join } from 'node:path'
4+
import { SKILLS_LIMITS } from '@browseros/shared/constants/limits'
45
import { TIMEOUTS } from '@browseros/shared/constants/timeouts'
56
import { EXTERNAL_URLS } from '@browseros/shared/constants/urls'
67
import { INLINED_ENV } from '../env'
@@ -18,6 +19,11 @@ export const MANIFEST_FILE = '.remote-manifest.json'
1819

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

22+
export function extractVersion(content: string): string {
23+
const match = content.match(/^\s*version:\s*["']?([^"'\n]+)["']?/m)
24+
return match?.[1]?.trim() || '1.0'
25+
}
26+
2127
export function contentHash(content: string): string {
2228
return createHash('sha256').update(content).digest('hex')
2329
}
@@ -32,10 +38,24 @@ function isValidManifest(data: unknown): data is SkillManifest {
3238
return typeof d.lastSyncedAt === 'string' && typeof d.skills === 'object' && d.skills !== null
3339
}
3440

41+
function isValidSkillEntry(entry: unknown): entry is RemoteSkillEntry {
42+
if (typeof entry !== 'object' || entry === null) return false
43+
const e = entry as Record<string, unknown>
44+
return (
45+
typeof e.id === 'string' &&
46+
typeof e.version === 'string' &&
47+
typeof e.content === 'string'
48+
)
49+
}
50+
3551
function isValidCatalog(data: unknown): data is RemoteSkillCatalog {
3652
if (typeof data !== 'object' || data === null) return false
3753
const d = data as Record<string, unknown>
38-
return typeof d.version === 'number' && Array.isArray(d.skills)
54+
return (
55+
typeof d.version === 'number' &&
56+
Array.isArray(d.skills) &&
57+
d.skills.every(isValidSkillEntry)
58+
)
3959
}
4060

4161
export async function loadManifest(): Promise<SkillManifest> {
@@ -71,7 +91,14 @@ export async function fetchRemoteCatalog(): Promise<RemoteSkillCatalog | null> {
7191
})
7292
return null
7393
}
74-
const data: unknown = await response.json()
94+
const text = await response.text()
95+
if (text.length > SKILLS_LIMITS.MAX_CATALOG_BYTES) {
96+
logger.warn('Remote skill catalog response too large', {
97+
size: text.length,
98+
})
99+
return null
100+
}
101+
const data: unknown = JSON.parse(text)
75102
if (!isValidCatalog(data)) {
76103
logger.warn('Remote skill catalog has invalid format')
77104
return null
@@ -171,8 +198,10 @@ export async function syncRemoteSkills(): Promise<{
171198
}
172199
}
173200

174-
manifest.lastSyncedAt = new Date().toISOString()
175-
await saveManifest(manifest)
201+
if (result.installed > 0 || result.updated > 0) {
202+
manifest.lastSyncedAt = new Date().toISOString()
203+
await saveManifest(manifest)
204+
}
176205

177206
return result
178207
}

packages/browseros-agent/apps/server/src/skills/seed.ts

Lines changed: 1 addition & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import { logger } from '../lib/logger'
44
import { DEFAULT_SKILLS } from './defaults'
55
import {
66
contentHash,
7+
extractVersion,
78
installSkill,
89
loadManifest,
910
saveManifest,
@@ -20,11 +21,6 @@ async function hasExistingSkills(skillsDir: string): Promise<boolean> {
2021
}
2122
}
2223

23-
function extractVersion(content: string): string {
24-
const match = content.match(/^\s*version:\s*["']?([^"'\n]+)["']?/m)
25-
return match?.[1]?.trim() || '1.0'
26-
}
27-
2824
export async function seedDefaultSkills(): Promise<void> {
2925
const skillsDir = getSkillsDir()
3026
if (await hasExistingSkills(skillsDir)) return

packages/browseros-agent/apps/server/tests/skills/remote-sync.test.ts

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -139,6 +139,31 @@ describe('fetchRemoteCatalog', () => {
139139
fetchSpy.mockRestore()
140140
})
141141

142+
it('returns null when skill entries have invalid shape', async () => {
143+
const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
144+
new Response(
145+
JSON.stringify({
146+
version: 1,
147+
skills: [{ id: 123, version: '1.0', content: null }],
148+
}),
149+
{ status: 200 },
150+
),
151+
)
152+
const result = await fetchRemoteCatalog()
153+
assert.strictEqual(result, null)
154+
fetchSpy.mockRestore()
155+
})
156+
157+
it('returns null for oversized response', async () => {
158+
const huge = 'x'.repeat(1_100_000)
159+
const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
160+
new Response(huge, { status: 200 }),
161+
)
162+
const result = await fetchRemoteCatalog()
163+
assert.strictEqual(result, null)
164+
fetchSpy.mockRestore()
165+
})
166+
142167
it('returns null when skills field is missing', async () => {
143168
const fetchSpy = spyOn(globalThis, 'fetch').mockResolvedValue(
144169
new Response(JSON.stringify({ version: 1 }), { status: 200 }),

packages/browseros-agent/packages/shared/src/constants/limits.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,10 @@ export const CDP_LIMITS = {
7878
RECONNECT_MAX_RETRIES: 3,
7979
} as const
8080

81+
export const SKILLS_LIMITS = {
82+
MAX_CATALOG_BYTES: 1_000_000,
83+
} as const
84+
8185
export const CONTENT_LIMITS = {
8286
BODY_CONTEXT_SIZE: 10_000,
8387
MAX_QUEUE_SIZE: 1_000,

0 commit comments

Comments
 (0)