@ouraihub/aether-core 0.1.0 → 0.1.2
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +2 -2
- package/src/engine/index.ts +95 -129
- package/src/engine/machine.ts +322 -0
- package/src/engine/model.ts +28 -16
- package/src/engine/prepare.ts +120 -34
- package/src/engine/tools.ts +59 -142
- package/src/error.ts +7 -2
- package/src/guard.ts +3 -1
- package/src/history.ts +9 -2
- package/src/images/index.ts +212 -39
- package/src/images/validation.ts +276 -0
- package/src/mcp/index.ts +83 -245
- package/src/mcp/protocol.ts +207 -0
- package/src/mcp/types.ts +49 -0
- package/src/pricing.ts +10 -2
- package/src/protocol/decoder.ts +39 -14
- package/src/protocol/request.ts +52 -17
- package/src/providers/connection.ts +16 -5
- package/src/providers/infer.ts +11 -6
- package/src/providers/probe.ts +20 -6
- package/src/risk.ts +13 -3
- package/src/schema.ts +34 -8
- package/src/search/execution.ts +21 -6
- package/src/search/index.ts +11 -2
- package/src/search/policy.ts +43 -10
- package/src/search/vendor.ts +42 -9
- package/src/skills/content.ts +13 -3
- package/src/skills/index.ts +6 -1
- package/src/skills/package.ts +29 -7
- package/src/skills/service.ts +54 -13
- package/src/skills/validation.ts +38 -9
package/src/search/policy.ts
CHANGED
|
@@ -15,7 +15,10 @@ export const PROVIDERS: readonly ProviderSpec[] = [
|
|
|
15
15
|
name: '智谱 Zhipu',
|
|
16
16
|
description: 'Web search',
|
|
17
17
|
basicAuth: false,
|
|
18
|
-
search: {
|
|
18
|
+
search: {
|
|
19
|
+
defaultBaseUrl: 'https://open.bigmodel.cn/api/paas/v4/web_search',
|
|
20
|
+
keyMode: 'required',
|
|
21
|
+
},
|
|
19
22
|
},
|
|
20
23
|
{
|
|
21
24
|
id: 'tavily',
|
|
@@ -98,7 +101,10 @@ export const PROVIDERS: readonly ProviderSpec[] = [
|
|
|
98
101
|
fetch: { defaultBaseUrl: '', keyMode: 'none' },
|
|
99
102
|
},
|
|
100
103
|
]
|
|
101
|
-
export const DEFAULT_PROVIDERS: Readonly<Record<SearchCap, string>> = {
|
|
104
|
+
export const DEFAULT_PROVIDERS: Readonly<Record<SearchCap, string>> = {
|
|
105
|
+
search: 'exa-mcp',
|
|
106
|
+
fetch: 'jina',
|
|
107
|
+
}
|
|
102
108
|
export const defaultPreferences = (): SearchPreferences => ({
|
|
103
109
|
maxResults: 5,
|
|
104
110
|
compression: 'none',
|
|
@@ -141,7 +147,12 @@ export function targetUrl(value: string): URL {
|
|
|
141
147
|
return url
|
|
142
148
|
}
|
|
143
149
|
export function normalizeRule(value: unknown): string {
|
|
144
|
-
if (
|
|
150
|
+
if (
|
|
151
|
+
typeof value !== 'string' ||
|
|
152
|
+
!value ||
|
|
153
|
+
[...value].length > 256 ||
|
|
154
|
+
/[\s[\]\\^{}|#]/u.test(value)
|
|
155
|
+
)
|
|
145
156
|
throw badRequest('blacklist', 'invalid')
|
|
146
157
|
for (const character of value) {
|
|
147
158
|
const code = character.codePointAt(0)
|
|
@@ -150,25 +161,39 @@ export function normalizeRule(value: unknown): string {
|
|
|
150
161
|
const parts = value.match(/^(https?):\/\/([^/]+)(\/.*)?$/i)
|
|
151
162
|
const hostPort = parts ? (parts[2] ?? '') : value
|
|
152
163
|
const match = hostPort.match(/^([^:]+)(?::([0-9]+))?$/)
|
|
153
|
-
if (
|
|
164
|
+
if (
|
|
165
|
+
!match ||
|
|
166
|
+
(!parts && match[2]) ||
|
|
167
|
+
(match[2] && (Number(match[2]) < 1 || Number(match[2]) > 65535))
|
|
168
|
+
)
|
|
154
169
|
throw badRequest('blacklist', 'invalid')
|
|
155
170
|
const host = (match[1] ?? '').toLowerCase().replace(/\.$/, '')
|
|
156
171
|
if (
|
|
157
172
|
host.length > 253 ||
|
|
158
173
|
host
|
|
159
174
|
.split('.')
|
|
160
|
-
.some(
|
|
175
|
+
.some(
|
|
176
|
+
(label) =>
|
|
177
|
+
!label || label.length > 63 || !/^[a-z0-9*?](?:[a-z0-9*?-]*[a-z0-9*?])?$/u.test(label),
|
|
178
|
+
)
|
|
161
179
|
)
|
|
162
180
|
throw badRequest('blacklist', 'invalid')
|
|
163
181
|
const scheme = parts?.[1]?.toLowerCase()
|
|
164
182
|
const port =
|
|
165
|
-
match[2] &&
|
|
183
|
+
match[2] &&
|
|
184
|
+
!((scheme === 'https' && match[2] === '443') || (scheme === 'http' && match[2] === '80'))
|
|
166
185
|
? ':' + match[2]
|
|
167
186
|
: ''
|
|
168
187
|
return parts ? scheme + '://' + host + port + (parts[3] ?? '/') : host
|
|
169
188
|
}
|
|
170
189
|
export function validatePreferences(value: unknown): SearchPreferences {
|
|
171
|
-
const input = fields(value, [
|
|
190
|
+
const input = fields(value, [
|
|
191
|
+
'maxResults',
|
|
192
|
+
'compression',
|
|
193
|
+
'cutoffLimit',
|
|
194
|
+
'blacklist',
|
|
195
|
+
'modelToolsPreferred',
|
|
196
|
+
])
|
|
172
197
|
const { maxResults, compression, cutoffLimit, blacklist, modelToolsPreferred } = input
|
|
173
198
|
if (
|
|
174
199
|
typeof maxResults !== 'number' ||
|
|
@@ -238,7 +263,10 @@ export function truncate(text: string, limit: number): string {
|
|
|
238
263
|
export function cutoff(text: string, preferences: SearchPreferences): string {
|
|
239
264
|
return preferences.compression === 'cutoff' ? truncate(text, preferences.cutoffLimit) : text
|
|
240
265
|
}
|
|
241
|
-
export function processHits(
|
|
266
|
+
export function processHits(
|
|
267
|
+
hits: readonly SearchHit[],
|
|
268
|
+
preferences: SearchPreferences,
|
|
269
|
+
): SearchHit[] {
|
|
242
270
|
const seen = new Set<string>()
|
|
243
271
|
const result: SearchHit[] = []
|
|
244
272
|
for (const hit of hits) {
|
|
@@ -251,7 +279,11 @@ export function processHits(hits: readonly SearchHit[], preferences: SearchPrefe
|
|
|
251
279
|
}
|
|
252
280
|
if (seen.has(url)) continue
|
|
253
281
|
seen.add(url)
|
|
254
|
-
result.push({
|
|
282
|
+
result.push({
|
|
283
|
+
title: truncate(hit.title || url, 500),
|
|
284
|
+
url,
|
|
285
|
+
snippet: cutoff(hit.snippet, preferences),
|
|
286
|
+
})
|
|
255
287
|
if (result.length >= preferences.maxResults) break
|
|
256
288
|
}
|
|
257
289
|
return result
|
|
@@ -262,6 +294,7 @@ export function validateQuery(value: string): string {
|
|
|
262
294
|
return query
|
|
263
295
|
}
|
|
264
296
|
export function validateTarget(value: string): string {
|
|
265
|
-
if (new TextEncoder().encode(stringValue(value, 'url')).length > 8192)
|
|
297
|
+
if (new TextEncoder().encode(stringValue(value, 'url')).length > 8192)
|
|
298
|
+
throw badRequest('url', 'too_long', 8192)
|
|
266
299
|
return targetUrl(value.trim()).href
|
|
267
300
|
}
|
package/src/search/vendor.ts
CHANGED
|
@@ -53,7 +53,16 @@ function collect(items: readonly unknown[]): SearchHit[] {
|
|
|
53
53
|
hits.push({
|
|
54
54
|
title: text(item, 'title', 'name') || url,
|
|
55
55
|
url,
|
|
56
|
-
snippet: text(
|
|
56
|
+
snippet: text(
|
|
57
|
+
item,
|
|
58
|
+
'content',
|
|
59
|
+
'text',
|
|
60
|
+
'snippet',
|
|
61
|
+
'description',
|
|
62
|
+
'excerpts',
|
|
63
|
+
'summary',
|
|
64
|
+
'markdown',
|
|
65
|
+
),
|
|
57
66
|
})
|
|
58
67
|
}
|
|
59
68
|
return hits
|
|
@@ -116,7 +125,10 @@ export function parseMcp(body: string): SearchHit[] {
|
|
|
116
125
|
})),
|
|
117
126
|
)
|
|
118
127
|
}
|
|
119
|
-
function headers(
|
|
128
|
+
function headers(
|
|
129
|
+
key: string | undefined,
|
|
130
|
+
kind: 'bearer' | 'api' = 'bearer',
|
|
131
|
+
): Record<string, string> {
|
|
120
132
|
return {
|
|
121
133
|
accept: 'application/json',
|
|
122
134
|
'content-type': 'application/json',
|
|
@@ -155,7 +167,9 @@ export function searchRequest(
|
|
|
155
167
|
init = {
|
|
156
168
|
headers: {
|
|
157
169
|
accept: 'application/json',
|
|
158
|
-
...(config.basicAuthUsername
|
|
170
|
+
...(config.basicAuthUsername
|
|
171
|
+
? { authorization: basic(config.basicAuthUsername, key ?? '') }
|
|
172
|
+
: {}),
|
|
159
173
|
},
|
|
160
174
|
}
|
|
161
175
|
break
|
|
@@ -194,7 +208,10 @@ export function searchRequest(
|
|
|
194
208
|
break
|
|
195
209
|
case 'parallel':
|
|
196
210
|
url += '/v1/search'
|
|
197
|
-
init = post(
|
|
211
|
+
init = post(
|
|
212
|
+
{ objective: query, search_queries: [query], advanced_settings: { max_results: count } },
|
|
213
|
+
'api',
|
|
214
|
+
)
|
|
198
215
|
break
|
|
199
216
|
case 'jina':
|
|
200
217
|
url += '/' + encodeURIComponent(query)
|
|
@@ -205,7 +222,10 @@ export function searchRequest(
|
|
|
205
222
|
init = post({ query, limit: count, scrapeOptions: { formats: ['markdown'] } })
|
|
206
223
|
break
|
|
207
224
|
default:
|
|
208
|
-
throw new KitFailure('bad_request', 'Unsupported search provider', {
|
|
225
|
+
throw new KitFailure('bad_request', 'Unsupported search provider', {
|
|
226
|
+
field: 'provider',
|
|
227
|
+
reason: 'unsupported',
|
|
228
|
+
})
|
|
209
229
|
}
|
|
210
230
|
return { url, init }
|
|
211
231
|
}
|
|
@@ -226,16 +246,26 @@ export function fetchRequest(
|
|
|
226
246
|
},
|
|
227
247
|
}
|
|
228
248
|
case 'jina':
|
|
229
|
-
return {
|
|
249
|
+
return {
|
|
250
|
+
url: base + '/' + target,
|
|
251
|
+
init: { headers: { ...headers(key), 'x-retain-images': 'none' } },
|
|
252
|
+
}
|
|
230
253
|
case 'firecrawl':
|
|
231
254
|
return {
|
|
232
255
|
url: base + '/v2/scrape',
|
|
233
|
-
init: {
|
|
256
|
+
init: {
|
|
257
|
+
method: 'POST',
|
|
258
|
+
headers: headers(key),
|
|
259
|
+
body: JSON.stringify({ url: target, formats: ['markdown'] }),
|
|
260
|
+
},
|
|
234
261
|
}
|
|
235
262
|
case 'fetch':
|
|
236
263
|
return { url: target, init: { method: 'GET', headers: { accept: 'text/html, text/plain' } } }
|
|
237
264
|
default:
|
|
238
|
-
throw new KitFailure('bad_request', 'Unsupported fetch provider', {
|
|
265
|
+
throw new KitFailure('bad_request', 'Unsupported fetch provider', {
|
|
266
|
+
field: 'provider',
|
|
267
|
+
reason: 'unsupported',
|
|
268
|
+
})
|
|
239
269
|
}
|
|
240
270
|
}
|
|
241
271
|
export function parsePage(provider: string, body: string, target: string): SearchPage {
|
|
@@ -246,7 +276,10 @@ export function parsePage(provider: string, body: string, target: string): Searc
|
|
|
246
276
|
?.replace(/<[^>]*>/gu, ' ')
|
|
247
277
|
.replace(/\s+/gu, ' ')
|
|
248
278
|
.trim() || target
|
|
249
|
-
const visible = body.replace(
|
|
279
|
+
const visible = body.replace(
|
|
280
|
+
/<!--[\s\S]*?-->|<(script|style|noscript|head|title)\b[^>]*>[\s\S]*?<\/\1\s*>/giu,
|
|
281
|
+
' ',
|
|
282
|
+
)
|
|
250
283
|
const documentBody = visible.match(/<body\b[^>]*>([\s\S]*?)(?:<\/body\s*>|$)/iu)?.[1] ?? visible
|
|
251
284
|
const content = documentBody
|
|
252
285
|
.replace(/<[^>]*>/gu, ' ')
|
package/src/skills/content.ts
CHANGED
|
@@ -14,15 +14,25 @@ export interface FileIdentity {
|
|
|
14
14
|
/** docs14 §3.1: validated text and unique files in ascending path order are prerequisites.
|
|
15
15
|
* No trimming, newline normalization, sorting or case conversion is performed here.
|
|
16
16
|
*/
|
|
17
|
-
export async function digest(
|
|
17
|
+
export async function digest(
|
|
18
|
+
content: ContentIdentity,
|
|
19
|
+
files: readonly FileIdentity[] = [],
|
|
20
|
+
): Promise<string> {
|
|
18
21
|
const encoder = new TextEncoder()
|
|
19
|
-
const fields: (string | null)[] = [
|
|
22
|
+
const fields: (string | null)[] = [
|
|
23
|
+
content.name,
|
|
24
|
+
content.description,
|
|
25
|
+
content.version,
|
|
26
|
+
content.body,
|
|
27
|
+
]
|
|
20
28
|
if (content.license !== null || files.length !== 0) {
|
|
21
29
|
fields.push('package', content.license)
|
|
22
30
|
for (const file of files) fields.push(file.path, file.sha256)
|
|
23
31
|
}
|
|
24
32
|
const encoded = fields.map((field) => (field === null ? null : encoder.encode(field)))
|
|
25
|
-
const bytes = new Uint8Array(
|
|
33
|
+
const bytes = new Uint8Array(
|
|
34
|
+
encoded.reduce((size, field) => size + (field === null ? 1 : 9 + field.length), 0),
|
|
35
|
+
)
|
|
26
36
|
const view = new DataView(bytes.buffer)
|
|
27
37
|
let offset = 0
|
|
28
38
|
for (const field of encoded) {
|
package/src/skills/index.ts
CHANGED
|
@@ -1,6 +1,11 @@
|
|
|
1
1
|
export { digest, sha256 } from './content.js'
|
|
2
2
|
export type { ContentIdentity, FileIdentity } from './content.js'
|
|
3
|
-
export {
|
|
3
|
+
export {
|
|
4
|
+
parseManualSave,
|
|
5
|
+
parseSkillMarkdown,
|
|
6
|
+
parseSkillSource,
|
|
7
|
+
validateSkillContent,
|
|
8
|
+
} from './validation.js'
|
|
4
9
|
export { readSkillSource } from './package.js'
|
|
5
10
|
export type { PackageFile, SkillPackage } from './package.js'
|
|
6
11
|
export type { BuiltinSkill, SkillSnapshot, SkillRecord, SkillCatalog } from './service.js'
|
package/src/skills/package.ts
CHANGED
|
@@ -1,7 +1,12 @@
|
|
|
1
1
|
import type { SkillFileKind } from '@ouraihub/aether-contracts'
|
|
2
2
|
import { badRequest, KitFailure } from '../error.js'
|
|
3
3
|
import { sha256 } from './content.js'
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
checkSecrets,
|
|
6
|
+
parseSkillMarkdown,
|
|
7
|
+
parseSkillSource,
|
|
8
|
+
type ParsedSkill,
|
|
9
|
+
} from './validation.js'
|
|
5
10
|
|
|
6
11
|
export interface PackageFile {
|
|
7
12
|
readonly path: string
|
|
@@ -40,7 +45,13 @@ function normalize(path: string): string {
|
|
|
40
45
|
}
|
|
41
46
|
function kind(path: string): SkillFileKind | null {
|
|
42
47
|
const top = path.split('/')[0]
|
|
43
|
-
return top === 'scripts'
|
|
48
|
+
return top === 'scripts'
|
|
49
|
+
? 'script'
|
|
50
|
+
: top === 'references'
|
|
51
|
+
? 'reference'
|
|
52
|
+
: top === 'assets'
|
|
53
|
+
? 'asset'
|
|
54
|
+
: null
|
|
44
55
|
}
|
|
45
56
|
function checkPath(path: string): void {
|
|
46
57
|
if (!path.split('/').every((part) => /^[A-Za-z0-9_-][A-Za-z0-9._-]*$/u.test(part)))
|
|
@@ -82,7 +93,8 @@ function entries(bytes: Uint8Array): Entry[] {
|
|
|
82
93
|
const result: Entry[] = []
|
|
83
94
|
const paths = new Set<string>()
|
|
84
95
|
for (let index = 0; index < count; index++) {
|
|
85
|
-
if (offset + 46 > centralEnd || view.getUint32(offset, true) !== 0x02014b50)
|
|
96
|
+
if (offset + 46 > centralEnd || view.getUint32(offset, true) !== 0x02014b50)
|
|
97
|
+
throw badRequest('zip', 'invalid')
|
|
86
98
|
const flags = view.getUint16(offset + 8, true)
|
|
87
99
|
if ((flags & 1) !== 0 || (flags & 64) !== 0) throw badRequest('zip', 'encrypted')
|
|
88
100
|
const mode = view.getUint32(offset + 38, true) >>> 16
|
|
@@ -91,7 +103,8 @@ function entries(bytes: Uint8Array): Entry[] {
|
|
|
91
103
|
const extraLength = view.getUint16(offset + 30, true)
|
|
92
104
|
const commentLength = view.getUint16(offset + 32, true)
|
|
93
105
|
const next = offset + 46 + nameLength + extraLength + commentLength
|
|
94
|
-
if (next > centralEnd || view.getUint16(offset + 34, true) !== 0)
|
|
106
|
+
if (next > centralEnd || view.getUint16(offset + 34, true) !== 0)
|
|
107
|
+
throw badRequest('zip', 'invalid')
|
|
95
108
|
const rawPath = decode(bytes.subarray(offset + 46, offset + 46 + nameLength), 'zip')
|
|
96
109
|
const path = normalize(rawPath)
|
|
97
110
|
if (paths.has(path)) throw badRequest('zip', 'duplicate_path')
|
|
@@ -182,7 +195,10 @@ export async function readSkillSource(value: unknown): Promise<SkillPackage> {
|
|
|
182
195
|
const fileEntries = allEntries.filter((entry) => !entry.directory)
|
|
183
196
|
const first = fileEntries[0]?.path
|
|
184
197
|
const prefix = first?.includes('/') ? first.slice(0, first.indexOf('/') + 1) : null
|
|
185
|
-
const strip =
|
|
198
|
+
const strip =
|
|
199
|
+
prefix !== null && fileEntries.every((entry) => entry.path.startsWith(prefix))
|
|
200
|
+
? prefix.length
|
|
201
|
+
: 0
|
|
186
202
|
const files: PackageFile[] = []
|
|
187
203
|
const unsupported: string[] = []
|
|
188
204
|
let raw: string | undefined
|
|
@@ -207,7 +223,8 @@ export async function readSkillSource(value: unknown): Promise<SkillPackage> {
|
|
|
207
223
|
const limit = Math.min(ownLimit, TOTAL - total)
|
|
208
224
|
const field = limit < ownLimit ? 'total' : path
|
|
209
225
|
const content = await inflate(bytes, entry, limit, field)
|
|
210
|
-
if (content.length !== entry.size || crc32(content) !== entry.crc)
|
|
226
|
+
if (content.length !== entry.size || crc32(content) !== entry.crc)
|
|
227
|
+
throw badRequest('zip', 'invalid')
|
|
211
228
|
total += content.length
|
|
212
229
|
let value: string | undefined
|
|
213
230
|
if (fileKind === 'asset') {
|
|
@@ -223,7 +240,12 @@ export async function readSkillSource(value: unknown): Promise<SkillPackage> {
|
|
|
223
240
|
if (license !== null) throw badRequest(path, 'duplicate')
|
|
224
241
|
license = value ?? null
|
|
225
242
|
} else if (fileKind !== null)
|
|
226
|
-
files.push({
|
|
243
|
+
files.push({
|
|
244
|
+
path,
|
|
245
|
+
kind: fileKind,
|
|
246
|
+
bytes: Array.from(content),
|
|
247
|
+
sha256: await sha256(content),
|
|
248
|
+
})
|
|
227
249
|
}
|
|
228
250
|
if (unsupported.length) throw badRequest(unsupported.sort().join(', '), 'unsupported_files')
|
|
229
251
|
if (raw === undefined) throw badRequest('SKILL.md', 'missing')
|
package/src/skills/service.ts
CHANGED
|
@@ -33,8 +33,12 @@ export interface SkillRecord extends SkillPackage {
|
|
|
33
33
|
}
|
|
34
34
|
export type SkillCatalog = readonly SkillRecord[]
|
|
35
35
|
export const conflict = (reason: string, entity?: string): KitFailure =>
|
|
36
|
-
new KitFailure('conflict', 'Skill conflict', {
|
|
37
|
-
|
|
36
|
+
new KitFailure('conflict', 'Skill conflict', {
|
|
37
|
+
reason,
|
|
38
|
+
...(entity === undefined ? {} : { entity }),
|
|
39
|
+
})
|
|
40
|
+
const missing = (entity: string): KitFailure =>
|
|
41
|
+
new KitFailure('not_found', 'Skill not found', { entity })
|
|
38
42
|
export function fileMetas(skill: SkillPackage): SkillFileMeta[] {
|
|
39
43
|
return skill.files.map((file) => ({ path: file.path, kind: file.kind, size: file.bytes.length }))
|
|
40
44
|
}
|
|
@@ -79,7 +83,12 @@ function pinned(catalog: SkillCatalog, id: string, expected: number): SkillRecor
|
|
|
79
83
|
export function files(catalog: SkillCatalog, id: string, expected: number): SkillFileMeta[] {
|
|
80
84
|
return fileMetas(pinned(catalog, id, expected))
|
|
81
85
|
}
|
|
82
|
-
export function file(
|
|
86
|
+
export function file(
|
|
87
|
+
catalog: SkillCatalog,
|
|
88
|
+
id: string,
|
|
89
|
+
expected: number,
|
|
90
|
+
path: string,
|
|
91
|
+
): Uint8Array<ArrayBuffer> {
|
|
83
92
|
const found = pinned(catalog, id, expected).files.find((entry) => entry.path === path)
|
|
84
93
|
if (!found) throw missing('skill_file')
|
|
85
94
|
return new Uint8Array(found.bytes)
|
|
@@ -97,8 +106,20 @@ function ensureFree(catalog: SkillCatalog, name: string, except?: string): void
|
|
|
97
106
|
function mutable(skill: SkillRecord): void {
|
|
98
107
|
if (skill.origin === 'builtin') throw badRequest('id', 'builtin')
|
|
99
108
|
}
|
|
100
|
-
async function create(
|
|
101
|
-
|
|
109
|
+
async function create(
|
|
110
|
+
content: SkillPackage,
|
|
111
|
+
id: string,
|
|
112
|
+
origin: SkillOrigin,
|
|
113
|
+
now: number,
|
|
114
|
+
): Promise<SkillRecord> {
|
|
115
|
+
return {
|
|
116
|
+
...content,
|
|
117
|
+
id,
|
|
118
|
+
origin,
|
|
119
|
+
revision: 1,
|
|
120
|
+
digest: await digest(content, content.files),
|
|
121
|
+
updatedAt: now,
|
|
122
|
+
}
|
|
102
123
|
}
|
|
103
124
|
async function update(
|
|
104
125
|
previous: SkillRecord,
|
|
@@ -157,12 +178,20 @@ export async function save(
|
|
|
157
178
|
newId: string,
|
|
158
179
|
now: number,
|
|
159
180
|
): Promise<SkillCatalog> {
|
|
160
|
-
const fields = {
|
|
181
|
+
const fields = {
|
|
182
|
+
name: input.name,
|
|
183
|
+
description: input.description,
|
|
184
|
+
version: input.version ?? null,
|
|
185
|
+
body: input.body,
|
|
186
|
+
}
|
|
161
187
|
validateSkillContent(fields)
|
|
162
188
|
if (input.id === undefined) {
|
|
163
189
|
ensureFree(catalog, input.name)
|
|
164
190
|
ensureId(catalog, newId)
|
|
165
|
-
return [
|
|
191
|
+
return [
|
|
192
|
+
...catalog,
|
|
193
|
+
await create({ ...fields, license: null, extra: {}, files: [] }, newId, 'manual', now),
|
|
194
|
+
]
|
|
166
195
|
}
|
|
167
196
|
if (input.expectedRevision === undefined) throw badRequest('expectedRevision', 'missing')
|
|
168
197
|
const current = record(catalog, input.id)
|
|
@@ -231,7 +260,10 @@ export async function syncBuiltins(
|
|
|
231
260
|
result = [...result, await create(content, id, 'builtin', now)]
|
|
232
261
|
}
|
|
233
262
|
}
|
|
234
|
-
return result.length === catalog.length &&
|
|
263
|
+
return result.length === catalog.length &&
|
|
264
|
+
result.every((skill, index) => skill === catalog[index])
|
|
265
|
+
? catalog
|
|
266
|
+
: result
|
|
235
267
|
}
|
|
236
268
|
|
|
237
269
|
/** Strictly narrows persisted documents before exposing their contents to services. */
|
|
@@ -260,16 +292,20 @@ export function parseCatalog(value: unknown): SkillCatalog {
|
|
|
260
292
|
'skill',
|
|
261
293
|
)
|
|
262
294
|
const origin = entry.origin
|
|
263
|
-
if (origin !== 'builtin' && origin !== 'manual' && origin !== 'imported')
|
|
295
|
+
if (origin !== 'builtin' && origin !== 'manual' && origin !== 'imported')
|
|
296
|
+
throw badRequest('origin', 'invalid')
|
|
264
297
|
const extras = object(entry.extra, 'extra')
|
|
265
|
-
const extra = Object.fromEntries(
|
|
298
|
+
const extra = Object.fromEntries(
|
|
299
|
+
Object.entries(extras).map(([key, value]) => [key, text(value, 'extra')]),
|
|
300
|
+
)
|
|
266
301
|
const fileValues: unknown = entry.files
|
|
267
302
|
if (!Array.isArray(fileValues)) throw badRequest('files', 'invalid')
|
|
268
303
|
const parsedFiles: PackageFile[] = fileValues.map((rawFile: unknown) => {
|
|
269
304
|
const file = object(rawFile, 'file')
|
|
270
305
|
exact(file, ['path', 'kind', 'sha256', 'bytes'], 'file')
|
|
271
306
|
const kind = file.kind
|
|
272
|
-
if (kind !== 'script' && kind !== 'reference' && kind !== 'asset')
|
|
307
|
+
if (kind !== 'script' && kind !== 'reference' && kind !== 'asset')
|
|
308
|
+
throw badRequest('kind', 'invalid')
|
|
273
309
|
const bytes: unknown = file.bytes
|
|
274
310
|
if (!Array.isArray(bytes)) throw badRequest('bytes', 'invalid')
|
|
275
311
|
const content = bytes.map((byte: unknown) => {
|
|
@@ -281,10 +317,15 @@ export function parseCatalog(value: unknown): SkillCatalog {
|
|
|
281
317
|
if (!/^[0-9a-f]{64}$/u.test(hash)) throw badRequest('sha256', 'invalid')
|
|
282
318
|
return { path: text(file.path, 'path'), kind, sha256: hash, bytes: content }
|
|
283
319
|
})
|
|
284
|
-
if (
|
|
320
|
+
if (
|
|
321
|
+
parsedFiles.some(
|
|
322
|
+
(file, index) => index > 0 && file.path <= (parsedFiles[index - 1]?.path ?? ''),
|
|
323
|
+
)
|
|
324
|
+
)
|
|
285
325
|
throw badRequest('files', 'invalid')
|
|
286
326
|
const updatedAt = entry.updatedAt
|
|
287
|
-
if (typeof updatedAt !== 'number' || !Number.isSafeInteger(updatedAt))
|
|
327
|
+
if (typeof updatedAt !== 'number' || !Number.isSafeInteger(updatedAt))
|
|
328
|
+
throw badRequest('updatedAt', 'invalid')
|
|
288
329
|
const skill: SkillRecord = {
|
|
289
330
|
id: text(entry.id, 'id'),
|
|
290
331
|
name: text(entry.name, 'name'),
|
package/src/skills/validation.ts
CHANGED
|
@@ -14,8 +14,13 @@ export function object(value: unknown, field: string): Record<string, unknown> {
|
|
|
14
14
|
if (!isObject(value)) throw badRequest(field, 'invalid')
|
|
15
15
|
return value
|
|
16
16
|
}
|
|
17
|
-
export function exact(
|
|
18
|
-
|
|
17
|
+
export function exact(
|
|
18
|
+
value: Record<string, unknown>,
|
|
19
|
+
keys: readonly string[],
|
|
20
|
+
field: string,
|
|
21
|
+
): void {
|
|
22
|
+
if (Object.keys(value).some((key) => !keys.includes(key)))
|
|
23
|
+
throw badRequest(field, 'unknown_field')
|
|
19
24
|
}
|
|
20
25
|
export function text(value: unknown, field: string): string {
|
|
21
26
|
if (typeof value !== 'string') throw badRequest(field, 'invalid')
|
|
@@ -31,7 +36,17 @@ export function strings(value: unknown, field: string): string[] {
|
|
|
31
36
|
return value.map((entry: unknown) => text(entry, field))
|
|
32
37
|
}
|
|
33
38
|
export function checkSecrets(value: string, field: string): void {
|
|
34
|
-
const prefixes = [
|
|
39
|
+
const prefixes = [
|
|
40
|
+
'sk-',
|
|
41
|
+
'ghp_',
|
|
42
|
+
'gho_',
|
|
43
|
+
'github_pat_',
|
|
44
|
+
'xoxb-',
|
|
45
|
+
'xoxp-',
|
|
46
|
+
'AKIA',
|
|
47
|
+
'AIza',
|
|
48
|
+
'glpat-',
|
|
49
|
+
]
|
|
35
50
|
let lineNumber = 0
|
|
36
51
|
for (const line of value.split('\n')) {
|
|
37
52
|
lineNumber++
|
|
@@ -40,17 +55,22 @@ export function checkSecrets(value: string, field: string): void {
|
|
|
40
55
|
if (
|
|
41
56
|
(line.includes('-----BEGIN') && line.includes('PRIVATE KEY')) ||
|
|
42
57
|
(bearer?.[1] !== undefined && /^[A-Za-z0-9_./+=-]{20}/u.test(bearer[1])) ||
|
|
43
|
-
tokens.some(
|
|
58
|
+
tokens.some(
|
|
59
|
+
(token) => token.length >= 20 && prefixes.some((prefix) => token.startsWith(prefix)),
|
|
60
|
+
)
|
|
44
61
|
) {
|
|
45
62
|
throw badRequest(`${field}:${lineNumber}`, 'secret_detected')
|
|
46
63
|
}
|
|
47
64
|
}
|
|
48
65
|
}
|
|
49
|
-
export function validateSkillContent(
|
|
66
|
+
export function validateSkillContent(
|
|
67
|
+
skill: Pick<ContentIdentity, 'name' | 'description' | 'version' | 'body'>,
|
|
68
|
+
): void {
|
|
50
69
|
if (!/^[a-z0-9][a-z0-9-]{0,63}$/u.test(skill.name)) throw badRequest('name', 'invalid')
|
|
51
70
|
if ([...skill.description].length < 1 || [...skill.description].length > 500)
|
|
52
71
|
throw badRequest('description', 'invalid', 500)
|
|
53
|
-
if (skill.version !== null && [...skill.version].length > 32)
|
|
72
|
+
if (skill.version !== null && [...skill.version].length > 32)
|
|
73
|
+
throw badRequest('version', 'invalid', 32)
|
|
54
74
|
if (!skill.body.trim()) throw badRequest('body', 'empty')
|
|
55
75
|
if (encoder.encode(skill.body).length > 65536) throw badRequest('body', 'too_large', 65536)
|
|
56
76
|
checkSecrets(skill.name, 'name')
|
|
@@ -65,7 +85,8 @@ export function parseManualSave(value: unknown): ManualSave {
|
|
|
65
85
|
name: text(input.name, 'name'),
|
|
66
86
|
description: text(input.description, 'description'),
|
|
67
87
|
body: text(input.body, 'body'),
|
|
68
|
-
version:
|
|
88
|
+
version:
|
|
89
|
+
input.version === undefined || input.version === null ? null : text(input.version, 'version'),
|
|
69
90
|
...(input.id === undefined ? {} : { id: text(input.id, 'id') }),
|
|
70
91
|
...(input.expectedRevision === undefined
|
|
71
92
|
? {}
|
|
@@ -136,7 +157,8 @@ export function parseSkillMarkdown(raw: string): ParsedSkill {
|
|
|
136
157
|
fields.set(key, value)
|
|
137
158
|
}
|
|
138
159
|
if (body === undefined) throw badRequest(String(lineNumber + 1), 'frontmatter_unsupported')
|
|
139
|
-
if (body.split('\n', 1)[0] === '---')
|
|
160
|
+
if (body.split('\n', 1)[0] === '---')
|
|
161
|
+
throw badRequest(String(lineNumber + 1), 'frontmatter_unsupported')
|
|
140
162
|
const name = fields.get('name')
|
|
141
163
|
const description = fields.get('description')
|
|
142
164
|
if (name === undefined) throw badRequest('name', 'missing')
|
|
@@ -145,7 +167,14 @@ export function parseSkillMarkdown(raw: string): ParsedSkill {
|
|
|
145
167
|
fields.delete('name')
|
|
146
168
|
fields.delete('description')
|
|
147
169
|
fields.delete('version')
|
|
148
|
-
const skill: ParsedSkill = {
|
|
170
|
+
const skill: ParsedSkill = {
|
|
171
|
+
name,
|
|
172
|
+
description,
|
|
173
|
+
version,
|
|
174
|
+
body,
|
|
175
|
+
license: null,
|
|
176
|
+
extra: Object.fromEntries(fields),
|
|
177
|
+
}
|
|
149
178
|
validateSkillContent(skill)
|
|
150
179
|
return skill
|
|
151
180
|
}
|