@appstrate/afps-shared 0.8.0 → 0.9.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/README.md +1 -0
- package/package.json +3 -2
- package/src/api-tool-naming.ts +6 -13
- package/src/companion-files.ts +53 -177
- package/src/jsonpath.ts +161 -0
- package/src/mcp-naming.ts +52 -10
package/README.md
CHANGED
|
@@ -35,6 +35,7 @@ import each module by subpath.
|
|
|
35
35
|
| `./file-field` | File-field parsing helpers. |
|
|
36
36
|
| `./token-usage` | Token-usage accounting shapes. |
|
|
37
37
|
| `./backoff` | Retry backoff computation. |
|
|
38
|
+
| `./jsonpath` | The single-value RFC 9535 JSONPath subset every integration-manifest path field is read with (`identity_claims`, login-engine selectors). |
|
|
38
39
|
|
|
39
40
|
```ts
|
|
40
41
|
import { guardedFetch } from "@appstrate/afps-shared/guarded-fetch";
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@appstrate/afps-shared",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.9.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",
|
|
@@ -51,7 +51,8 @@
|
|
|
51
51
|
"./unzip-bounded": "./src/unzip-bounded.ts",
|
|
52
52
|
"./archive-prefix": "./src/archive-prefix.ts",
|
|
53
53
|
"./backoff": "./src/backoff.ts",
|
|
54
|
-
"./mime": "./src/mime.ts"
|
|
54
|
+
"./mime": "./src/mime.ts",
|
|
55
|
+
"./jsonpath": "./src/jsonpath.ts"
|
|
55
56
|
},
|
|
56
57
|
"dependencies": {
|
|
57
58
|
"@types/semver": "^7.8.0",
|
package/src/api-tool-naming.ts
CHANGED
|
@@ -1,7 +1,11 @@
|
|
|
1
1
|
// SPDX-License-Identifier: Apache-2.0
|
|
2
2
|
// Copyright 2026 Appstrate
|
|
3
3
|
|
|
4
|
-
import {
|
|
4
|
+
import {
|
|
5
|
+
fnv1a64Hex,
|
|
6
|
+
MCP_TOOL_NAME_MAX_LENGTH,
|
|
7
|
+
MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH,
|
|
8
|
+
} from "./mcp-naming.ts";
|
|
5
9
|
|
|
6
10
|
/** Canonical unprefixed name of the credential-injecting API tool. */
|
|
7
11
|
export const API_CALL_TOOL_NAME = "api_call";
|
|
@@ -36,7 +40,7 @@ const API_TOOL_AUTH_HASH_HEX_LENGTH = API_TOOL_AUTH_TOKEN_LENGTH - 2;
|
|
|
36
40
|
*/
|
|
37
41
|
export function apiToolAuthToken(authKey: string): string {
|
|
38
42
|
if (authKey.length <= API_TOOL_RAW_AUTH_KEY_MAX_LENGTH) return authKey;
|
|
39
|
-
return `h0${
|
|
43
|
+
return `h0${fnv1a64Hex(authKey).slice(0, API_TOOL_AUTH_HASH_HEX_LENGTH)}`;
|
|
40
44
|
}
|
|
41
45
|
|
|
42
46
|
/** Throw when two distinct auth keys collapse onto the same bounded token. */
|
|
@@ -54,17 +58,6 @@ export function assertUniqueApiToolAuthTokens(authKeys: readonly string[]): void
|
|
|
54
58
|
}
|
|
55
59
|
}
|
|
56
60
|
|
|
57
|
-
function fnv1a64(value: string): string {
|
|
58
|
-
let hash = 0xcbf29ce484222325n;
|
|
59
|
-
const prime = 0x100000001b3n;
|
|
60
|
-
const mask = 0xffffffffffffffffn;
|
|
61
|
-
for (const byte of new TextEncoder().encode(value)) {
|
|
62
|
-
hash ^= BigInt(byte);
|
|
63
|
-
hash = (hash * prime) & mask;
|
|
64
|
-
}
|
|
65
|
-
return hash.toString(16).padStart(16, "0");
|
|
66
|
-
}
|
|
67
|
-
|
|
68
61
|
/** Derive the unprefixed api_call name for one auth surface. */
|
|
69
62
|
export function apiCallToolNameForAuth(authKey: string, multiAuth: boolean): string {
|
|
70
63
|
return multiAuth ? `${API_CALL_TOOL_NAME}__${apiToolAuthToken(authKey)}` : API_CALL_TOOL_NAME;
|
package/src/companion-files.ts
CHANGED
|
@@ -76,6 +76,27 @@ export function companionFilesFromRecord(files: Record<string, Uint8Array>): Com
|
|
|
76
76
|
};
|
|
77
77
|
}
|
|
78
78
|
|
|
79
|
+
/**
|
|
80
|
+
* Candidate spellings for a manifest-declared archive path, in order.
|
|
81
|
+
*
|
|
82
|
+
* A manifest may write `./server.js` or `server.js`; zip entries are stored
|
|
83
|
+
* without the `./` prefix. Only the leading `./` is normalised — `..`
|
|
84
|
+
* segments and absolute paths are NOT resolved, so this cannot widen the
|
|
85
|
+
* lookup beyond the archive root.
|
|
86
|
+
*/
|
|
87
|
+
function archivePathCandidates(declared: string): string[] {
|
|
88
|
+
const stripped = declared.startsWith("./") ? declared.slice(2) : declared;
|
|
89
|
+
return stripped === declared ? [declared] : [declared, stripped];
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** First candidate spelling of `declared` present in `files`, else null. */
|
|
93
|
+
function resolveArchivePath(files: CompanionFileSource, declared: string): string | null {
|
|
94
|
+
for (const candidate of archivePathCandidates(declared)) {
|
|
95
|
+
if (files.has(candidate)) return candidate;
|
|
96
|
+
}
|
|
97
|
+
return null;
|
|
98
|
+
}
|
|
99
|
+
|
|
79
100
|
/**
|
|
80
101
|
* Validate companion-file presence per AFPS §3.3 / §3.4 for the given
|
|
81
102
|
* package type. Returns the first violation encountered, or `null` when
|
|
@@ -84,9 +105,8 @@ export function companionFilesFromRecord(files: Record<string, Uint8Array>): Com
|
|
|
84
105
|
* The check is intentionally minimal and presence-focused:
|
|
85
106
|
* - `agent` → `prompt.md` present at root, non-empty bytes (§3.2).
|
|
86
107
|
* - `skill` → `SKILL.md` present at root, with a YAML frontmatter `name`
|
|
87
|
-
* (§3.3). Missing `description` is tolerated
|
|
88
|
-
*
|
|
89
|
-
* {@link checkSkillMarkdown}.
|
|
108
|
+
* (§3.3). Missing `description` is tolerated — this is the LOADER gate;
|
|
109
|
+
* the producer rule is {@link checkSkillMarkdown}.
|
|
90
110
|
* - `mcp-server` → file at `manifest.server.entry_point` present in the
|
|
91
111
|
* archive (§3.4 "self-contained — every runtime dep bundled").
|
|
92
112
|
* - `integration` → no required companion (§3.5).
|
|
@@ -130,13 +150,6 @@ export function checkCompanionFiles(
|
|
|
130
150
|
path: "SKILL.md",
|
|
131
151
|
};
|
|
132
152
|
}
|
|
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.
|
|
140
153
|
if (!hasFrontmatterName(new TextDecoder().decode(bytes))) {
|
|
141
154
|
return {
|
|
142
155
|
reason: "SKILL_MISSING_FRONTMATTER_NAME",
|
|
@@ -159,7 +172,14 @@ export function checkCompanionFiles(
|
|
|
159
172
|
message: "mcp-server manifest must declare server.entry_point",
|
|
160
173
|
};
|
|
161
174
|
}
|
|
162
|
-
|
|
175
|
+
// MCPB manifests conventionally write the entry point relative-explicit
|
|
176
|
+
// (`./server.js`), while archive entries are stored flat (`server.js`).
|
|
177
|
+
// Both spellings name the same file, so resolve either — an exact
|
|
178
|
+
// `has(entryPoint)` rejected every package in this repo's own
|
|
179
|
+
// `system-packages/` tree when imported through `POST /api/packages/import`,
|
|
180
|
+
// while the on-disk system-package loader accepted them. One archive, two
|
|
181
|
+
// verdicts, depending on which door it came through.
|
|
182
|
+
if (!resolveArchivePath(files, entryPoint)) {
|
|
163
183
|
return {
|
|
164
184
|
reason: "MCP_SERVER_MISSING_ENTRY_POINT",
|
|
165
185
|
message: `mcp-server archive missing server.entry_point payload: ${entryPoint}`,
|
|
@@ -185,21 +205,10 @@ function isEffectivelyEmpty(bytes: Uint8Array): boolean {
|
|
|
185
205
|
}
|
|
186
206
|
|
|
187
207
|
/**
|
|
188
|
-
*
|
|
189
|
-
*
|
|
190
|
-
*
|
|
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.
|
|
208
|
+
* Deliberately NOT {@link parseSkillFrontmatter}: published artifacts exist
|
|
209
|
+
* whose frontmatter `yaml` cannot parse at all (17 in production at the time of
|
|
210
|
+
* writing, each an unquoted `description: … : …`) and the run launcher has to
|
|
211
|
+
* keep serving them, so this probe's acceptance set may never shrink.
|
|
203
212
|
*/
|
|
204
213
|
function hasFrontmatterName(content: string): boolean {
|
|
205
214
|
const fmMatch = content.match(/^---[^\S\n]*\n([\s\S]*?)\n---/);
|
|
@@ -209,114 +218,37 @@ function hasFrontmatterName(content: string): boolean {
|
|
|
209
218
|
if (!nameMatch) return false;
|
|
210
219
|
const raw = (nameMatch[1] ?? "").trim();
|
|
211
220
|
if (raw.length === 0) return false;
|
|
212
|
-
// Strip surrounding quotes, the way a YAML quoted scalar would be read.
|
|
213
221
|
const unquoted = /^(['"])(.*)\1$/.exec(raw);
|
|
214
222
|
const value = unquoted ? unquoted[2] : raw;
|
|
215
223
|
return (value ?? "").trim().length > 0;
|
|
216
224
|
}
|
|
217
225
|
|
|
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
|
+
/** Agent Skills bounds, in code points. */
|
|
226
227
|
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
228
|
export const SKILL_DESCRIPTION_MAX_LENGTH = 1024;
|
|
239
229
|
|
|
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
|
-
*/
|
|
230
|
+
/** The bare slug an agent runtime addresses — NOT a `@scope/name` package id. */
|
|
253
231
|
const SKILL_NAME_PATTERN = /^[a-z0-9]+(?:-[a-z0-9]+)*$/;
|
|
254
232
|
|
|
255
|
-
/** Length in Unicode code points — `"🙂".length` is 2, this returns 1. */
|
|
256
233
|
function codePointLength(value: string): number {
|
|
257
234
|
return [...value].length;
|
|
258
235
|
}
|
|
259
236
|
|
|
260
|
-
/** Does `name` satisfy the Agent Skills `name` rule (shape AND length)? */
|
|
261
237
|
export function isValidSkillName(name: string): boolean {
|
|
262
238
|
return codePointLength(name) <= SKILL_NAME_MAX_LENGTH && SKILL_NAME_PATTERN.test(name);
|
|
263
239
|
}
|
|
264
240
|
|
|
265
|
-
/** Parsed `SKILL.md`
|
|
241
|
+
/** Parsed `SKILL.md` frontmatter. Never throws; a parse failure is `error`. */
|
|
266
242
|
export interface SkillFrontmatter {
|
|
267
|
-
/** Whether a closed `--- … ---` frontmatter block was found. */
|
|
268
243
|
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
|
-
*/
|
|
244
|
+
/** Opens with `---` but never closes the block. */
|
|
273
245
|
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
246
|
error: string | null;
|
|
281
|
-
/** Frontmatter `name`, or `""` when absent/blank. */
|
|
282
247
|
name: string;
|
|
283
|
-
/** Frontmatter `description`, or `""` when absent/blank. */
|
|
284
248
|
description: string;
|
|
285
249
|
}
|
|
286
250
|
|
|
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
|
-
*/
|
|
251
|
+
/** Parses the way the skill runtime does, so the gate cannot accept what it fails to read. */
|
|
320
252
|
export function parseSkillFrontmatter(content: string): SkillFrontmatter {
|
|
321
253
|
const empty = { found: false, unterminated: false, error: null, name: "", description: "" };
|
|
322
254
|
|
|
@@ -338,8 +270,6 @@ export function parseSkillFrontmatter(content: string): SkillFrontmatter {
|
|
|
338
270
|
};
|
|
339
271
|
}
|
|
340
272
|
|
|
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
273
|
const mapping = parsed ?? {};
|
|
344
274
|
if (typeof mapping !== "object" || Array.isArray(mapping)) {
|
|
345
275
|
return {
|
|
@@ -358,15 +288,7 @@ export function parseSkillFrontmatter(content: string): SkillFrontmatter {
|
|
|
358
288
|
return { found: true, unterminated: false, error: null, name, description };
|
|
359
289
|
}
|
|
360
290
|
|
|
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
|
-
*/
|
|
291
|
+
/** An absent key and an empty scalar both mean "not provided" and yield `""`. */
|
|
370
292
|
function readStringField(record: Record<string, unknown>, key: string): string | { error: string } {
|
|
371
293
|
const value = record[key];
|
|
372
294
|
if (value === undefined || value === null) return "";
|
|
@@ -384,73 +306,37 @@ function describeType(value: unknown): string {
|
|
|
384
306
|
return `a ${typeof value}`;
|
|
385
307
|
}
|
|
386
308
|
|
|
387
|
-
/** Does the text begin with a UTF-8 byte-order mark? */
|
|
388
309
|
function startsWithBom(content: string): boolean {
|
|
389
310
|
return content.charCodeAt(0) === 0xfeff;
|
|
390
311
|
}
|
|
391
312
|
|
|
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
|
-
*/
|
|
313
|
+
/** `ignoreBOM: true` reads backwards: it means "do not CONSUME the BOM". */
|
|
402
314
|
export function decodeSkillMarkdown(bytes: Uint8Array): string {
|
|
403
315
|
return new TextDecoder("utf-8", { ignoreBOM: true }).decode(bytes);
|
|
404
316
|
}
|
|
405
317
|
|
|
406
|
-
/** First line of an error message — YAML errors carry a multi-line excerpt. */
|
|
407
318
|
function firstLine(err: unknown): string {
|
|
408
319
|
const message = err instanceof Error ? err.message : String(err);
|
|
409
320
|
return message.split("\n")[0]!.trim();
|
|
410
321
|
}
|
|
411
322
|
|
|
412
|
-
// ─────────────────────────────────────────────
|
|
413
|
-
// SKILL.md — the PRODUCER-side gate
|
|
414
|
-
// ─────────────────────────────────────────────
|
|
415
|
-
|
|
416
323
|
/**
|
|
417
|
-
*
|
|
418
|
-
*
|
|
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.
|
|
324
|
+
* The producer-side AFPS §3.3 gate: an Agent Skills `name`
|
|
325
|
+
* (https://agentskills.io/specification) plus a non-empty bounded `description`.
|
|
436
326
|
*/
|
|
437
327
|
export function checkSkillMarkdown(content: string): CompanionFileViolation | null {
|
|
438
|
-
//
|
|
439
|
-
//
|
|
440
|
-
//
|
|
441
|
-
//
|
|
442
|
-
//
|
|
443
|
-
//
|
|
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.
|
|
328
|
+
// Rejected rather than stripped, and it stays rejected now that Pi >= 0.85
|
|
329
|
+
// strips a BOM itself: a minted version is immutable and has to load on every
|
|
330
|
+
// runtime image the platform ships, including the 0.84.x ones that test
|
|
331
|
+
// startsWith("---"), read no frontmatter behind a BOM and drop the skill.
|
|
332
|
+
// The platform's own loader eats it either way, so without this check the
|
|
333
|
+
// version would be minted and simply never load on those images.
|
|
448
334
|
if (startsWithBom(content)) {
|
|
449
335
|
return {
|
|
450
336
|
reason: "SKILL_INVALID_FRONTMATTER",
|
|
451
337
|
message:
|
|
452
338
|
"skill SKILL.md starts with a byte-order mark (U+FEFF); remove it — " +
|
|
453
|
-
"
|
|
339
|
+
"older runtimes read no frontmatter behind a BOM and drop the skill",
|
|
454
340
|
path: "SKILL.md",
|
|
455
341
|
};
|
|
456
342
|
}
|
|
@@ -506,18 +392,8 @@ export function checkSkillMarkdown(content: string): CompanionFileViolation | nu
|
|
|
506
392
|
};
|
|
507
393
|
}
|
|
508
394
|
|
|
509
|
-
//
|
|
510
|
-
//
|
|
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.
|
|
395
|
+
// Containment: `name:\n triage` is valid YAML the loader's probe cannot
|
|
396
|
+
// read, so accepting it would mint a version the run launcher cannot load.
|
|
521
397
|
if (!hasFrontmatterName(content)) {
|
|
522
398
|
return {
|
|
523
399
|
reason: "SKILL_INVALID_FRONTMATTER_NAME",
|
package/src/jsonpath.ts
ADDED
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// SPDX-License-Identifier: Apache-2.0
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* The JSONPath dialect of AFPS integration manifests — a single-value RFC 9535
|
|
5
|
+
* subset: `$`, `.name` (member-name-shorthand), `['name']` / `["name"]` (RFC
|
|
6
|
+
* 9535 string literals and escapes), `[0]` / `[-1]` (RFC 9535 `int`). Anything
|
|
7
|
+
* else throws {@link JsonPathSyntaxError} rather than silently missing.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export class JsonPathSyntaxError extends Error {
|
|
11
|
+
override readonly name = "JsonPathSyntaxError";
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
type JsonPathSegment = string | number;
|
|
15
|
+
|
|
16
|
+
const SIMPLE_ESCAPES: Record<string, string> = {
|
|
17
|
+
b: "\b",
|
|
18
|
+
f: "\f",
|
|
19
|
+
n: "\n",
|
|
20
|
+
r: "\r",
|
|
21
|
+
t: "\t",
|
|
22
|
+
"/": "/",
|
|
23
|
+
"\\": "\\",
|
|
24
|
+
};
|
|
25
|
+
|
|
26
|
+
const isDigit = (ch: string) => ch >= "0" && ch <= "9";
|
|
27
|
+
const isNameFirst = (ch: string) =>
|
|
28
|
+
(ch >= "a" && ch <= "z") || (ch >= "A" && ch <= "Z") || ch === "_" || ch.charCodeAt(0) >= 0x80;
|
|
29
|
+
const isBlank = (ch: string | undefined) => ch === " " || ch === "\t" || ch === "\n" || ch === "\r";
|
|
30
|
+
|
|
31
|
+
/** Tokenize `path` into member names and array indices. Throws on anything outside the subset. */
|
|
32
|
+
export function parseJsonPath(path: string): JsonPathSegment[] {
|
|
33
|
+
const fail = (at: number, what: string): never => {
|
|
34
|
+
throw new JsonPathSyntaxError(`jsonpath '${path}' at offset ${at}: ${what}`);
|
|
35
|
+
};
|
|
36
|
+
if (path[0] !== "$") fail(0, "must start with '$'");
|
|
37
|
+
|
|
38
|
+
const segments: JsonPathSegment[] = [];
|
|
39
|
+
let i = 1;
|
|
40
|
+
const skipBlank = () => {
|
|
41
|
+
while (isBlank(path[i])) i++;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const readString = (quote: string): string => {
|
|
45
|
+
const start = i;
|
|
46
|
+
i++;
|
|
47
|
+
let out = "";
|
|
48
|
+
while (i < path.length) {
|
|
49
|
+
const ch = path[i]!;
|
|
50
|
+
if (ch === quote) {
|
|
51
|
+
i++;
|
|
52
|
+
return out;
|
|
53
|
+
}
|
|
54
|
+
if (ch.charCodeAt(0) < 0x20) fail(i, "unescaped control character in string");
|
|
55
|
+
if (ch !== "\\") {
|
|
56
|
+
out += ch;
|
|
57
|
+
i++;
|
|
58
|
+
continue;
|
|
59
|
+
}
|
|
60
|
+
const esc = path[i + 1];
|
|
61
|
+
if (esc === quote) out += quote;
|
|
62
|
+
else if (esc !== undefined && esc in SIMPLE_ESCAPES) out += SIMPLE_ESCAPES[esc];
|
|
63
|
+
else if (esc === "u") {
|
|
64
|
+
const unit = readHex4(i + 2);
|
|
65
|
+
if (unit >= 0xdc00 && unit <= 0xdfff) fail(i, "lone low surrogate escape");
|
|
66
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
67
|
+
const low = path.startsWith("\\u", i + 6) ? readHex4(i + 8) : -1;
|
|
68
|
+
if (low < 0xdc00 || low > 0xdfff)
|
|
69
|
+
fail(i, "high surrogate escape without a low surrogate");
|
|
70
|
+
out += String.fromCharCode(unit, low);
|
|
71
|
+
i += 12;
|
|
72
|
+
continue;
|
|
73
|
+
}
|
|
74
|
+
out += String.fromCharCode(unit);
|
|
75
|
+
i += 6;
|
|
76
|
+
continue;
|
|
77
|
+
} else fail(i, `invalid escape '\\${esc ?? ""}'`);
|
|
78
|
+
i += 2;
|
|
79
|
+
}
|
|
80
|
+
return fail(start, "unterminated string");
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
const readHex4 = (at: number): number => {
|
|
84
|
+
const hex = path.slice(at, at + 4);
|
|
85
|
+
if (!/^[0-9A-Fa-f]{4}$/.test(hex)) fail(at, "\\u must be followed by 4 hex digits");
|
|
86
|
+
return parseInt(hex, 16);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
const readInt = (): number => {
|
|
90
|
+
const start = i;
|
|
91
|
+
if (path[i] === "-") i++;
|
|
92
|
+
const digitsAt = i;
|
|
93
|
+
while (isDigit(path[i] ?? "")) i++;
|
|
94
|
+
const text = path.slice(start, i);
|
|
95
|
+
if (i === digitsAt) fail(start, "expected an index");
|
|
96
|
+
if (text === "-0" || (path[digitsAt] === "0" && i - digitsAt > 1)) {
|
|
97
|
+
fail(start, `index '${text}' is not an RFC 9535 int (no leading zeros, no -0)`);
|
|
98
|
+
}
|
|
99
|
+
const n = Number(text);
|
|
100
|
+
if (!Number.isSafeInteger(n)) fail(start, `index '${text}' is out of range`);
|
|
101
|
+
return n;
|
|
102
|
+
};
|
|
103
|
+
|
|
104
|
+
while (i < path.length) {
|
|
105
|
+
const ch = path[i]!;
|
|
106
|
+
if (ch === ".") {
|
|
107
|
+
i++;
|
|
108
|
+
const start = i;
|
|
109
|
+
const first = path[i];
|
|
110
|
+
if (first === ".") fail(start, "recursive descent is not supported");
|
|
111
|
+
if (first === "*") fail(start, "a wildcard is not supported");
|
|
112
|
+
if (first !== undefined && isDigit(first)) {
|
|
113
|
+
fail(
|
|
114
|
+
start,
|
|
115
|
+
"a member name cannot start with a digit — write an index as [0] or a member as ['0']",
|
|
116
|
+
);
|
|
117
|
+
}
|
|
118
|
+
if (first === undefined || !isNameFirst(first)) fail(start, "expected a member name");
|
|
119
|
+
while (i < path.length && (isNameFirst(path[i]!) || isDigit(path[i]!))) i++;
|
|
120
|
+
segments.push(path.slice(start, i));
|
|
121
|
+
} else if (ch === "[") {
|
|
122
|
+
i++;
|
|
123
|
+
skipBlank();
|
|
124
|
+
const sel = path[i];
|
|
125
|
+
if (sel === "'" || sel === '"') segments.push(readString(sel));
|
|
126
|
+
else if (sel === "-" || (sel !== undefined && isDigit(sel))) segments.push(readInt());
|
|
127
|
+
else if (sel === undefined) fail(i, "unterminated '['");
|
|
128
|
+
else
|
|
129
|
+
fail(i, `unsupported selector '${sel}' (wildcards, filters and slices are not supported)`);
|
|
130
|
+
skipBlank();
|
|
131
|
+
if (path[i] === ",") fail(i, "a union of selectors is not supported");
|
|
132
|
+
if (path[i] === ":") fail(i, "a slice is not supported");
|
|
133
|
+
if (path[i] !== "]") fail(i, path[i] === undefined ? "unterminated '['" : "expected ']'");
|
|
134
|
+
i++;
|
|
135
|
+
} else {
|
|
136
|
+
fail(i, `unexpected character '${ch}'`);
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
return segments;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/**
|
|
143
|
+
* Evaluate `path` against `root`. Returns `undefined` when the path is valid
|
|
144
|
+
* but selects nothing; throws {@link JsonPathSyntaxError} when it is invalid.
|
|
145
|
+
* Members are own properties only, so `$.constructor` cannot reach a prototype.
|
|
146
|
+
*/
|
|
147
|
+
export function evaluateJsonPath(root: unknown, path: string): unknown {
|
|
148
|
+
let cur: unknown = root;
|
|
149
|
+
for (const seg of parseJsonPath(path)) {
|
|
150
|
+
if (cur === null || typeof cur !== "object") return undefined;
|
|
151
|
+
if (typeof seg === "number") {
|
|
152
|
+
if (!Array.isArray(cur)) return undefined;
|
|
153
|
+
cur = cur[seg < 0 ? cur.length + seg : seg];
|
|
154
|
+
} else {
|
|
155
|
+
// Not `Object.hasOwn`: the web app compiles this leaf against ES2020.
|
|
156
|
+
if (Array.isArray(cur) || !Object.prototype.hasOwnProperty.call(cur, seg)) return undefined;
|
|
157
|
+
cur = (cur as Record<string, unknown>)[seg];
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
return cur;
|
|
161
|
+
}
|
package/src/mcp-naming.ts
CHANGED
|
@@ -13,7 +13,6 @@ export const MCP_TOOL_NAMESPACE_BASE_MAX_LENGTH = 20;
|
|
|
13
13
|
* snake-case namespace capped before collision suffixing.
|
|
14
14
|
*/
|
|
15
15
|
export function normaliseMcpToolNamespace(raw: string): string {
|
|
16
|
-
if (typeof raw !== "string") return "";
|
|
17
16
|
const out = trimUnderscores(
|
|
18
17
|
raw
|
|
19
18
|
.replace(/^@/, "")
|
|
@@ -24,18 +23,61 @@ export function normaliseMcpToolNamespace(raw: string): string {
|
|
|
24
23
|
}
|
|
25
24
|
|
|
26
25
|
/**
|
|
27
|
-
*
|
|
28
|
-
*
|
|
29
|
-
|
|
26
|
+
* Exposed tool-name grammar: `{snake_namespace}__{body}`, the body in the LLM
|
|
27
|
+
* providers' `^[a-zA-Z0-9_-]{1,64}$` alphabet (56-char ceiling: re-prefix headroom).
|
|
28
|
+
*/
|
|
29
|
+
const MCP_TOOL_NAME_PATTERN = /^[a-z0-9][a-z0-9_]*__[A-Za-z0-9_-]+$/;
|
|
30
|
+
|
|
31
|
+
export function isValidMcpToolName(name: string): boolean {
|
|
32
|
+
return name.length <= MCP_TOOL_NAME_MAX_LENGTH && MCP_TOOL_NAME_PATTERN.test(name);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Map an untrusted upstream tool name onto the body alphabet: each rejected
|
|
37
|
+
* code point becomes `_`; nothing else changes.
|
|
30
38
|
*/
|
|
31
39
|
export function normaliseMcpToolBody(raw: string): string {
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
40
|
+
return raw.replace(/[^A-Za-z0-9_-]/gu, "_");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
const MCP_TOOL_HASH_LENGTH = 8;
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Exposed name for an untrusted upstream tool: `{namespace}__{body}` when it
|
|
47
|
+
* fits and is free, else the body cut to fit plus a hash of the ORIGINAL
|
|
48
|
+
* upstream name, so two names that normalise to the same body (`a.b`, `a_b`)
|
|
49
|
+
* still get distinct names. Which of them keeps the plain form depends on
|
|
50
|
+
* registration order: the first one registered. One salted re-hash on
|
|
51
|
+
* collision, then it throws.
|
|
52
|
+
*/
|
|
53
|
+
export function allocateMcpToolName(
|
|
54
|
+
namespace: string,
|
|
55
|
+
upstreamName: string,
|
|
56
|
+
taken: (name: string) => boolean,
|
|
57
|
+
): string {
|
|
58
|
+
const body = normaliseMcpToolBody(upstreamName);
|
|
59
|
+
const plain = `${namespace}__${body}`;
|
|
60
|
+
if (isValidMcpToolName(plain) && !taken(plain)) return plain;
|
|
61
|
+
const budget = MCP_TOOL_NAME_MAX_LENGTH - namespace.length - 2 - MCP_TOOL_HASH_LENGTH - 1;
|
|
62
|
+
const head = body.slice(0, Math.max(0, budget));
|
|
63
|
+
for (const seed of [upstreamName, `${upstreamName}\0`]) {
|
|
64
|
+
const hash = fnv1a64Hex(seed).slice(0, MCP_TOOL_HASH_LENGTH);
|
|
65
|
+
const candidate = head ? `${namespace}__${head}_${hash}` : `${namespace}__${hash}`;
|
|
66
|
+
if (!taken(candidate)) return candidate;
|
|
67
|
+
}
|
|
68
|
+
throw new Error(`MCP tool name ${JSON.stringify(upstreamName)} collides after re-hashing`);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** 64-bit FNV-1a over the UTF-8 bytes, as 16 lowercase hex digits. */
|
|
72
|
+
export function fnv1a64Hex(value: string): string {
|
|
73
|
+
let hash = 0xcbf29ce484222325n;
|
|
74
|
+
const prime = 0x100000001b3n;
|
|
75
|
+
const mask = 0xffffffffffffffffn;
|
|
76
|
+
for (const byte of new TextEncoder().encode(value)) {
|
|
77
|
+
hash ^= BigInt(byte);
|
|
78
|
+
hash = (hash * prime) & mask;
|
|
37
79
|
}
|
|
38
|
-
return
|
|
80
|
+
return hash.toString(16).padStart(16, "0");
|
|
39
81
|
}
|
|
40
82
|
|
|
41
83
|
/** Trim underscore runs in linear time without a backtracking expression. */
|