@appstrate/afps-shared 0.6.0 → 0.8.0
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 +3 -2
- package/src/companion-files.ts +351 -3
- package/src/file-field.ts +64 -16
- package/src/guarded-fetch.ts +87 -7
- package/src/semver-resolve.ts +2 -2
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.8.0",
|
|
4
4
|
"description": "Zero-dependency AFPS helpers shared by @appstrate/core and @appstrate/afps-runtime (companion-file checks, semver resolution, SRI integrity, credential templates, delivery.http projection)",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
@@ -56,7 +56,8 @@
|
|
|
56
56
|
"dependencies": {
|
|
57
57
|
"@types/semver": "^7.8.0",
|
|
58
58
|
"fflate": "^0.8.3",
|
|
59
|
-
"semver": "^7.8.4"
|
|
59
|
+
"semver": "^7.8.4",
|
|
60
|
+
"yaml": "^2.9.0"
|
|
60
61
|
},
|
|
61
62
|
"peerDependencies": {
|
|
62
63
|
"typescript": ">=5 <8"
|
package/src/companion-files.ts
CHANGED
|
@@ -17,6 +17,8 @@
|
|
|
17
17
|
* `bundle/validate-bundle.ts`; it is not a package subpath either.
|
|
18
18
|
*/
|
|
19
19
|
|
|
20
|
+
import { parse as parseYaml } from "yaml";
|
|
21
|
+
|
|
20
22
|
/**
|
|
21
23
|
* Stable, machine-readable companion-file violation reasons.
|
|
22
24
|
*/
|
|
@@ -24,7 +26,11 @@ export type CompanionViolationReason =
|
|
|
24
26
|
| "AGENT_MISSING_PROMPT"
|
|
25
27
|
| "AGENT_EMPTY_PROMPT"
|
|
26
28
|
| "SKILL_MISSING_SKILL_MD"
|
|
29
|
+
| "SKILL_INVALID_FRONTMATTER"
|
|
27
30
|
| "SKILL_MISSING_FRONTMATTER_NAME"
|
|
31
|
+
| "SKILL_INVALID_FRONTMATTER_NAME"
|
|
32
|
+
| "SKILL_MISSING_FRONTMATTER_DESCRIPTION"
|
|
33
|
+
| "SKILL_INVALID_FRONTMATTER_DESCRIPTION"
|
|
28
34
|
| "MCP_SERVER_MISSING_ENTRY_POINT";
|
|
29
35
|
|
|
30
36
|
/**
|
|
@@ -77,8 +83,10 @@ export function companionFilesFromRecord(files: Record<string, Uint8Array>): Com
|
|
|
77
83
|
*
|
|
78
84
|
* The check is intentionally minimal and presence-focused:
|
|
79
85
|
* - `agent` → `prompt.md` present at root, non-empty bytes (§3.2).
|
|
80
|
-
* - `skill` → `SKILL.md` present at root, with YAML frontmatter `name`
|
|
81
|
-
* (§3.3). Missing `description` is tolerated
|
|
86
|
+
* - `skill` → `SKILL.md` present at root, with a YAML frontmatter `name`
|
|
87
|
+
* (§3.3). Missing `description` is tolerated here: this function is the
|
|
88
|
+
* LOADER-side gate. The stricter producer-side rule lives in
|
|
89
|
+
* {@link checkSkillMarkdown}.
|
|
82
90
|
* - `mcp-server` → file at `manifest.server.entry_point` present in the
|
|
83
91
|
* archive (§3.4 "self-contained — every runtime dep bundled").
|
|
84
92
|
* - `integration` → no required companion (§3.5).
|
|
@@ -122,6 +130,13 @@ export function checkCompanionFiles(
|
|
|
122
130
|
path: "SKILL.md",
|
|
123
131
|
};
|
|
124
132
|
}
|
|
133
|
+
// LENIENT on purpose, and deliberately NOT the shared parser — see
|
|
134
|
+
// {@link checkSkillMarkdown}. This function runs on the LOADER side too
|
|
135
|
+
// (`extractRootFromAfps` → the run launcher's package catalog), where the
|
|
136
|
+
// archive is an already-published, immutable artifact. Its acceptance set
|
|
137
|
+
// must never shrink: anything it rejects is an agent that stops running
|
|
138
|
+
// for a defect nobody can fix in place. `hasFrontmatterName` is therefore
|
|
139
|
+
// frozen — the permissive substring probe, unchanged.
|
|
125
140
|
if (!hasFrontmatterName(new TextDecoder().decode(bytes))) {
|
|
126
141
|
return {
|
|
127
142
|
reason: "SKILL_MISSING_FRONTMATTER_NAME",
|
|
@@ -169,6 +184,23 @@ function isEffectivelyEmpty(bytes: Uint8Array): boolean {
|
|
|
169
184
|
return true;
|
|
170
185
|
}
|
|
171
186
|
|
|
187
|
+
/**
|
|
188
|
+
* LOADER-side name probe. FROZEN — byte-for-byte what it was before the
|
|
189
|
+
* producer-side rule existed, and deliberately NOT routed through
|
|
190
|
+
* {@link parseSkillFrontmatter}.
|
|
191
|
+
*
|
|
192
|
+
* The distinction that forces the duplication: this probe decides whether an
|
|
193
|
+
* ALREADY-PUBLISHED bundle loads, and a published bundle is immutable. Any
|
|
194
|
+
* input it used to accept and would now reject is an agent that stops running
|
|
195
|
+
* for a defect nobody can fix in place. The shared parser reads only column-0
|
|
196
|
+
* keys — correct for authoring, but it would newly reject ` name: triage`,
|
|
197
|
+
* `metadata:\n name: triage` and `skill_name: triage`, all of which this
|
|
198
|
+
* substring probe accepts and some published artifact may well contain.
|
|
199
|
+
*
|
|
200
|
+
* So its acceptance set may never shrink. Nothing here is to be "unified" with
|
|
201
|
+
* the parser below: they answer different questions for different sides of the
|
|
202
|
+
* artifact lifecycle.
|
|
203
|
+
*/
|
|
172
204
|
function hasFrontmatterName(content: string): boolean {
|
|
173
205
|
const fmMatch = content.match(/^---[^\S\n]*\n([\s\S]*?)\n---/);
|
|
174
206
|
if (!fmMatch) return false;
|
|
@@ -177,8 +209,324 @@ function hasFrontmatterName(content: string): boolean {
|
|
|
177
209
|
if (!nameMatch) return false;
|
|
178
210
|
const raw = (nameMatch[1] ?? "").trim();
|
|
179
211
|
if (raw.length === 0) return false;
|
|
180
|
-
// Strip surrounding quotes
|
|
212
|
+
// Strip surrounding quotes, the way a YAML quoted scalar would be read.
|
|
181
213
|
const unquoted = /^(['"])(.*)\1$/.exec(raw);
|
|
182
214
|
const value = unquoted ? unquoted[2] : raw;
|
|
183
215
|
return (value ?? "").trim().length > 0;
|
|
184
216
|
}
|
|
217
|
+
|
|
218
|
+
// ─────────────────────────────────────────────
|
|
219
|
+
// SKILL.md YAML frontmatter — the PRODUCER-side parser
|
|
220
|
+
// ─────────────────────────────────────────────
|
|
221
|
+
|
|
222
|
+
/**
|
|
223
|
+
* Maximum length of a skill frontmatter `name`, in Unicode CODE POINTS (Agent
|
|
224
|
+
* Skills specification, https://agentskills.io/specification).
|
|
225
|
+
*/
|
|
226
|
+
export const SKILL_NAME_MAX_LENGTH = 64;
|
|
227
|
+
|
|
228
|
+
/**
|
|
229
|
+
* Maximum length of a skill frontmatter `description`, in Unicode CODE POINTS
|
|
230
|
+
* — the Agent Skills specification (https://agentskills.io/specification)
|
|
231
|
+
* counts characters, and a code point is what "character" means there.
|
|
232
|
+
*
|
|
233
|
+
* NOT a parity constant: Pi measures `description.length`, i.e. UTF-16 units,
|
|
234
|
+
* and only emits a warning past the bound rather than dropping the skill. The
|
|
235
|
+
* platform enforces the spec instead, because the artifact it mints is
|
|
236
|
+
* immutable and other consumers (Codex, Claude Code) are not so forgiving.
|
|
237
|
+
*/
|
|
238
|
+
export const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
|
|
239
|
+
|
|
240
|
+
/**
|
|
241
|
+
* Agent Skills `name` rule: lowercase `a-z`, `0-9` and `-` only, no leading or
|
|
242
|
+
* trailing hyphen, no consecutive hyphens. Length is checked separately
|
|
243
|
+
* against {@link SKILL_NAME_MAX_LENGTH} so the violation message can name the
|
|
244
|
+
* bound.
|
|
245
|
+
*
|
|
246
|
+
* NOT the same namespace as a package id. A package id is `@scope/name`
|
|
247
|
+
* validated by `SLUG_PATTERN` (`@appstrate/core/naming`) — unbounded in
|
|
248
|
+
* length and tolerant of `--`. The frontmatter `name` is the BARE skill slug
|
|
249
|
+
* an agent runtime addresses (`triage`, never `@acme/triage`), and it is the
|
|
250
|
+
* Agent Skills rule that governs it. The two are deliberately different and
|
|
251
|
+
* neither validator may be substituted for the other.
|
|
252
|
+
*/
|
|
253
|
+
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
254
|
+
|
|
255
|
+
/** Length in Unicode code points — `"🙂".length` is 2, this returns 1. */
|
|
256
|
+
function codePointLength(value: string): number {
|
|
257
|
+
return [...value].length;
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Does `name` satisfy the Agent Skills `name` rule (shape AND length)? */
|
|
261
|
+
export function isValidSkillName(name: string): boolean {
|
|
262
|
+
return codePointLength(name) <= SKILL_NAME_MAX_LENGTH && SKILL_NAME_PATTERN.test(name);
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Parsed `SKILL.md` YAML frontmatter. */
|
|
266
|
+
export interface SkillFrontmatter {
|
|
267
|
+
/** Whether a closed `--- … ---` frontmatter block was found. */
|
|
268
|
+
found: boolean;
|
|
269
|
+
/**
|
|
270
|
+
* True when the document opens with `---` but never closes the block. Told
|
|
271
|
+
* apart from "no frontmatter at all" so the author gets the actual fault.
|
|
272
|
+
*/
|
|
273
|
+
unterminated: boolean;
|
|
274
|
+
/**
|
|
275
|
+
* Why the block could not be read as `{ name, description }` — a YAML syntax
|
|
276
|
+
* error, a document that is not a mapping, or a field that is not a string.
|
|
277
|
+
* `null` when the block parsed cleanly. Never thrown: `extractSkillMeta`'s
|
|
278
|
+
* contract is to degrade to empty fields plus a warning.
|
|
279
|
+
*/
|
|
280
|
+
error: string | null;
|
|
281
|
+
/** Frontmatter `name`, or `""` when absent/blank. */
|
|
282
|
+
name: string;
|
|
283
|
+
/** Frontmatter `description`, or `""` when absent/blank. */
|
|
284
|
+
description: string;
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
/**
|
|
288
|
+
* Parse the `name` / `description` of a `SKILL.md` YAML frontmatter block.
|
|
289
|
+
*
|
|
290
|
+
* PARITY WITH THE CONSUMER IS THE POINT. The runtime that actually loads a
|
|
291
|
+
* skill — `@earendil-works/pi-coding-agent`, `dist/utils/frontmatter.js` —
|
|
292
|
+
* normalises newlines, requires the document to start with `---`, cuts the
|
|
293
|
+
* block at the first `\n---`, and hands the slice to `yaml`'s `parse`. This
|
|
294
|
+
* function does the same, against the same library at the same major, so the
|
|
295
|
+
* gate cannot accept a document the consumer then fails to PARSE.
|
|
296
|
+
*
|
|
297
|
+
* Parsing is where parity is exact. The RULES are not symmetric, on purpose:
|
|
298
|
+
* Pi only WARNS on a name or description that breaks the Agent Skills spec
|
|
299
|
+
* (and measures the description in UTF-16 units, so `"🙂".length` is 2), while
|
|
300
|
+
* this gate refuses it. The bounds below therefore follow the SPEC — which
|
|
301
|
+
* says characters, hence code points — not Pi's warning threshold. Being
|
|
302
|
+
* stricter than the consumer is safe: it costs an author one edit. Being
|
|
303
|
+
* looser is not: it mints an immutable artifact the consumer drops.
|
|
304
|
+
*
|
|
305
|
+
* The hand-rolled scanner this replaces got that wrong in both directions: it
|
|
306
|
+
* accepted `description: a: b` and `name:x`, which `yaml` refuses outright,
|
|
307
|
+
* and it had to re-implement block scalars, quoted escapes, comments,
|
|
308
|
+
* continuation lines and duplicate detection — each an opportunity to diverge
|
|
309
|
+
* from a spec the library already implements. `uniqueKeys` and `strict` are
|
|
310
|
+
* passed explicitly rather than left to their defaults so a future default
|
|
311
|
+
* change cannot loosen the gate in silence.
|
|
312
|
+
*
|
|
313
|
+
* NOTHING is normalised away that the runtime keeps — a leading BOM above all.
|
|
314
|
+
* Pi tests `normalized.startsWith("---")`, which a BOM defeats, so it reads no
|
|
315
|
+
* frontmatter at all and drops the skill. Stripping the BOM here would make
|
|
316
|
+
* this function read a name and description the runtime never sees, which is
|
|
317
|
+
* exactly the divergence it exists to prevent; {@link checkSkillMarkdown}
|
|
318
|
+
* refuses such a file instead.
|
|
319
|
+
*/
|
|
320
|
+
export function parseSkillFrontmatter(content: string): SkillFrontmatter {
|
|
321
|
+
const empty = { found: false, unterminated: false, error: null, name: "", description: "" };
|
|
322
|
+
|
|
323
|
+
const normalized = content.replace(/\r\n/g, "\n").replace(/\r/g, "\n");
|
|
324
|
+
if (!normalized.startsWith("---")) return empty;
|
|
325
|
+
const endIndex = normalized.indexOf("\n---", 3);
|
|
326
|
+
if (endIndex === -1) return { ...empty, unterminated: true };
|
|
327
|
+
|
|
328
|
+
const block = normalized.slice(4, endIndex);
|
|
329
|
+
|
|
330
|
+
let parsed: unknown;
|
|
331
|
+
try {
|
|
332
|
+
parsed = parseYaml(block, { uniqueKeys: true, strict: true });
|
|
333
|
+
} catch (err) {
|
|
334
|
+
return {
|
|
335
|
+
...empty,
|
|
336
|
+
found: true,
|
|
337
|
+
error: `frontmatter is not valid YAML: ${firstLine(err)}`,
|
|
338
|
+
};
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
// An empty block yields `null`; the runtime coerces that to `{}` and so do
|
|
342
|
+
// we, which makes it a missing NAME rather than a malformed document.
|
|
343
|
+
const mapping = parsed ?? {};
|
|
344
|
+
if (typeof mapping !== "object" || Array.isArray(mapping)) {
|
|
345
|
+
return {
|
|
346
|
+
...empty,
|
|
347
|
+
found: true,
|
|
348
|
+
error: "frontmatter is not valid YAML: expected a mapping of keys to values",
|
|
349
|
+
};
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
const record = mapping as Record<string, unknown>;
|
|
353
|
+
const name = readStringField(record, "name");
|
|
354
|
+
if (typeof name !== "string") return { ...empty, found: true, error: name.error };
|
|
355
|
+
const description = readStringField(record, "description");
|
|
356
|
+
if (typeof description !== "string") return { ...empty, found: true, error: description.error };
|
|
357
|
+
|
|
358
|
+
return { found: true, unterminated: false, error: null, name, description };
|
|
359
|
+
}
|
|
360
|
+
|
|
361
|
+
/**
|
|
362
|
+
* Read one frontmatter field as a trimmed string.
|
|
363
|
+
*
|
|
364
|
+
* An ABSENT key and an empty YAML scalar (`description:`, which parses to
|
|
365
|
+
* `null`, as does an explicit `description: null`) are indistinguishable once
|
|
366
|
+
* parsed and both mean "not provided" — they yield `""` so the caller reports
|
|
367
|
+
* the field as MISSING. Any other non-string (a number, a boolean, a list, a
|
|
368
|
+
* nested mapping) is a malformed document, not a missing field.
|
|
369
|
+
*/
|
|
370
|
+
function readStringField(record: Record<string, unknown>, key: string): string | { error: string } {
|
|
371
|
+
const value = record[key];
|
|
372
|
+
if (value === undefined || value === null) return "";
|
|
373
|
+
if (typeof value !== "string") {
|
|
374
|
+
return {
|
|
375
|
+
error: `frontmatter is not valid YAML: '${key}' must be a string, got ${describeType(value)}`,
|
|
376
|
+
};
|
|
377
|
+
}
|
|
378
|
+
return value.trim();
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function describeType(value: unknown): string {
|
|
382
|
+
if (Array.isArray(value)) return "a list";
|
|
383
|
+
if (typeof value === "object") return "a mapping";
|
|
384
|
+
return `a ${typeof value}`;
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
/** Does the text begin with a UTF-8 byte-order mark? */
|
|
388
|
+
function startsWithBom(content: string): boolean {
|
|
389
|
+
return content.charCodeAt(0) === 0xfeff;
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
/**
|
|
393
|
+
* Decode `SKILL.md` bytes for {@link checkSkillMarkdown}.
|
|
394
|
+
*
|
|
395
|
+
* `ignoreBOM: true` is the whole point, and its name is backwards: it means
|
|
396
|
+
* "do not CONSUME the BOM", i.e. keep U+FEFF as a character. A default
|
|
397
|
+
* `TextDecoder` silently swallows it, so a write path that decoded stored or
|
|
398
|
+
* archived bytes the ordinary way would hand the gate a BOM-free string, pass
|
|
399
|
+
* it, and freeze bytes the runtime cannot read. Every write path that starts
|
|
400
|
+
* from bytes rather than from a request body decodes through THIS function.
|
|
401
|
+
*/
|
|
402
|
+
export function decodeSkillMarkdown(bytes: Uint8Array): string {
|
|
403
|
+
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
|
|
404
|
+
}
|
|
405
|
+
|
|
406
|
+
/** First line of an error message — YAML errors carry a multi-line excerpt. */
|
|
407
|
+
function firstLine(err: unknown): string {
|
|
408
|
+
const message = err instanceof Error ? err.message : String(err);
|
|
409
|
+
return message.split("\n")[0]!.trim();
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
// ─────────────────────────────────────────────
|
|
413
|
+
// SKILL.md — the PRODUCER-side gate
|
|
414
|
+
// ─────────────────────────────────────────────
|
|
415
|
+
|
|
416
|
+
/**
|
|
417
|
+
* Validate a `SKILL.md`'s frontmatter against the FULL AFPS §3.3 rule, as a
|
|
418
|
+
* producer must: a `name` conforming to the Agent Skills specification
|
|
419
|
+
* (https://agentskills.io/specification) and a non-empty `description` of at
|
|
420
|
+
* most {@link SKILL_DESCRIPTION_MAX_LENGTH} code points. Returns the first
|
|
421
|
+
* violation, or `null`.
|
|
422
|
+
*
|
|
423
|
+
* WHY THIS IS SEPARATE FROM {@link checkCompanionFiles}. That function runs on
|
|
424
|
+
* both sides of the artifact lifecycle, and the loader side reads
|
|
425
|
+
* already-published, immutable bundles: a skill published before this rule
|
|
426
|
+
* existed must keep loading, or every run of an agent that depends on it fails
|
|
427
|
+
* at launch for a defect nobody can now fix in place. So the rule is applied
|
|
428
|
+
* at the moment content is AUTHORED — editor create, draft save, publish,
|
|
429
|
+
* restore, fork, and the ROOT of every import — and never at load. AFPS spells
|
|
430
|
+
* both fields SHOULD; the platform mints these artifacts and holds itself to
|
|
431
|
+
* MUST.
|
|
432
|
+
*
|
|
433
|
+
* Takes the decoded text, not a file source: every write path already has the
|
|
434
|
+
* `SKILL.md` in hand (a request body, a draft column, a ZIP entry), and
|
|
435
|
+
* "is SKILL.md present at all" is `checkCompanionFiles`'s question.
|
|
436
|
+
*/
|
|
437
|
+
export function checkSkillMarkdown(content: string): CompanionFileViolation | null {
|
|
438
|
+
// FIRST, because everything below reads a document the runtime cannot: Pi's
|
|
439
|
+
// `parseFrontmatter` tests `startsWith("---")`, which a BOM defeats, so it
|
|
440
|
+
// reads an empty frontmatter and `loadSkillFromFile` returns `skill: null` —
|
|
441
|
+
// the skill is silently dropped at run time. The platform's own loader is
|
|
442
|
+
// more forgiving (it decodes archive bytes through a default `TextDecoder`,
|
|
443
|
+
// which eats the BOM), so nothing downstream would have complained: the
|
|
444
|
+
// version would be minted, immutable, and simply never load in the agent.
|
|
445
|
+
//
|
|
446
|
+
// Rejected rather than stripped: silently rewriting an author's bytes would
|
|
447
|
+
// make the artifact disagree with the file they wrote.
|
|
448
|
+
if (startsWithBom(content)) {
|
|
449
|
+
return {
|
|
450
|
+
reason: "SKILL_INVALID_FRONTMATTER",
|
|
451
|
+
message:
|
|
452
|
+
"skill SKILL.md starts with a byte-order mark (U+FEFF); remove it — " +
|
|
453
|
+
"the runtime cannot read frontmatter behind a BOM",
|
|
454
|
+
path: "SKILL.md",
|
|
455
|
+
};
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
const { unterminated, error, name, description } = parseSkillFrontmatter(content);
|
|
459
|
+
|
|
460
|
+
if (unterminated) {
|
|
461
|
+
return {
|
|
462
|
+
reason: "SKILL_MISSING_FRONTMATTER_NAME",
|
|
463
|
+
message: "skill SKILL.md frontmatter block is not closed (expected a second '---' line)",
|
|
464
|
+
path: "SKILL.md",
|
|
465
|
+
};
|
|
466
|
+
}
|
|
467
|
+
if (error) {
|
|
468
|
+
return {
|
|
469
|
+
reason: "SKILL_INVALID_FRONTMATTER",
|
|
470
|
+
message: `skill SKILL.md ${error}`,
|
|
471
|
+
path: "SKILL.md",
|
|
472
|
+
};
|
|
473
|
+
}
|
|
474
|
+
if (!name) {
|
|
475
|
+
return {
|
|
476
|
+
reason: "SKILL_MISSING_FRONTMATTER_NAME",
|
|
477
|
+
message: "skill SKILL.md must declare a 'name' in YAML frontmatter",
|
|
478
|
+
path: "SKILL.md",
|
|
479
|
+
};
|
|
480
|
+
}
|
|
481
|
+
if (!isValidSkillName(name)) {
|
|
482
|
+
return {
|
|
483
|
+
reason: "SKILL_INVALID_FRONTMATTER_NAME",
|
|
484
|
+
message:
|
|
485
|
+
`skill SKILL.md 'name' must be 1-${SKILL_NAME_MAX_LENGTH} characters of lowercase ` +
|
|
486
|
+
`a-z, 0-9 and '-', with no leading or trailing hyphen and no consecutive hyphens ` +
|
|
487
|
+
`(got '${name}')`,
|
|
488
|
+
path: "SKILL.md",
|
|
489
|
+
};
|
|
490
|
+
}
|
|
491
|
+
if (!description) {
|
|
492
|
+
return {
|
|
493
|
+
reason: "SKILL_MISSING_FRONTMATTER_DESCRIPTION",
|
|
494
|
+
message: "skill SKILL.md must declare a non-empty 'description' in YAML frontmatter",
|
|
495
|
+
path: "SKILL.md",
|
|
496
|
+
};
|
|
497
|
+
}
|
|
498
|
+
const descriptionLength = codePointLength(description);
|
|
499
|
+
if (descriptionLength > SKILL_DESCRIPTION_MAX_LENGTH) {
|
|
500
|
+
return {
|
|
501
|
+
reason: "SKILL_INVALID_FRONTMATTER_DESCRIPTION",
|
|
502
|
+
message:
|
|
503
|
+
`skill SKILL.md 'description' must be at most ${SKILL_DESCRIPTION_MAX_LENGTH} ` +
|
|
504
|
+
`characters (got ${descriptionLength})`,
|
|
505
|
+
path: "SKILL.md",
|
|
506
|
+
};
|
|
507
|
+
}
|
|
508
|
+
|
|
509
|
+
// CONTAINMENT: what this gate accepts must be a SUBSET of what the loader
|
|
510
|
+
// accepts. The two read the frontmatter differently on purpose — a real YAML
|
|
511
|
+
// parser here, a frozen substring probe there — and YAML is the more
|
|
512
|
+
// permissive of the two: `name:\n triage`, `name : triage` and a
|
|
513
|
+
// BOM-prefixed document are all valid YAML that `hasFrontmatterName` cannot
|
|
514
|
+
// see. Without this check, create and publish would mint an IMMUTABLE version
|
|
515
|
+
// the run launcher then refuses to load — the one failure mode this whole
|
|
516
|
+
// split exists to prevent, and unfixable once published.
|
|
517
|
+
//
|
|
518
|
+
// Stated as a check rather than by loosening the loader, because the loader's
|
|
519
|
+
// acceptance set may never shrink, and rather than by tightening the parser,
|
|
520
|
+
// because the parser must keep matching the runtime.
|
|
521
|
+
if (!hasFrontmatterName(content)) {
|
|
522
|
+
return {
|
|
523
|
+
reason: "SKILL_INVALID_FRONTMATTER_NAME",
|
|
524
|
+
message:
|
|
525
|
+
`skill SKILL.md 'name' must be written inline on one line, e.g. "name: my-skill" ` +
|
|
526
|
+
`(a name on a following line, or a space before the colon, makes the platform's ` +
|
|
527
|
+
`package loader unable to read it)`,
|
|
528
|
+
path: "SKILL.md",
|
|
529
|
+
};
|
|
530
|
+
}
|
|
531
|
+
return null;
|
|
532
|
+
}
|
package/src/file-field.ts
CHANGED
|
@@ -2,9 +2,8 @@
|
|
|
2
2
|
// SPDX-License-Identifier: Apache-2.0
|
|
3
3
|
|
|
4
4
|
/**
|
|
5
|
-
* Canonical AFPS file-field predicate
|
|
6
|
-
*
|
|
7
|
-
* `@appstrate/afps-runtime`'s platform-prompt composer.
|
|
5
|
+
* Canonical AFPS file-field predicate, used by `@appstrate/afps-runtime`'s
|
|
6
|
+
* platform-prompt composer.
|
|
8
7
|
*
|
|
9
8
|
* AFPS file fields are JSON Schema string nodes carrying `format: "uri"` plus a
|
|
10
9
|
* `contentMediaType` (single file), or an array whose `items` are such nodes
|
|
@@ -17,10 +16,46 @@
|
|
|
17
16
|
* Accepts a permissive `unknown` input narrowed internally so both the
|
|
18
17
|
* JSONSchema7-typed core call site and the `unknown`-typed runtime call site
|
|
19
18
|
* compile against one definition.
|
|
19
|
+
*
|
|
20
|
+
* ── `@appstrate/core/form` HAS A PARALLEL COPY OF THIS RULE, ON PURPOSE ──
|
|
21
|
+
* `isMultipleFileField` is new in THIS version (0.7.0), which is not on npm
|
|
22
|
+
* yet; the newest published release, 0.6.0, exports `isFileField` from this
|
|
23
|
+
* subpath and nothing else. `@appstrate/core` ships as source, so a consumer's
|
|
24
|
+
* `tsc` compiles core's files against the `@appstrate/afps-shared` their own
|
|
25
|
+
* install resolves — importing `isMultipleFileField` from here typechecks in
|
|
26
|
+
* this workspace and cannot resolve for them. Core therefore carries its own
|
|
27
|
+
* copy, derived from the same single-file-node rule; `packages/core/test/
|
|
28
|
+
* form.test.ts` asserts the two agree table-wide, so a change made HERE and not
|
|
29
|
+
* there (or vice versa) fails that test.
|
|
30
|
+
*
|
|
31
|
+
* Core has ALREADY raised its floor to `^0.7.0`, so the remaining step is the
|
|
32
|
+
* publish (`git tag afps-shared@0.7.0`); after it lands, core's copy is
|
|
33
|
+
* replaced by an import of the two predicates. Not before.
|
|
34
|
+
*
|
|
35
|
+
* The helpers below (`asNode`, `isSingleFileNode`, `resolveItems`,
|
|
36
|
+
* `resolveType`) are deliberately NOT exported and are not part of that plan.
|
|
37
|
+
* They are implementation detail of the two predicates, they have no importer
|
|
38
|
+
* anywhere, and every name this package exports is a semver commitment to
|
|
39
|
+
* out-of-tree consumers that only a breaking release can take back.
|
|
20
40
|
*/
|
|
21
41
|
|
|
22
|
-
/**
|
|
23
|
-
function
|
|
42
|
+
/** Narrow an `unknown` schema node to an indexable object, or `undefined`. */
|
|
43
|
+
function asNode(schema: unknown): Record<string, unknown> | undefined {
|
|
44
|
+
return schema && typeof schema === "object" ? (schema as Record<string, unknown>) : undefined;
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
/**
|
|
48
|
+
* A single file field: `format: "uri"` + a DECLARED `contentMediaType`.
|
|
49
|
+
*
|
|
50
|
+
* "Declared" is `!= null && !== false`, deliberately NOT truthiness: the
|
|
51
|
+
* keyword's presence is what marks the field as a file, and whether its value
|
|
52
|
+
* is a well-formed media type is the manifest validator's job, not this
|
|
53
|
+
* predicate's. `contentMediaType: ""` is therefore a file field — the same
|
|
54
|
+
* reading `apps/api/src/services/inline-run.ts` documents and relies on.
|
|
55
|
+
*/
|
|
56
|
+
function isSingleFileNode(schema: unknown): boolean {
|
|
57
|
+
const node = asNode(schema);
|
|
58
|
+
if (!node) return false;
|
|
24
59
|
return node.format === "uri" && node.contentMediaType != null && node.contentMediaType !== false;
|
|
25
60
|
}
|
|
26
61
|
|
|
@@ -28,8 +63,9 @@ function isSingleFileNode(node: Record<string, unknown>): boolean {
|
|
|
28
63
|
* Resolve a node's `items` schema, handling the JSON Schema boolean / tuple
|
|
29
64
|
* forms (`items: false` → none; `items: [first, …]` → first object entry).
|
|
30
65
|
*/
|
|
31
|
-
function resolveItems(
|
|
32
|
-
const
|
|
66
|
+
function resolveItems(schema: unknown): Record<string, unknown> | undefined {
|
|
67
|
+
const node = asNode(schema);
|
|
68
|
+
const items = node?.items;
|
|
33
69
|
if (!items || typeof items === "boolean") return undefined;
|
|
34
70
|
if (Array.isArray(items)) {
|
|
35
71
|
const first = items[0];
|
|
@@ -39,7 +75,10 @@ function resolveItems(node: Record<string, unknown>): Record<string, unknown> |
|
|
|
39
75
|
return undefined;
|
|
40
76
|
}
|
|
41
77
|
|
|
42
|
-
|
|
78
|
+
/** Resolve a node's `type` (JSON Schema allows a union array — first wins). */
|
|
79
|
+
function resolveType(schema: unknown): string | undefined {
|
|
80
|
+
const node = asNode(schema);
|
|
81
|
+
if (!node) return undefined;
|
|
43
82
|
if (typeof node.type === "string") return node.type;
|
|
44
83
|
if (Array.isArray(node.type) && node.type.length > 0 && typeof node.type[0] === "string") {
|
|
45
84
|
return node.type[0];
|
|
@@ -52,12 +91,21 @@ function resolveType(node: Record<string, unknown>): string | undefined {
|
|
|
52
91
|
* OR an array whose items are such a node.
|
|
53
92
|
*/
|
|
54
93
|
export function isFileField(schema: unknown): boolean {
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
94
|
+
return isSingleFileNode(schema) || isMultipleFileField(schema);
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
/**
|
|
98
|
+
* Detect a MULTIPLE-files field: an array whose `items` are a single file node.
|
|
99
|
+
*
|
|
100
|
+
* Shares {@link isSingleFileNode} with {@link isFileField} by construction, so
|
|
101
|
+
* the two can never disagree about the same array node — they did, when
|
|
102
|
+
* `@appstrate/core/form`'s `isMultipleFileField` tested
|
|
103
|
+
* `!!items.contentMediaType` (truthiness) against an `isFileField` that tested
|
|
104
|
+
* "declared": for `contentMediaType: ""` the field was a file field that was
|
|
105
|
+
* not multiple, and the RJSF adapter rendered a single-file widget bound to an
|
|
106
|
+
* array property. Core's copy is now derived the same way; see the header for
|
|
107
|
+
* why it is still a copy.
|
|
108
|
+
*/
|
|
109
|
+
export function isMultipleFileField(schema: unknown): boolean {
|
|
110
|
+
return resolveType(schema) === "array" && isSingleFileNode(resolveItems(schema));
|
|
63
111
|
}
|
package/src/guarded-fetch.ts
CHANGED
|
@@ -54,8 +54,51 @@
|
|
|
54
54
|
|
|
55
55
|
import { resolveAndCheckHost, type HostResolver } from "./ssrf-dns.ts";
|
|
56
56
|
|
|
57
|
+
/**
|
|
58
|
+
* Redirect hops any guarded chain will chase before giving up — the ONE budget
|
|
59
|
+
* in the codebase, and the default of both followers.
|
|
60
|
+
*
|
|
61
|
+
* It used to be two unrelated numbers: `maxRedirects ?? 5` here and a hard
|
|
62
|
+
* `MAX_REDIRECTS = 10` in `@appstrate/afps-runtime`'s credential-proxy
|
|
63
|
+
* follower, neither aware of the other. 10 is the surviving value because it is
|
|
64
|
+
* the one with a reason: the credential proxy walks multi-step OAuth/CAS dances
|
|
65
|
+
* whose session cookie lands on an intermediate 302 (#473), and five hops does
|
|
66
|
+
* not always reach the end of one. Nothing is weakened by the raise — every hop
|
|
67
|
+
* is independently DNS-checked, allowlist-checked and credential-stripped, so
|
|
68
|
+
* the cap is a loop/DoS bound, not a trust boundary.
|
|
69
|
+
*
|
|
70
|
+
* Raising a SHARED default for one caller's benefit would be a poor trade, so
|
|
71
|
+
* here is the whole roster it applies to. The callers where a longer chain
|
|
72
|
+
* would be questionable — a signed payload, an inference endpoint, a
|
|
73
|
+
* registration POST — already pin their own budget and are untouched by the
|
|
74
|
+
* value here:
|
|
75
|
+
*
|
|
76
|
+
* pinned `maxRedirects: 0`, unaffected
|
|
77
|
+
* - `apps/api/src/modules/webhooks/service.ts` — a signed delivery payload
|
|
78
|
+
* must never be re-sent to a `Location` target.
|
|
79
|
+
* - `apps/api/src/services/llm-proxy/core.ts` — an inference endpoint has
|
|
80
|
+
* no legitimate reason to redirect.
|
|
81
|
+
* - `packages/connect/src/dcr.ts` — dynamic client registration is a
|
|
82
|
+
* single POST to a discovered endpoint.
|
|
83
|
+
*
|
|
84
|
+
* on this default, and all of them multi-step credential exchanges — the
|
|
85
|
+
* exact population #473 is about
|
|
86
|
+
* - `apps/api/src/services/credential-proxy/core.ts` — the out-of-container
|
|
87
|
+
* twin of the `@appstrate/afps-runtime` follower this value came from;
|
|
88
|
+
* same vendor auth dances, and it re-runs the caller's `validateHop`
|
|
89
|
+
* allowlist assertion on every hop.
|
|
90
|
+
* - `packages/connect/src/oauth-egress.ts` — OAuth discovery, token
|
|
91
|
+
* exchange and userinfo.
|
|
92
|
+
* - `apps/api/src/services/integration-connections.ts` — OAuth
|
|
93
|
+
* protected-resource metadata discovery.
|
|
94
|
+
* - `apps/api/src/services/org-models.ts` — model-catalog probes.
|
|
95
|
+
* - `runtime-pi/sidecar/integrations-boot.ts` — remote MCP transport
|
|
96
|
+
* egress.
|
|
97
|
+
*/
|
|
98
|
+
export const DEFAULT_MAX_REDIRECTS = 10;
|
|
99
|
+
|
|
57
100
|
export interface GuardedFetchOptions {
|
|
58
|
-
/** Max redirect hops to follow before giving up. Default
|
|
101
|
+
/** Max redirect hops to follow before giving up. Default {@link DEFAULT_MAX_REDIRECTS}. */
|
|
59
102
|
maxRedirects?: number;
|
|
60
103
|
/**
|
|
61
104
|
* Deadline in ms covering the redirect chain up to the final response's
|
|
@@ -188,7 +231,7 @@ export async function guardedFetch(
|
|
|
188
231
|
init?: RequestInit,
|
|
189
232
|
opts?: GuardedFetchOptions,
|
|
190
233
|
): Promise<Response> {
|
|
191
|
-
const maxRedirects = opts?.maxRedirects ??
|
|
234
|
+
const maxRedirects = opts?.maxRedirects ?? DEFAULT_MAX_REDIRECTS;
|
|
192
235
|
|
|
193
236
|
let current = stripUserInfoAndFragment(new URL(typeof input === "string" ? input : input.href));
|
|
194
237
|
assertHttp(current);
|
|
@@ -338,13 +381,50 @@ export async function guardedFetch(
|
|
|
338
381
|
}
|
|
339
382
|
}
|
|
340
383
|
|
|
341
|
-
// Standard redirect method/body rewriting
|
|
342
|
-
//
|
|
343
|
-
//
|
|
344
|
-
|
|
345
|
-
|
|
384
|
+
// Standard redirect method/body rewriting, per WHATWG fetch
|
|
385
|
+
// (HTTP-redirect fetch step 11) + RFC 9110 §15.4:
|
|
386
|
+
// - 301/302 downgrade POST → GET; every OTHER method is preserved.
|
|
387
|
+
// - 303 downgrades everything except GET/HEAD → GET.
|
|
388
|
+
// - 307/308 preserve method + body (already dropped above when
|
|
389
|
+
// crossing a host boundary).
|
|
390
|
+
// The 301/302 clause used to read `method !== "HEAD"`, which turned a
|
|
391
|
+
// 302'd PUT/PATCH/DELETE into a bodyless GET — a request the caller never
|
|
392
|
+
// made, and a silent one. `@appstrate/afps-runtime`'s credential-proxy
|
|
393
|
+
// follower always had the conformant rule; this side did not, and the
|
|
394
|
+
// two disagreed about the same response.
|
|
395
|
+
const toGet =
|
|
396
|
+
((res.status === 301 || res.status === 302) && method === "POST") ||
|
|
397
|
+
(res.status === 303 && method !== "GET" && method !== "HEAD");
|
|
398
|
+
if (toGet) {
|
|
399
|
+
method = "GET";
|
|
346
400
|
dropBody();
|
|
347
401
|
}
|
|
402
|
+
// A `ReadableStream` body is single-use: hop 0 consumed it, so any hop
|
|
403
|
+
// that PRESERVES the body is about to re-send a locked stream. The
|
|
404
|
+
// runtime answers that with an opaque `TypeError: body already used`
|
|
405
|
+
// from inside `fetch`, naming neither the redirect nor the stream.
|
|
406
|
+
//
|
|
407
|
+
// Callers CAN reach this: `apps/api/src/services/credential-proxy/core.ts`
|
|
408
|
+
// forwards its caller's request body straight through, sets
|
|
409
|
+
// `duplex: "half"` for the stream case, and takes the default redirect
|
|
410
|
+
// budget. It has always been reachable via 307/308 (which preserve
|
|
411
|
+
// method + body unconditionally); making 301/302 conformant for
|
|
412
|
+
// PUT/PATCH/DELETE widened WHICH statuses land on it, so it is named
|
|
413
|
+
// here rather than left to surface as a transport-level type error.
|
|
414
|
+
//
|
|
415
|
+
// Fail loudly instead of dropping the body: a bodyless PUT the caller
|
|
416
|
+
// never made, sent silently, is the worse outcome — that is the exact
|
|
417
|
+
// shape the 301/302 conformance fix removed. Nothing replayable is
|
|
418
|
+
// affected (string / `Uint8Array` / `FormData` bodies re-send fine), and
|
|
419
|
+
// a hop that DROPS the body (`toGet` above, or the cross-host containment
|
|
420
|
+
// below) never gets here.
|
|
421
|
+
if (body instanceof ReadableStream) {
|
|
422
|
+
throw new TypeError(
|
|
423
|
+
`guardedFetch cannot follow a ${res.status} redirect to ${next.origin}: the request ` +
|
|
424
|
+
`body is a ReadableStream and was already consumed by the previous hop. Buffer the ` +
|
|
425
|
+
`body before the call, or pass maxRedirects: 0 and handle the redirect yourself.`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
348
428
|
current = next;
|
|
349
429
|
pinnedAddress = nextPin;
|
|
350
430
|
// Drain the redirect response body so the connection can be reused.
|
package/src/semver-resolve.ts
CHANGED
|
@@ -9,8 +9,8 @@
|
|
|
9
9
|
* string, and for applying yank policy by pre-filtering the
|
|
10
10
|
* `rangeVersions` and `distTags` inputs accordingly.
|
|
11
11
|
*
|
|
12
|
-
* This is the SINGLE source of truth, re-exported by
|
|
13
|
-
*
|
|
12
|
+
* This is the SINGLE source of truth, re-exported by `@appstrate/core/semver`
|
|
13
|
+
* and imported directly by `@appstrate/afps-runtime`.
|
|
14
14
|
*
|
|
15
15
|
* Conventional yank policy (matches npm/crates.io):
|
|
16
16
|
* - `exactVersions`: include yanked (exact pins always resolve).
|