@aexhq/sdk 0.13.6
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/LICENSE +201 -0
- package/README.md +160 -0
- package/dist/_contracts/connection-ticket.d.ts +21 -0
- package/dist/_contracts/connection-ticket.js +49 -0
- package/dist/_contracts/event-envelope.d.ts +276 -0
- package/dist/_contracts/event-envelope.js +324 -0
- package/dist/_contracts/event-stream-client.d.ts +47 -0
- package/dist/_contracts/event-stream-client.js +141 -0
- package/dist/_contracts/http.d.ts +35 -0
- package/dist/_contracts/http.js +114 -0
- package/dist/_contracts/index.d.ts +28 -0
- package/dist/_contracts/index.js +29 -0
- package/dist/_contracts/managed-key.d.ts +74 -0
- package/dist/_contracts/managed-key.js +110 -0
- package/dist/_contracts/operations.d.ts +237 -0
- package/dist/_contracts/operations.js +632 -0
- package/dist/_contracts/provider-support.d.ts +220 -0
- package/dist/_contracts/provider-support.js +90 -0
- package/dist/_contracts/proxy-protocol.d.ts +257 -0
- package/dist/_contracts/proxy-protocol.js +234 -0
- package/dist/_contracts/proxy-validation.d.ts +19 -0
- package/dist/_contracts/proxy-validation.js +51 -0
- package/dist/_contracts/run-artifacts.d.ts +47 -0
- package/dist/_contracts/run-artifacts.js +101 -0
- package/dist/_contracts/run-config.d.ts +304 -0
- package/dist/_contracts/run-config.js +659 -0
- package/dist/_contracts/run-cost.d.ts +125 -0
- package/dist/_contracts/run-cost.js +616 -0
- package/dist/_contracts/run-custody.d.ts +226 -0
- package/dist/_contracts/run-custody.js +465 -0
- package/dist/_contracts/run-record.d.ts +127 -0
- package/dist/_contracts/run-record.js +177 -0
- package/dist/_contracts/run-retention.d.ts +213 -0
- package/dist/_contracts/run-retention.js +484 -0
- package/dist/_contracts/run-unit.d.ts +194 -0
- package/dist/_contracts/run-unit.js +215 -0
- package/dist/_contracts/runner-event.d.ts +114 -0
- package/dist/_contracts/runner-event.js +187 -0
- package/dist/_contracts/runtime-manifest.d.ts +106 -0
- package/dist/_contracts/runtime-manifest.js +98 -0
- package/dist/_contracts/runtime-security-profile.d.ts +27 -0
- package/dist/_contracts/runtime-security-profile.js +82 -0
- package/dist/_contracts/runtime-sizes.d.ts +144 -0
- package/dist/_contracts/runtime-sizes.js +136 -0
- package/dist/_contracts/runtime-types.d.ts +212 -0
- package/dist/_contracts/runtime-types.js +2 -0
- package/dist/_contracts/sdk-errors.d.ts +34 -0
- package/dist/_contracts/sdk-errors.js +52 -0
- package/dist/_contracts/sdk-secrets.d.ts +31 -0
- package/dist/_contracts/sdk-secrets.js +220 -0
- package/dist/_contracts/side-effect-audit.d.ts +129 -0
- package/dist/_contracts/side-effect-audit.js +494 -0
- package/dist/_contracts/sse.d.ts +74 -0
- package/dist/_contracts/sse.js +0 -0
- package/dist/_contracts/stable.d.ts +26 -0
- package/dist/_contracts/stable.js +44 -0
- package/dist/_contracts/status.d.ts +19 -0
- package/dist/_contracts/status.js +61 -0
- package/dist/_contracts/submission.d.ts +383 -0
- package/dist/_contracts/submission.js +1380 -0
- package/dist/agents-md.d.ts +46 -0
- package/dist/agents-md.js +83 -0
- package/dist/agents-md.js.map +1 -0
- package/dist/asset-upload.d.ts +66 -0
- package/dist/asset-upload.js +168 -0
- package/dist/asset-upload.js.map +1 -0
- package/dist/bundle.d.ts +33 -0
- package/dist/bundle.js +89 -0
- package/dist/bundle.js.map +1 -0
- package/dist/cli.mjs +4140 -0
- package/dist/cli.mjs.sha256 +1 -0
- package/dist/client.d.ts +460 -0
- package/dist/client.js +857 -0
- package/dist/client.js.map +1 -0
- package/dist/fetch-archive.d.ts +16 -0
- package/dist/fetch-archive.js +170 -0
- package/dist/fetch-archive.js.map +1 -0
- package/dist/file.d.ts +57 -0
- package/dist/file.js +153 -0
- package/dist/file.js.map +1 -0
- package/dist/index.d.ts +30 -0
- package/dist/index.js +34 -0
- package/dist/index.js.map +1 -0
- package/dist/mcp-server.d.ts +84 -0
- package/dist/mcp-server.js +114 -0
- package/dist/mcp-server.js.map +1 -0
- package/dist/node-fs.d.ts +12 -0
- package/dist/node-fs.js +44 -0
- package/dist/node-fs.js.map +1 -0
- package/dist/proxy-endpoint.d.ts +131 -0
- package/dist/proxy-endpoint.js +147 -0
- package/dist/proxy-endpoint.js.map +1 -0
- package/dist/skill.d.ts +117 -0
- package/dist/skill.js +169 -0
- package/dist/skill.js.map +1 -0
- package/dist/version.d.ts +9 -0
- package/dist/version.js +10 -0
- package/dist/version.js.map +1 -0
- package/docs/cleanup.md +38 -0
- package/docs/credentials.md +153 -0
- package/docs/events.md +76 -0
- package/docs/mcp.md +47 -0
- package/docs/outputs.md +157 -0
- package/docs/product-boundaries.md +57 -0
- package/docs/provider-runtime-capabilities.md +103 -0
- package/docs/quickstart.md +110 -0
- package/docs/release.md +99 -0
- package/docs/run-config.md +53 -0
- package/docs/run-record.md +39 -0
- package/docs/skills.md +139 -0
- package/docs/testing.md +29 -0
- package/package.json +47 -0
|
@@ -0,0 +1,659 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Run-request config and composition refs for the public SDK/CLI surface.
|
|
3
|
+
*
|
|
4
|
+
* Public composition concepts:
|
|
5
|
+
*
|
|
6
|
+
* - `SkillRef` is the wire-level reference to a skill — either an
|
|
7
|
+
* `skl_*` id pointing at a workspace-uploaded bundle, a
|
|
8
|
+
* `{vendor, skillId, version}` reference to a provider built-in, or
|
|
9
|
+
* a `{slot, name, contentHash}` reference to per-run bytes attached
|
|
10
|
+
* as a multipart part on the submitRun call (and torn down at run
|
|
11
|
+
* terminal). The three shapes are discriminated by `kind` so
|
|
12
|
+
* consumers branch mechanically and providers can never accidentally
|
|
13
|
+
* be looked up in `skill_bundles`. Transient refs do NOT round-trip
|
|
14
|
+
* through JSON (bytes can't be serialised back); `parseRunRequestConfig`
|
|
15
|
+
* therefore rejects them while the BFF multipart submission parser
|
|
16
|
+
* accepts them.
|
|
17
|
+
*
|
|
18
|
+
* - `McpServerRef` is the non-secret part of an MCP server declaration:
|
|
19
|
+
* `name` and `url`. Bearer / cookie / per-request headers travel in
|
|
20
|
+
* the run's vaulted `secrets.mcpServers` block keyed by the same
|
|
21
|
+
* `name`, and never enter the hashed submission payload or the
|
|
22
|
+
* run snapshot.
|
|
23
|
+
*
|
|
24
|
+
* - `RunRequestConfig` is the credential-free set of run parameters that
|
|
25
|
+
* can be persisted to disk (e.g. `aex run --config run.json`) or
|
|
26
|
+
* returned from ordinary application helper functions. It excludes
|
|
27
|
+
* `secrets`/`idempotencyKey`/`signal`; strings are already resolved at
|
|
28
|
+
* the call site before submission.
|
|
29
|
+
*
|
|
30
|
+
* - Skill bundle validation lives here so the SDK (zipping locally),
|
|
31
|
+
* hosted API (server-side unzip + manifest extraction) and runtime
|
|
32
|
+
* mount layer share a single source of truth for
|
|
33
|
+
* the limits, the path normaliser, and the manifest invariants. The
|
|
34
|
+
* DB CHECK constraints on `skill_bundles.manifest` mirror these.
|
|
35
|
+
*
|
|
36
|
+
* Keep this as the public source of truth for the SDK/CLI composition
|
|
37
|
+
* boundary.
|
|
38
|
+
*/
|
|
39
|
+
// ---------------------------------------------------------------------------
|
|
40
|
+
// Skill ID + name format
|
|
41
|
+
// ---------------------------------------------------------------------------
|
|
42
|
+
/**
|
|
43
|
+
* Mirrors the server-side CHECK constraint
|
|
44
|
+
* `skill_bundles_id_format_chk = check (id ~ '^skl_[A-Za-z0-9_-]{8,128}$')`
|
|
45
|
+
* on persisted skill bundles. Keep the two in lockstep.
|
|
46
|
+
*/
|
|
47
|
+
export const SKILL_ID_PATTERN = /^skl_[A-Za-z0-9_-]{8,128}$/;
|
|
48
|
+
/**
|
|
49
|
+
* Human-readable, workspace-scoped name. Lowercase, kebab-friendly,
|
|
50
|
+
* 1..128 chars. The DB enforces the length bound via
|
|
51
|
+
* `skill_bundles_name_len_chk`; this regex tightens the SDK/CLI input
|
|
52
|
+
* surface so callers fail at the boundary rather than in the BFF.
|
|
53
|
+
*/
|
|
54
|
+
export const SKILL_NAME_PATTERN = /^[a-z0-9][a-z0-9_-]{0,127}$/;
|
|
55
|
+
// ---------------------------------------------------------------------------
|
|
56
|
+
// Skill bundle limits (uploaded bundles)
|
|
57
|
+
// ---------------------------------------------------------------------------
|
|
58
|
+
/**
|
|
59
|
+
* Hard caps applied at upload time. The SDK enforces these before
|
|
60
|
+
* computing the zip hash so a clearly-too-big bundle never wastes
|
|
61
|
+
* bytes-on-the-wire; the BFF re-enforces server-side because the SDK
|
|
62
|
+
* is untrusted. Numbers are deliberately conservative for the MVP and
|
|
63
|
+
* can be tuned later; keep this object as the single tuning point.
|
|
64
|
+
*/
|
|
65
|
+
export const SKILL_BUNDLE_LIMITS = {
|
|
66
|
+
/** Compressed (.zip) ceiling. */
|
|
67
|
+
maxCompressedBytes: 10 * 1024 * 1024,
|
|
68
|
+
/** Sum of uncompressed file sizes. */
|
|
69
|
+
maxDecompressedBytes: 50 * 1024 * 1024,
|
|
70
|
+
/** Number of regular file entries (directories don't count). */
|
|
71
|
+
maxFiles: 1000,
|
|
72
|
+
/** Maximum directory nesting depth — `a/b/c/d` has depth 4. */
|
|
73
|
+
maxDepth: 16,
|
|
74
|
+
/** Single-entry path length cap. */
|
|
75
|
+
maxPathLength: 512,
|
|
76
|
+
/** Stored file mode for ordinary files. */
|
|
77
|
+
defaultFileMode: 0o644,
|
|
78
|
+
/** Stored directory mode. */
|
|
79
|
+
defaultDirMode: 0o755
|
|
80
|
+
};
|
|
81
|
+
/** Content-hash format: `sha256:<64 lowercase hex>`. */
|
|
82
|
+
export const INLINE_CONTENT_HASH_PATTERN = /^sha256:[0-9a-f]{64}$/;
|
|
83
|
+
export function isProviderSkillRef(ref) {
|
|
84
|
+
return ref.kind === "provider";
|
|
85
|
+
}
|
|
86
|
+
export function isAssetRef(ref) {
|
|
87
|
+
return ref.kind === "asset";
|
|
88
|
+
}
|
|
89
|
+
/**
|
|
90
|
+
* Asset ids are storage-neutral product ids. Current uploads derive the id from
|
|
91
|
+
* the content digest (`asset_<sha256hex>`), but callers must treat it as opaque.
|
|
92
|
+
*/
|
|
93
|
+
export const ASSET_ID_PATTERN = /^asset_[A-Za-z0-9_-]{8,128}$/;
|
|
94
|
+
export function isAgentsMdAssetRef(ref) {
|
|
95
|
+
return ref.kind === "asset";
|
|
96
|
+
}
|
|
97
|
+
export function isFileAssetRef(ref) {
|
|
98
|
+
return ref.kind === "asset";
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Parse a `SkillRef` from untrusted input. Used by the BFF run parser
|
|
102
|
+
* and by the operations module when deserialising API responses. Only
|
|
103
|
+
* `kind: "asset"` and `kind: "provider"` are valid; all other historical
|
|
104
|
+
* wire shapes (including storage-specific refs) are rejected.
|
|
105
|
+
*/
|
|
106
|
+
export function parseSkillRef(input, path) {
|
|
107
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
108
|
+
throw new Error(`${path} must be a SkillRef object`);
|
|
109
|
+
}
|
|
110
|
+
const record = input;
|
|
111
|
+
const kind = record.kind;
|
|
112
|
+
if (kind === "provider") {
|
|
113
|
+
for (const key of Object.keys(record)) {
|
|
114
|
+
if (key !== "kind" && key !== "vendor" && key !== "skillId" && key !== "version") {
|
|
115
|
+
throw new Error(`${path} contains unexpected field for provider SkillRef: ${key}`);
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
const vendor = record.vendor;
|
|
119
|
+
if (vendor !== "anthropic" && vendor !== "custom") {
|
|
120
|
+
throw new Error(`${path}.vendor must be 'anthropic' or 'custom'`);
|
|
121
|
+
}
|
|
122
|
+
const skillId = record.skillId;
|
|
123
|
+
if (typeof skillId !== "string" || skillId.length === 0 || skillId.length > 256) {
|
|
124
|
+
throw new Error(`${path}.skillId must be a non-empty string (<= 256 chars)`);
|
|
125
|
+
}
|
|
126
|
+
const version = record.version;
|
|
127
|
+
if (version !== undefined && (typeof version !== "string" || version.length === 0 || version.length > 64)) {
|
|
128
|
+
throw new Error(`${path}.version, when provided, must be a non-empty string (<= 64 chars)`);
|
|
129
|
+
}
|
|
130
|
+
return {
|
|
131
|
+
kind: "provider",
|
|
132
|
+
vendor,
|
|
133
|
+
skillId,
|
|
134
|
+
...(version !== undefined ? { version } : {})
|
|
135
|
+
};
|
|
136
|
+
}
|
|
137
|
+
if (kind === "asset") {
|
|
138
|
+
return parseAssetRefFields(record, path);
|
|
139
|
+
}
|
|
140
|
+
throw new Error(`${path}.kind must be 'provider' or 'asset'`);
|
|
141
|
+
}
|
|
142
|
+
/**
|
|
143
|
+
* Common parser for any `kind: "asset"` ref (skill / agentsMd / file).
|
|
144
|
+
*/
|
|
145
|
+
export function parseAssetRefFields(record, path) {
|
|
146
|
+
for (const key of Object.keys(record)) {
|
|
147
|
+
if (key !== "kind" &&
|
|
148
|
+
key !== "assetId" &&
|
|
149
|
+
key !== "name" &&
|
|
150
|
+
key !== "mountPath") {
|
|
151
|
+
throw new Error(`${path} contains unexpected field for asset ref: ${key}`);
|
|
152
|
+
}
|
|
153
|
+
}
|
|
154
|
+
const assetId = record.assetId;
|
|
155
|
+
if (typeof assetId !== "string" || !ASSET_ID_PATTERN.test(assetId)) {
|
|
156
|
+
throw new Error(`${path}.assetId must match ${ASSET_ID_PATTERN.source}`);
|
|
157
|
+
}
|
|
158
|
+
const name = record.name;
|
|
159
|
+
if (typeof name !== "string" || name.length === 0 || name.length > 128) {
|
|
160
|
+
throw new Error(`${path}.name must be a non-empty string (<= 128 chars)`);
|
|
161
|
+
}
|
|
162
|
+
const mountPath = record.mountPath;
|
|
163
|
+
if (mountPath !== undefined && (typeof mountPath !== "string" || mountPath.length === 0)) {
|
|
164
|
+
throw new Error(`${path}.mountPath, when provided, must be a non-empty string`);
|
|
165
|
+
}
|
|
166
|
+
return {
|
|
167
|
+
kind: "asset",
|
|
168
|
+
assetId,
|
|
169
|
+
name,
|
|
170
|
+
...(mountPath !== undefined ? { mountPath } : {})
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
export class SkillBundleValidationError extends Error {
|
|
174
|
+
constructor(message) {
|
|
175
|
+
super(message);
|
|
176
|
+
this.name = "SkillBundleValidationError";
|
|
177
|
+
}
|
|
178
|
+
}
|
|
179
|
+
/**
|
|
180
|
+
* Reject input paths that try to escape the bundle root or smuggle
|
|
181
|
+
* platform-specific syntax. Returns the canonical forward-slash
|
|
182
|
+
* relative path; never returns paths starting or ending with `/`.
|
|
183
|
+
*
|
|
184
|
+
* Rejects:
|
|
185
|
+
* - empty strings and pure whitespace
|
|
186
|
+
* - absolute paths (`/foo`, `C:\foo`, `\\server\share`)
|
|
187
|
+
* - backslash separators (Windows)
|
|
188
|
+
* - `..` segments anywhere in the path
|
|
189
|
+
* - `.` segments anywhere except a leading bare `.`
|
|
190
|
+
* - paths whose length exceeds `SKILL_BUNDLE_LIMITS.maxPathLength`
|
|
191
|
+
* - paths whose depth exceeds `SKILL_BUNDLE_LIMITS.maxDepth`
|
|
192
|
+
* - NUL bytes
|
|
193
|
+
*/
|
|
194
|
+
export function normaliseSkillBundlePath(input) {
|
|
195
|
+
if (typeof input !== "string") {
|
|
196
|
+
throw new SkillBundleValidationError("bundle entry path must be a string");
|
|
197
|
+
}
|
|
198
|
+
if (input.length === 0 || input.trim().length === 0) {
|
|
199
|
+
throw new SkillBundleValidationError("bundle entry path must be non-empty");
|
|
200
|
+
}
|
|
201
|
+
if (input.length > SKILL_BUNDLE_LIMITS.maxPathLength) {
|
|
202
|
+
throw new SkillBundleValidationError(`bundle entry path exceeds maxPathLength (${SKILL_BUNDLE_LIMITS.maxPathLength}): ${input}`);
|
|
203
|
+
}
|
|
204
|
+
if (input.includes("\0")) {
|
|
205
|
+
throw new SkillBundleValidationError(`bundle entry path contains NUL byte: ${JSON.stringify(input)}`);
|
|
206
|
+
}
|
|
207
|
+
if (input.includes("\\")) {
|
|
208
|
+
throw new SkillBundleValidationError(`bundle entry path uses backslash separator: ${input}`);
|
|
209
|
+
}
|
|
210
|
+
if (/^[A-Za-z]:[\\/]/.test(input)) {
|
|
211
|
+
throw new SkillBundleValidationError(`bundle entry path uses a drive letter: ${input}`);
|
|
212
|
+
}
|
|
213
|
+
if (input.startsWith("/")) {
|
|
214
|
+
throw new SkillBundleValidationError(`bundle entry path must be relative: ${input}`);
|
|
215
|
+
}
|
|
216
|
+
// Reject trailing slash so callers cannot disguise directory entries
|
|
217
|
+
// as files. The manifest is files-only.
|
|
218
|
+
if (input.endsWith("/")) {
|
|
219
|
+
throw new SkillBundleValidationError(`bundle entry path must not end with '/': ${input}`);
|
|
220
|
+
}
|
|
221
|
+
const segments = input.split("/");
|
|
222
|
+
for (const segment of segments) {
|
|
223
|
+
if (segment === "..") {
|
|
224
|
+
throw new SkillBundleValidationError(`bundle entry path contains '..' segment: ${input}`);
|
|
225
|
+
}
|
|
226
|
+
if (segment === "." || segment === "") {
|
|
227
|
+
throw new SkillBundleValidationError(`bundle entry path contains empty or '.' segment: ${input}`);
|
|
228
|
+
}
|
|
229
|
+
}
|
|
230
|
+
if (segments.length > SKILL_BUNDLE_LIMITS.maxDepth) {
|
|
231
|
+
throw new SkillBundleValidationError(`bundle entry path exceeds maxDepth (${SKILL_BUNDLE_LIMITS.maxDepth}): ${input}`);
|
|
232
|
+
}
|
|
233
|
+
return input;
|
|
234
|
+
}
|
|
235
|
+
/**
|
|
236
|
+
* Validate one manifest entry: normalises the path, bounds the size,
|
|
237
|
+
* and sanitises the mode to one of {defaultFileMode, defaultDirMode}.
|
|
238
|
+
* The bundle is files-only, so any non-regular-file entry is rejected
|
|
239
|
+
* upstream by the caller (zip parser must skip symlinks, device files,
|
|
240
|
+
* etc. before reaching this function).
|
|
241
|
+
*/
|
|
242
|
+
export function validateSkillBundleEntry(input) {
|
|
243
|
+
const path = normaliseSkillBundlePath(input.path);
|
|
244
|
+
if (!Number.isFinite(input.size) || !Number.isInteger(input.size) || input.size < 0) {
|
|
245
|
+
throw new SkillBundleValidationError(`bundle entry size must be a non-negative integer (${path})`);
|
|
246
|
+
}
|
|
247
|
+
if (input.size > SKILL_BUNDLE_LIMITS.maxDecompressedBytes) {
|
|
248
|
+
throw new SkillBundleValidationError(`bundle entry size exceeds maxDecompressedBytes (${SKILL_BUNDLE_LIMITS.maxDecompressedBytes}): ${path}`);
|
|
249
|
+
}
|
|
250
|
+
// Sanitise the stored mode. Executable bit is implied by runtime
|
|
251
|
+
// convention; we never persist arbitrary chmod from the user's FS.
|
|
252
|
+
const mode = (input.mode ?? SKILL_BUNDLE_LIMITS.defaultFileMode) & 0o777;
|
|
253
|
+
if (mode !== SKILL_BUNDLE_LIMITS.defaultFileMode && mode !== SKILL_BUNDLE_LIMITS.defaultDirMode) {
|
|
254
|
+
return { path, size: input.size, mode: SKILL_BUNDLE_LIMITS.defaultFileMode };
|
|
255
|
+
}
|
|
256
|
+
return { path, size: input.size, mode };
|
|
257
|
+
}
|
|
258
|
+
/**
|
|
259
|
+
* Validate a full **skill bundle** manifest. Enforces:
|
|
260
|
+
* - entries is a non-empty array
|
|
261
|
+
* - `SKILL.md` exists at the bundle root (this is what makes a
|
|
262
|
+
* bundle a skill rather than a plain workspace file)
|
|
263
|
+
* - file count <= maxFiles
|
|
264
|
+
* - total uncompressed size <= maxDecompressedBytes
|
|
265
|
+
* - per-entry validation (see `validateSkillBundleEntry`)
|
|
266
|
+
* - no duplicate paths
|
|
267
|
+
*
|
|
268
|
+
* In this public surface, **skill** means "Claude Skill" — bundles without
|
|
269
|
+
* `SKILL.md` are not skills and must go
|
|
270
|
+
* through the `AgentsMd` or `File` upload concepts instead.
|
|
271
|
+
*
|
|
272
|
+
* Returns a canonical manifest with totals computed.
|
|
273
|
+
*/
|
|
274
|
+
export function validateSkillBundleManifest(input) {
|
|
275
|
+
if (!Array.isArray(input) || input.length === 0) {
|
|
276
|
+
throw new SkillBundleValidationError("bundle manifest must be a non-empty array of entries");
|
|
277
|
+
}
|
|
278
|
+
if (input.length > SKILL_BUNDLE_LIMITS.maxFiles) {
|
|
279
|
+
throw new SkillBundleValidationError(`bundle exceeds maxFiles (${SKILL_BUNDLE_LIMITS.maxFiles}): got ${input.length}`);
|
|
280
|
+
}
|
|
281
|
+
const seen = new Set();
|
|
282
|
+
const entries = [];
|
|
283
|
+
let totalSize = 0;
|
|
284
|
+
let hasSkillMd = false;
|
|
285
|
+
for (const raw of input) {
|
|
286
|
+
const entry = validateSkillBundleEntry(raw);
|
|
287
|
+
if (seen.has(entry.path)) {
|
|
288
|
+
throw new SkillBundleValidationError(`bundle manifest contains duplicate path: ${entry.path}`);
|
|
289
|
+
}
|
|
290
|
+
seen.add(entry.path);
|
|
291
|
+
if (entry.path === "SKILL.md") {
|
|
292
|
+
hasSkillMd = true;
|
|
293
|
+
}
|
|
294
|
+
totalSize += entry.size;
|
|
295
|
+
if (totalSize > SKILL_BUNDLE_LIMITS.maxDecompressedBytes) {
|
|
296
|
+
throw new SkillBundleValidationError(`bundle total size exceeds maxDecompressedBytes (${SKILL_BUNDLE_LIMITS.maxDecompressedBytes})`);
|
|
297
|
+
}
|
|
298
|
+
entries.push(entry);
|
|
299
|
+
}
|
|
300
|
+
if (!hasSkillMd) {
|
|
301
|
+
throw new SkillBundleValidationError("skill bundle manifest must contain a 'SKILL.md' entry at the bundle root. " +
|
|
302
|
+
"If you want to upload an instructions file or generic agent context, use " +
|
|
303
|
+
"AgentsMd or File instead.");
|
|
304
|
+
}
|
|
305
|
+
return { entries, totalSize, fileCount: entries.length };
|
|
306
|
+
}
|
|
307
|
+
/**
|
|
308
|
+
* Returns true when the manifest carries a `SKILL.md` entry at the
|
|
309
|
+
* bundle root. The presence of this file is Anthropic's
|
|
310
|
+
* skill-auto-discovery signal — bundles that have it are treated as
|
|
311
|
+
* Claude skills and mounted accordingly; bundles that don't are still
|
|
312
|
+
* usable agent context (AGENTS.md, settings files, folders of helper
|
|
313
|
+
* data) but the agent won't pick them up via the skills mechanism.
|
|
314
|
+
*
|
|
315
|
+
* The check is intentionally a separate, callable predicate (rather
|
|
316
|
+
* than baked into `validateSkillBundleManifest`) so the storage and
|
|
317
|
+
* the attach layers can remain independent.
|
|
318
|
+
*/
|
|
319
|
+
export function hasSkillMdAtRoot(manifest) {
|
|
320
|
+
return manifest.entries.some((entry) => entry.path === "SKILL.md");
|
|
321
|
+
}
|
|
322
|
+
// ---------------------------------------------------------------------------
|
|
323
|
+
// McpServerRef (non-secret) + RunConfigMcpServer (with optional headers)
|
|
324
|
+
// ---------------------------------------------------------------------------
|
|
325
|
+
/**
|
|
326
|
+
* Remote MCP transports Aex accepts. Both are over HTTP — `http`
|
|
327
|
+
* is the streamable-HTTP transport, `sse` is the event-stream
|
|
328
|
+
* transport. `stdio` is explicitly NOT a value here: local-process
|
|
329
|
+
* MCP is not implemented.
|
|
330
|
+
*/
|
|
331
|
+
export const REMOTE_MCP_TRANSPORTS = ["http", "sse"];
|
|
332
|
+
/**
|
|
333
|
+
* Canonical error string for any attempt to declare a stdio-shaped MCP
|
|
334
|
+
* server (`transport: "stdio"`, or a stdio-only field like `command` /
|
|
335
|
+
* `args` / `env`). Pinned in source so every surface — shared parser,
|
|
336
|
+
* SDK builder, CLI flag parser, dashboard form — surfaces the same
|
|
337
|
+
* message and a user can find it via grep.
|
|
338
|
+
*/
|
|
339
|
+
export const REMOTE_MCP_STDIO_REJECTED_MESSAGE = "stdio MCP servers are not supported by Aex. Aex supports remote MCP servers over HTTP/SSE only.";
|
|
340
|
+
/**
|
|
341
|
+
* Stdio-only fields. Used by the parser to detect a stdio shape even
|
|
342
|
+
* when the caller omits `transport: "stdio"` (e.g. `{ url, command }`
|
|
343
|
+
* — the presence of `command` alone is enough to identify a stdio
|
|
344
|
+
* declaration and reject it).
|
|
345
|
+
*/
|
|
346
|
+
const STDIO_ONLY_FIELDS = ["command", "args", "env"];
|
|
347
|
+
export const MCP_SERVER_NAME_PATTERN = /^[a-z][a-z0-9_-]{0,62}$/;
|
|
348
|
+
export function parseMcpServerRef(input, path) {
|
|
349
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
350
|
+
throw new Error(`${path} must be an object`);
|
|
351
|
+
}
|
|
352
|
+
const record = input;
|
|
353
|
+
rejectStdioMcpShape(record);
|
|
354
|
+
// Headers belong on `RunConfigMcpServer`, not the non-secret wire ref;
|
|
355
|
+
// and the wire `submission.mcpServers` must NEVER contain headers. So
|
|
356
|
+
// reject any field other than {name,url,transport} explicitly to make a
|
|
357
|
+
// caller accidentally inlining `headers` into the non-secret half fail
|
|
358
|
+
// loudly instead of silently dropping the field.
|
|
359
|
+
// `parseRunConfigMcpServerRef` handles the headers case separately for
|
|
360
|
+
// run-config entries.
|
|
361
|
+
for (const key of Object.keys(record)) {
|
|
362
|
+
if (key !== "name" && key !== "url" && key !== "transport") {
|
|
363
|
+
throw new Error(`${path}.${key} is not an allowed field for McpServerRef; permitted: name, url, transport`);
|
|
364
|
+
}
|
|
365
|
+
}
|
|
366
|
+
const name = record.name;
|
|
367
|
+
if (typeof name !== "string" || !MCP_SERVER_NAME_PATTERN.test(name)) {
|
|
368
|
+
throw new Error(`${path}.name must match ${MCP_SERVER_NAME_PATTERN.source}`);
|
|
369
|
+
}
|
|
370
|
+
const url = record.url;
|
|
371
|
+
if (typeof url !== "string" || url.length === 0) {
|
|
372
|
+
throw new Error(`${path}.url must be a non-empty string`);
|
|
373
|
+
}
|
|
374
|
+
try {
|
|
375
|
+
const parsed = new URL(url);
|
|
376
|
+
if (parsed.protocol !== "https:" && parsed.protocol !== "http:") {
|
|
377
|
+
throw new Error(`${path}.url must use http or https (got ${parsed.protocol})`);
|
|
378
|
+
}
|
|
379
|
+
// Auth belongs in `secrets.mcpServers[i].headers`, never in the URL
|
|
380
|
+
// itself. A `https://user:pass@host` style URL would be persisted in
|
|
381
|
+
// the non-secret run snapshot and hashed into the idempotency key —
|
|
382
|
+
// both unacceptable for credential material.
|
|
383
|
+
if (parsed.username !== "" || parsed.password !== "") {
|
|
384
|
+
throw new Error(`${path}.url must not contain userinfo (username/password); use secrets.mcpServers[].headers for auth`);
|
|
385
|
+
}
|
|
386
|
+
// SSRF guard at the parser boundary (C4) — the Worker MCP proxy
|
|
387
|
+
// relies on this validation; CF's outbound fetch refuses RFC1918 but
|
|
388
|
+
// does NOT block loopback names, link-local IPv6, 169.254.x, or
|
|
389
|
+
// arbitrary non-443 ports on https. Each branch below maps to a
|
|
390
|
+
// c4-worker-ssrf.regression test case.
|
|
391
|
+
const ssrfDenial = denyReasonForMcpHost(parsed);
|
|
392
|
+
if (ssrfDenial !== null) {
|
|
393
|
+
throw new Error(`${path}.url ${ssrfDenial}`);
|
|
394
|
+
}
|
|
395
|
+
}
|
|
396
|
+
catch (cause) {
|
|
397
|
+
if (cause instanceof Error && cause.message.startsWith(path)) {
|
|
398
|
+
throw cause;
|
|
399
|
+
}
|
|
400
|
+
throw new Error(`${path}.url is not a valid URL: ${url}`);
|
|
401
|
+
}
|
|
402
|
+
const transport = parseRemoteMcpTransport(record.transport, `${path}.transport`);
|
|
403
|
+
return transport ? { name, url, transport } : { name, url };
|
|
404
|
+
}
|
|
405
|
+
/**
|
|
406
|
+
* Throw the canonical stdio-rejected error if the record carries any
|
|
407
|
+
* stdio-only marker (`transport: "stdio"`, `command`, `args`, `env`).
|
|
408
|
+
* Used by both the shared parser and the SDK `McpServer.remote`
|
|
409
|
+
* builder so every entry point surfaces the same message.
|
|
410
|
+
*/
|
|
411
|
+
export function rejectStdioMcpShape(record) {
|
|
412
|
+
if (record.transport === "stdio") {
|
|
413
|
+
throw new Error(REMOTE_MCP_STDIO_REJECTED_MESSAGE);
|
|
414
|
+
}
|
|
415
|
+
for (const field of STDIO_ONLY_FIELDS) {
|
|
416
|
+
if (record[field] !== undefined) {
|
|
417
|
+
throw new Error(REMOTE_MCP_STDIO_REJECTED_MESSAGE);
|
|
418
|
+
}
|
|
419
|
+
}
|
|
420
|
+
}
|
|
421
|
+
/**
|
|
422
|
+
* Reasons an MCP server URL should be refused at parse time. Returns null
|
|
423
|
+
* when the URL is acceptable. Hostnames are lowercased; numeric ranges
|
|
424
|
+
* are checked literally so the catch covers both names ("localhost") and
|
|
425
|
+
* IP literals ("127.0.0.1") symmetrically.
|
|
426
|
+
*
|
|
427
|
+
* Surface tracked by server-side SSRF regression coverage.
|
|
428
|
+
*/
|
|
429
|
+
function denyReasonForMcpHost(parsed) {
|
|
430
|
+
// `new URL("https://[fe80::1]/").hostname` returns `[fe80::1]` WITH
|
|
431
|
+
// the brackets on Node 22; strip them so the IPv6 checks match either
|
|
432
|
+
// shape symmetrically.
|
|
433
|
+
const host = parsed.hostname.toLowerCase().replace(/^\[|\]$/g, "");
|
|
434
|
+
// Loopback name (covers `localhost` + `localhost.localdomain` etc.)
|
|
435
|
+
if (host === "localhost" || host.endsWith(".localhost")) {
|
|
436
|
+
return "must not target a loopback hostname";
|
|
437
|
+
}
|
|
438
|
+
// Loopback IPv4 (127.0.0.0/8)
|
|
439
|
+
if (/^127(?:\.[0-9]+){3}$/.test(host)) {
|
|
440
|
+
return "must not target loopback IPv4 (127.0.0.0/8)";
|
|
441
|
+
}
|
|
442
|
+
// Loopback IPv6 (::1 in any acceptable form)
|
|
443
|
+
if (host === "::1" || host === "0:0:0:0:0:0:0:1") {
|
|
444
|
+
return "must not target loopback IPv6 (::1)";
|
|
445
|
+
}
|
|
446
|
+
// Link-local IPv6 (fe80::/10 — fe80:: through febf::)
|
|
447
|
+
if (/^fe[89ab][0-9a-f]?:/.test(host)) {
|
|
448
|
+
return "must not target link-local IPv6 (fe80::/10)";
|
|
449
|
+
}
|
|
450
|
+
// Link-local / metadata IPv4 (169.254.0.0/16 — includes 169.254.169.254)
|
|
451
|
+
if (/^169\.254\.[0-9]+\.[0-9]+$/.test(host)) {
|
|
452
|
+
return "must not target link-local IPv4 (169.254.0.0/16) — cloud metadata range";
|
|
453
|
+
}
|
|
454
|
+
// RFC1918 private ranges (10/8, 172.16/12, 192.168/16) — defense in depth.
|
|
455
|
+
if (/^10\.[0-9]+\.[0-9]+\.[0-9]+$/.test(host)) {
|
|
456
|
+
return "must not target RFC1918 IPv4 (10.0.0.0/8)";
|
|
457
|
+
}
|
|
458
|
+
if (/^172\.(1[6-9]|2[0-9]|3[01])\.[0-9]+\.[0-9]+$/.test(host)) {
|
|
459
|
+
return "must not target RFC1918 IPv4 (172.16.0.0/12)";
|
|
460
|
+
}
|
|
461
|
+
if (/^192\.168\.[0-9]+\.[0-9]+$/.test(host)) {
|
|
462
|
+
return "must not target RFC1918 IPv4 (192.168.0.0/16)";
|
|
463
|
+
}
|
|
464
|
+
// Port constraint: https must be on 443 (defense in depth — non-standard
|
|
465
|
+
// https ports often indicate internal services). http allowance keeps
|
|
466
|
+
// the existing local-dev pattern (e.g. host.docker.internal:8787) usable.
|
|
467
|
+
if (parsed.protocol === "https:" && parsed.port !== "" && parsed.port !== "443") {
|
|
468
|
+
return `must use port 443 for https (got ${parsed.port})`;
|
|
469
|
+
}
|
|
470
|
+
return null;
|
|
471
|
+
}
|
|
472
|
+
function parseRemoteMcpTransport(input, field) {
|
|
473
|
+
if (input === undefined) {
|
|
474
|
+
return undefined;
|
|
475
|
+
}
|
|
476
|
+
if (typeof input !== "string" || !REMOTE_MCP_TRANSPORTS.includes(input)) {
|
|
477
|
+
throw new Error(`${field} must be one of: ${REMOTE_MCP_TRANSPORTS.join(", ")} (got ${JSON.stringify(input)})`);
|
|
478
|
+
}
|
|
479
|
+
return input;
|
|
480
|
+
}
|
|
481
|
+
/**
|
|
482
|
+
* Strict parser for run-config MCP server entries. Allows only the
|
|
483
|
+
* `{name, url, headers?}` shape so config loaded from `--config run.json`
|
|
484
|
+
* cannot smuggle unrelated fields past the parser.
|
|
485
|
+
*/
|
|
486
|
+
function parseRunConfigMcpServerRef(input, path) {
|
|
487
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
488
|
+
throw new Error(`${path} must be an object`);
|
|
489
|
+
}
|
|
490
|
+
const record = input;
|
|
491
|
+
rejectStdioMcpShape(record);
|
|
492
|
+
for (const key of Object.keys(record)) {
|
|
493
|
+
if (key !== "name" && key !== "url" && key !== "headers" && key !== "transport") {
|
|
494
|
+
throw new Error(`${path}.${key} is not an allowed field for RunConfigMcpServer; permitted: name, url, transport, headers`);
|
|
495
|
+
}
|
|
496
|
+
}
|
|
497
|
+
// Reuse the {name,url,transport} validator by passing the stripped object.
|
|
498
|
+
const stripped = { name: record.name, url: record.url };
|
|
499
|
+
if (record.transport !== undefined)
|
|
500
|
+
stripped.transport = record.transport;
|
|
501
|
+
const ref = parseMcpServerRef(stripped, path);
|
|
502
|
+
const rawHeaders = record.headers;
|
|
503
|
+
if (rawHeaders === undefined) {
|
|
504
|
+
return ref;
|
|
505
|
+
}
|
|
506
|
+
if (rawHeaders === null || typeof rawHeaders !== "object" || Array.isArray(rawHeaders)) {
|
|
507
|
+
throw new Error(`${path}.headers, when provided, must be a string-keyed object`);
|
|
508
|
+
}
|
|
509
|
+
const headers = {};
|
|
510
|
+
for (const [hk, hv] of Object.entries(rawHeaders)) {
|
|
511
|
+
if (typeof hv !== "string") {
|
|
512
|
+
throw new Error(`${path}.headers.${hk} must be a string`);
|
|
513
|
+
}
|
|
514
|
+
headers[hk] = hv;
|
|
515
|
+
}
|
|
516
|
+
return { ...ref, headers };
|
|
517
|
+
}
|
|
518
|
+
// ---------------------------------------------------------------------------
|
|
519
|
+
// Run request config parser (used by CLI to load `run.json`)
|
|
520
|
+
// ---------------------------------------------------------------------------
|
|
521
|
+
/**
|
|
522
|
+
* Parse a run request config from JSON. Defensive — used by the host CLI to
|
|
523
|
+
* load `--config run.json`. Throws with the JSON path that failed so
|
|
524
|
+
* a user can fix their file. Headers are preserved here and split out
|
|
525
|
+
* later by the SDK normalisation step.
|
|
526
|
+
*/
|
|
527
|
+
export function parseRunRequestConfig(input) {
|
|
528
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) {
|
|
529
|
+
throw new Error("run request config must be an object");
|
|
530
|
+
}
|
|
531
|
+
const record = input;
|
|
532
|
+
const allowed = new Set([
|
|
533
|
+
"model",
|
|
534
|
+
"system",
|
|
535
|
+
"prompt",
|
|
536
|
+
"skills",
|
|
537
|
+
"mcpServers",
|
|
538
|
+
"environment",
|
|
539
|
+
"runtimeSize",
|
|
540
|
+
"timeout",
|
|
541
|
+
"proxyEndpoints",
|
|
542
|
+
"metadata"
|
|
543
|
+
]);
|
|
544
|
+
for (const key of Object.keys(record)) {
|
|
545
|
+
if (!allowed.has(key)) {
|
|
546
|
+
throw new Error(`run request config contains unexpected field: ${key}`);
|
|
547
|
+
}
|
|
548
|
+
}
|
|
549
|
+
const model = record.model;
|
|
550
|
+
if (typeof model !== "string" || model.length === 0) {
|
|
551
|
+
throw new Error("run request config model must be a non-empty string");
|
|
552
|
+
}
|
|
553
|
+
const system = record.system;
|
|
554
|
+
if (system !== undefined && typeof system !== "string") {
|
|
555
|
+
throw new Error("run request config system, when provided, must be a string");
|
|
556
|
+
}
|
|
557
|
+
const prompt = parseRunRequestConfigPrompt(record.prompt);
|
|
558
|
+
const skills = parseRunRequestConfigSkills(record.skills);
|
|
559
|
+
const mcpServers = parseRunRequestConfigMcpServers(record.mcpServers);
|
|
560
|
+
return {
|
|
561
|
+
model,
|
|
562
|
+
...(system !== undefined ? { system } : {}),
|
|
563
|
+
prompt,
|
|
564
|
+
...(skills !== undefined ? { skills } : {}),
|
|
565
|
+
...(mcpServers !== undefined ? { mcpServers } : {}),
|
|
566
|
+
// environment / proxyEndpoints / metadata: passed through
|
|
567
|
+
// as-is — the BFF revalidates them via `parseRunSubmissionRequest`,
|
|
568
|
+
// so duplicating the heavyweight parsers here would mean two sources
|
|
569
|
+
// of truth. The CLI surfaces structural errors at submission time.
|
|
570
|
+
...(record.environment !== undefined
|
|
571
|
+
? { environment: record.environment }
|
|
572
|
+
: {}),
|
|
573
|
+
...(record.runtimeSize !== undefined
|
|
574
|
+
? { runtimeSize: record.runtimeSize }
|
|
575
|
+
: {}),
|
|
576
|
+
...(record.timeout !== undefined
|
|
577
|
+
? { timeout: record.timeout }
|
|
578
|
+
: {}),
|
|
579
|
+
...(record.proxyEndpoints !== undefined
|
|
580
|
+
? { proxyEndpoints: record.proxyEndpoints }
|
|
581
|
+
: {}),
|
|
582
|
+
...(record.metadata !== undefined
|
|
583
|
+
? { metadata: record.metadata }
|
|
584
|
+
: {})
|
|
585
|
+
};
|
|
586
|
+
}
|
|
587
|
+
function parseRunRequestConfigPrompt(value) {
|
|
588
|
+
if (typeof value === "string") {
|
|
589
|
+
if (value.length === 0) {
|
|
590
|
+
throw new Error("run request config prompt must be a non-empty string");
|
|
591
|
+
}
|
|
592
|
+
return value;
|
|
593
|
+
}
|
|
594
|
+
if (Array.isArray(value)) {
|
|
595
|
+
const arr = [];
|
|
596
|
+
for (let i = 0; i < value.length; i++) {
|
|
597
|
+
const item = value[i];
|
|
598
|
+
if (typeof item !== "string" || item.length === 0) {
|
|
599
|
+
throw new Error(`run request config prompt[${i}] must be a non-empty string`);
|
|
600
|
+
}
|
|
601
|
+
arr.push(item);
|
|
602
|
+
}
|
|
603
|
+
if (arr.length === 0) {
|
|
604
|
+
throw new Error("run request config prompt must be a non-empty string or array of strings");
|
|
605
|
+
}
|
|
606
|
+
return arr;
|
|
607
|
+
}
|
|
608
|
+
throw new Error("run request config prompt must be a string or array of strings");
|
|
609
|
+
}
|
|
610
|
+
function parseRunRequestConfigSkills(value) {
|
|
611
|
+
if (value === undefined) {
|
|
612
|
+
return undefined;
|
|
613
|
+
}
|
|
614
|
+
if (!Array.isArray(value)) {
|
|
615
|
+
throw new Error("run request config skills must be an array");
|
|
616
|
+
}
|
|
617
|
+
return value.map((item, index) => parseSkillRef(item, `run request config skills[${index}]`));
|
|
618
|
+
}
|
|
619
|
+
function parseRunRequestConfigMcpServers(value) {
|
|
620
|
+
if (value === undefined) {
|
|
621
|
+
return undefined;
|
|
622
|
+
}
|
|
623
|
+
if (!Array.isArray(value)) {
|
|
624
|
+
throw new Error("run request config mcpServers must be an array");
|
|
625
|
+
}
|
|
626
|
+
const seen = new Set();
|
|
627
|
+
return value.map((item, index) => {
|
|
628
|
+
const entry = parseRunConfigMcpServerRef(item, `run request config mcpServers[${index}]`);
|
|
629
|
+
if (seen.has(entry.name)) {
|
|
630
|
+
throw new Error(`run request config mcpServers duplicate name: ${entry.name}`);
|
|
631
|
+
}
|
|
632
|
+
seen.add(entry.name);
|
|
633
|
+
return entry;
|
|
634
|
+
});
|
|
635
|
+
}
|
|
636
|
+
export function normaliseRunRequestConfig(config) {
|
|
637
|
+
const prompt = typeof config.prompt === "string" ? [config.prompt] : config.prompt;
|
|
638
|
+
const skills = config.skills ?? [];
|
|
639
|
+
const mcpServers = [];
|
|
640
|
+
const mcpServerSecrets = [];
|
|
641
|
+
for (const entry of config.mcpServers ?? []) {
|
|
642
|
+
mcpServers.push({ name: entry.name, url: entry.url });
|
|
643
|
+
if (entry.headers !== undefined) {
|
|
644
|
+
mcpServerSecrets.push({ name: entry.name, url: entry.url, headers: entry.headers });
|
|
645
|
+
}
|
|
646
|
+
}
|
|
647
|
+
return {
|
|
648
|
+
model: config.model,
|
|
649
|
+
...(config.system !== undefined ? { system: config.system } : {}),
|
|
650
|
+
prompt,
|
|
651
|
+
skills,
|
|
652
|
+
mcpServers,
|
|
653
|
+
...(config.environment !== undefined ? { environment: config.environment } : {}),
|
|
654
|
+
...(config.proxyEndpoints !== undefined ? { proxyEndpoints: config.proxyEndpoints } : {}),
|
|
655
|
+
...(config.metadata !== undefined ? { metadata: config.metadata } : {}),
|
|
656
|
+
mcpServerSecrets
|
|
657
|
+
};
|
|
658
|
+
}
|
|
659
|
+
//# sourceMappingURL=run-config.js.map
|