@ryuhq/sdk 0.0.5 → 0.0.17
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/dist/agent.cjs +4 -1
- package/dist/agent.d.cts +1 -1
- package/dist/agent.d.ts +1 -1
- package/dist/agent.js +1 -1
- package/dist/{chunk-KPKMMGVC.js → chunk-MTUBUPIV.js} +4 -1
- package/dist/{chunk-GXHL5CO7.js → chunk-SKVIJH5I.js} +84 -2
- package/dist/cli.cjs +138 -10
- package/dist/cli.js +56 -9
- package/dist/{index-DAxq7Y0R.d.ts → index-B6SkaAjJ.d.ts} +4 -4
- package/dist/{index-CEbS1SlS.d.cts → index-BvAB5eMk.d.cts} +4 -4
- package/dist/index.cjs +135 -9
- package/dist/index.d.cts +30 -12
- package/dist/index.d.ts +30 -12
- package/dist/index.js +50 -8
- package/dist/manifest.cjs +85 -2
- package/dist/manifest.d.cts +93 -10
- package/dist/manifest.d.ts +93 -10
- package/dist/manifest.js +3 -1
- package/package.json +2 -2
- package/src/builder.ts +4 -4
- package/src/cli.ts +98 -13
- package/src/contracts-lockstep.test.ts +4 -4
- package/src/generated/plugin-manifest.ts +1148 -22
- package/src/manifest-schema.test.ts +182 -0
- package/src/manifest.fixtures.test.ts +447 -0
- package/src/manifest.test.ts +100 -16
- package/src/manifest.ts +137 -17
- package/src/plugin/ryu-plugin.ts +1 -1
- package/src/runnable/agent.ts +3 -3
- package/src/runnable/app.test.ts +67 -0
- package/src/runnable/app.ts +54 -4
- package/src/runnable/primitives.test.ts +1 -5
- package/src/runnable/primitives.ts +6 -5
- package/src/runnable/tool.ts +4 -4
- package/src/runnable/turn-hook.ts +30 -4
|
@@ -0,0 +1,182 @@
|
|
|
1
|
+
// Pure-zod tests for the manifest schema seams that gate what an author can pack:
|
|
2
|
+
// the semver regex (Core-lockstep), the anti-impersonation companion-label refine,
|
|
3
|
+
// and the RunnableMeta identity contract. These need no native addon — they exercise
|
|
4
|
+
// the TS-authoring zod layer directly, documenting the ACTUAL accept/reject boundary
|
|
5
|
+
// (including the deliberate substring behavior of the impersonation check).
|
|
6
|
+
|
|
7
|
+
import { describe, expect, test } from "bun:test";
|
|
8
|
+
import {
|
|
9
|
+
CompanionSurfaceSchema,
|
|
10
|
+
labelImpersonatesSystemChrome,
|
|
11
|
+
PluginManifestSchema,
|
|
12
|
+
RunnableMetaSchema,
|
|
13
|
+
} from "./manifest.ts";
|
|
14
|
+
|
|
15
|
+
function baseManifest(overrides: Record<string, unknown> = {}) {
|
|
16
|
+
return {
|
|
17
|
+
id: "com.example.app",
|
|
18
|
+
name: "App",
|
|
19
|
+
version: "1.0.0",
|
|
20
|
+
runnables: [],
|
|
21
|
+
...overrides,
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// ── semver regex ──────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
describe("PluginManifest version (semver regex)", () => {
|
|
28
|
+
test("accepts plain MAJOR.MINOR.PATCH", () => {
|
|
29
|
+
expect(
|
|
30
|
+
PluginManifestSchema.safeParse(baseManifest({ version: "1.0.0" })).success
|
|
31
|
+
).toBe(true);
|
|
32
|
+
expect(
|
|
33
|
+
PluginManifestSchema.safeParse(baseManifest({ version: "10.20.30" }))
|
|
34
|
+
.success
|
|
35
|
+
).toBe(true);
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
test("accepts a prerelease tag (1.0.0-beta.1)", () => {
|
|
39
|
+
expect(
|
|
40
|
+
PluginManifestSchema.safeParse(baseManifest({ version: "1.0.0-beta.1" }))
|
|
41
|
+
.success
|
|
42
|
+
).toBe(true);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("accepts build metadata (1.0.0+build.5) and both together", () => {
|
|
46
|
+
expect(
|
|
47
|
+
PluginManifestSchema.safeParse(baseManifest({ version: "1.0.0+build.5" }))
|
|
48
|
+
.success
|
|
49
|
+
).toBe(true);
|
|
50
|
+
expect(
|
|
51
|
+
PluginManifestSchema.safeParse(
|
|
52
|
+
baseManifest({ version: "1.2.3-rc.1+exp.sha.5114f85" })
|
|
53
|
+
).success
|
|
54
|
+
).toBe(true);
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
test("rejects a two-segment version (1.0)", () => {
|
|
58
|
+
expect(
|
|
59
|
+
PluginManifestSchema.safeParse(baseManifest({ version: "1.0" })).success
|
|
60
|
+
).toBe(false);
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
test("rejects a leading-v version (v1.0.0)", () => {
|
|
64
|
+
expect(
|
|
65
|
+
PluginManifestSchema.safeParse(baseManifest({ version: "v1.0.0" }))
|
|
66
|
+
.success
|
|
67
|
+
).toBe(false);
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
test("rejects a non-numeric segment (1.0.x)", () => {
|
|
71
|
+
expect(
|
|
72
|
+
PluginManifestSchema.safeParse(baseManifest({ version: "1.0.x" })).success
|
|
73
|
+
).toBe(false);
|
|
74
|
+
});
|
|
75
|
+
|
|
76
|
+
test("rejects an empty version", () => {
|
|
77
|
+
expect(
|
|
78
|
+
PluginManifestSchema.safeParse(baseManifest({ version: "" })).success
|
|
79
|
+
).toBe(false);
|
|
80
|
+
});
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
// ── labelImpersonatesSystemChrome (substring, case-insensitive) ───────────────
|
|
84
|
+
|
|
85
|
+
describe("labelImpersonatesSystemChrome", () => {
|
|
86
|
+
test("flags any label containing 'ryu' or 'system', case-insensitively", () => {
|
|
87
|
+
for (const bad of [
|
|
88
|
+
"Ryu",
|
|
89
|
+
"ryu panel",
|
|
90
|
+
"My RYU thing",
|
|
91
|
+
"System",
|
|
92
|
+
"system tools",
|
|
93
|
+
"SYSTEM",
|
|
94
|
+
]) {
|
|
95
|
+
expect(labelImpersonatesSystemChrome(bad)).toBe(true);
|
|
96
|
+
}
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
test("is a raw substring match — 'systematic' and 'ryusaki' are flagged (documents behavior)", () => {
|
|
100
|
+
// The check is a deliberate substring test, not word-boundary aware. These
|
|
101
|
+
// false-positive-looking cases are the ACTUAL contract; pin them so a future
|
|
102
|
+
// change is conscious.
|
|
103
|
+
expect(labelImpersonatesSystemChrome("systematic review")).toBe(true);
|
|
104
|
+
expect(labelImpersonatesSystemChrome("ryusaki")).toBe(true);
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
test("allows an ordinary third-party label", () => {
|
|
108
|
+
for (const ok of ["Whiteboard", "Mail", "Kanban Board", "Notes"]) {
|
|
109
|
+
expect(labelImpersonatesSystemChrome(ok)).toBe(false);
|
|
110
|
+
}
|
|
111
|
+
});
|
|
112
|
+
});
|
|
113
|
+
|
|
114
|
+
// ── CompanionSurfaceSchema refine ─────────────────────────────────────────────
|
|
115
|
+
|
|
116
|
+
describe("CompanionSurfaceSchema", () => {
|
|
117
|
+
test("accepts a clean label with optional icon + shortcut", () => {
|
|
118
|
+
const parsed = CompanionSurfaceSchema.safeParse({
|
|
119
|
+
label: "Whiteboard",
|
|
120
|
+
icon: "sparkles",
|
|
121
|
+
shortcut: "ctrl+shift+w",
|
|
122
|
+
});
|
|
123
|
+
expect(parsed.success).toBe(true);
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
test("rejects an empty label (min(1))", () => {
|
|
127
|
+
expect(CompanionSurfaceSchema.safeParse({ label: "" }).success).toBe(false);
|
|
128
|
+
});
|
|
129
|
+
|
|
130
|
+
test("rejects a label that impersonates system chrome, with the documented message", () => {
|
|
131
|
+
const parsed = CompanionSurfaceSchema.safeParse({ label: "Ryu Settings" });
|
|
132
|
+
expect(parsed.success).toBe(false);
|
|
133
|
+
if (parsed.success) {
|
|
134
|
+
return;
|
|
135
|
+
}
|
|
136
|
+
expect(parsed.error.issues[0]?.message).toContain(
|
|
137
|
+
"impersonate system chrome"
|
|
138
|
+
);
|
|
139
|
+
});
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
// ── RunnableMetaSchema identity contract ──────────────────────────────────────
|
|
143
|
+
|
|
144
|
+
describe("RunnableMetaSchema", () => {
|
|
145
|
+
test("requires a non-empty id and name", () => {
|
|
146
|
+
expect(
|
|
147
|
+
RunnableMetaSchema.safeParse({ id: "", name: "X", kind: "agent" }).success
|
|
148
|
+
).toBe(false);
|
|
149
|
+
expect(
|
|
150
|
+
RunnableMetaSchema.safeParse({ id: "x", name: "", kind: "agent" }).success
|
|
151
|
+
).toBe(false);
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
test("rejects an unknown kind", () => {
|
|
155
|
+
expect(
|
|
156
|
+
RunnableMetaSchema.safeParse({ id: "x", name: "X", kind: "daemon" })
|
|
157
|
+
.success
|
|
158
|
+
).toBe(false);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
test("accepts each of the known kinds", () => {
|
|
162
|
+
for (const kind of ["agent", "workflow", "tool", "skill"]) {
|
|
163
|
+
expect(
|
|
164
|
+
RunnableMetaSchema.safeParse({ id: "x", name: "X", kind }).success
|
|
165
|
+
).toBe(true);
|
|
166
|
+
}
|
|
167
|
+
});
|
|
168
|
+
|
|
169
|
+
test("keeps an opaque per-kind config record", () => {
|
|
170
|
+
const parsed = RunnableMetaSchema.safeParse({
|
|
171
|
+
id: "x",
|
|
172
|
+
name: "X",
|
|
173
|
+
kind: "tool",
|
|
174
|
+
config: { widget: true, slug: "x__render" },
|
|
175
|
+
});
|
|
176
|
+
expect(parsed.success).toBe(true);
|
|
177
|
+
if (!parsed.success) {
|
|
178
|
+
return;
|
|
179
|
+
}
|
|
180
|
+
expect(parsed.data.config?.widget).toBe(true);
|
|
181
|
+
});
|
|
182
|
+
});
|
|
@@ -0,0 +1,447 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Validation coverage for every checked-in Plugin manifest fixture.
|
|
3
|
+
*
|
|
4
|
+
* The authoritative loader for these `manifest.json` files is Core's Rust
|
|
5
|
+
* `PluginManifestLoader` (`apps/core/src/plugin_manifest/`). This suite is the
|
|
6
|
+
* TypeScript-side guard: it walks the on-disk fixtures Core ships and asserts the
|
|
7
|
+
* properties the SDK layer is responsible for keeping in lockstep with Rust —
|
|
8
|
+
*
|
|
9
|
+
* 1. every fixture is well-formed (JSON, non-empty id, valid semver, known
|
|
10
|
+
* runnable kinds, non-impersonating companion labels, valid surface targets);
|
|
11
|
+
* 2. every turn-hook `on` names a real Core hook phase (the `ON_*` constants in
|
|
12
|
+
* `apps/core/src/plugin_host/mod.rs`);
|
|
13
|
+
* 3. every `match.tools` gate is a well-formed tiny-glob (the shape Core's
|
|
14
|
+
* `glob_match` treats as a leading/trailing wildcard rather than a literal);
|
|
15
|
+
* 4. every manifest whose runnables use only SDK-known kinds parses cleanly
|
|
16
|
+
* through `PluginManifestSchema`.
|
|
17
|
+
*
|
|
18
|
+
* The set is read from Core's tree so a new shipped plugin is covered the moment it
|
|
19
|
+
* lands, without touching this file. It spans BOTH homes: the packaged manifests
|
|
20
|
+
* live in `apps-store/<x>/manifest.json` and `plugins-store/<x>/manifest.json` (Core
|
|
21
|
+
* `include_str!`s them from there), and only the ~13 Core-only ones remain under
|
|
22
|
+
* `apps/core/src/plugin_manifest/fixtures/`. Reading just the fixtures dir would
|
|
23
|
+
* still pass — on 13 files instead of 71 — so both roots are walked deliberately.
|
|
24
|
+
*/
|
|
25
|
+
|
|
26
|
+
import { describe, expect, it } from "bun:test";
|
|
27
|
+
import { existsSync, readdirSync, readFileSync } from "node:fs";
|
|
28
|
+
import { join } from "node:path";
|
|
29
|
+
import {
|
|
30
|
+
labelImpersonatesSystemChrome,
|
|
31
|
+
PluginManifestSchema,
|
|
32
|
+
SurfaceSchema,
|
|
33
|
+
} from "./manifest.ts";
|
|
34
|
+
|
|
35
|
+
// ── Fixture discovery ─────────────────────────────────────────────────────────
|
|
36
|
+
//
|
|
37
|
+
// Resolve from this file's dir (packages/sdk/src → repo root) so the suite reads
|
|
38
|
+
// the same set whether `bun test` is invoked from the package or the repo root.
|
|
39
|
+
const REPO_ROOT = join(import.meta.dir, "../../..");
|
|
40
|
+
const FIXTURES_DIR = join(REPO_ROOT, "apps/core/src/plugin_manifest/fixtures");
|
|
41
|
+
/** The package roots whose `manifest.json` Core compiles in directly. */
|
|
42
|
+
const PACKAGE_ROOTS = ["apps-store", "plugins-store"];
|
|
43
|
+
|
|
44
|
+
interface RawManifest {
|
|
45
|
+
companion?: { label?: unknown };
|
|
46
|
+
contributes?: {
|
|
47
|
+
turn_hooks?: Array<{ on?: unknown; match?: { tools?: unknown[] } }>;
|
|
48
|
+
};
|
|
49
|
+
id?: unknown;
|
|
50
|
+
name?: unknown;
|
|
51
|
+
runnables?: Array<{ kind?: unknown; id?: unknown; name?: unknown }>;
|
|
52
|
+
targets?: unknown[];
|
|
53
|
+
version?: unknown;
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
function loadFixtures(): Array<{ file: string; manifest: RawManifest }> {
|
|
57
|
+
const found: Array<{ file: string; path: string }> = [];
|
|
58
|
+
|
|
59
|
+
// 1. Core-only manifests still living beside the crate.
|
|
60
|
+
for (const f of readdirSync(FIXTURES_DIR)) {
|
|
61
|
+
if (f.endsWith(".manifest.json")) {
|
|
62
|
+
found.push({ file: f, path: join(FIXTURES_DIR, f) });
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
// 2. The packaged manifests, named after their directory so a failure message
|
|
67
|
+
// still identifies the app rather than saying "manifest.json".
|
|
68
|
+
for (const root of PACKAGE_ROOTS) {
|
|
69
|
+
const rootDir = join(REPO_ROOT, root);
|
|
70
|
+
let names: string[];
|
|
71
|
+
try {
|
|
72
|
+
names = readdirSync(rootDir);
|
|
73
|
+
} catch {
|
|
74
|
+
continue; // root not shipped in this tree
|
|
75
|
+
}
|
|
76
|
+
for (const name of names) {
|
|
77
|
+
const path = join(rootDir, name, "manifest.json");
|
|
78
|
+
if (existsSync(path)) {
|
|
79
|
+
found.push({ file: `${root}/${name}/manifest.json`, path });
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
found.sort((a, b) => a.file.localeCompare(b.file));
|
|
85
|
+
return found.map(({ file, path }) => ({
|
|
86
|
+
file,
|
|
87
|
+
manifest: JSON.parse(readFileSync(path, "utf8")) as RawManifest,
|
|
88
|
+
}));
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const FIXTURES = loadFixtures();
|
|
92
|
+
|
|
93
|
+
// ── Authoritative vocabularies (mirror the Rust source of truth) ──────────────
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Every `RunnableKind` Core defines
|
|
97
|
+
* (`crates/core/kernel-contracts/src/runnable.rs`). Note this is a SUPERSET of the
|
|
98
|
+
* SDK's `RunnableKindSchema` (which omits `channel`/`engine`/`policy`) — the SDK
|
|
99
|
+
* models only the subset a third-party author can pack today.
|
|
100
|
+
*/
|
|
101
|
+
const CORE_RUNNABLE_KINDS = new Set([
|
|
102
|
+
"agent",
|
|
103
|
+
"workflow",
|
|
104
|
+
"tool",
|
|
105
|
+
"skill",
|
|
106
|
+
"companion",
|
|
107
|
+
"channel",
|
|
108
|
+
"engine",
|
|
109
|
+
"policy",
|
|
110
|
+
]);
|
|
111
|
+
|
|
112
|
+
/** Kinds the SDK's `PluginManifestSchema` can round-trip. */
|
|
113
|
+
const SDK_KNOWN_KINDS = new Set([
|
|
114
|
+
"agent",
|
|
115
|
+
"workflow",
|
|
116
|
+
"tool",
|
|
117
|
+
"skill",
|
|
118
|
+
"companion",
|
|
119
|
+
]);
|
|
120
|
+
|
|
121
|
+
/**
|
|
122
|
+
* Every valid turn-hook phase — the `ON_*` string constants in
|
|
123
|
+
* `apps/core/src/plugin_host/mod.rs`. A hook whose `on` is not one of these is
|
|
124
|
+
* dead: `phase_matches` never fires it (the sole exception, `stop`, is itself in
|
|
125
|
+
* the set and is also treated as `post_assistant_turn`).
|
|
126
|
+
*/
|
|
127
|
+
const VALID_HOOK_PHASES = new Set([
|
|
128
|
+
"post_assistant_turn",
|
|
129
|
+
"pre_user_turn",
|
|
130
|
+
"session_start",
|
|
131
|
+
"stop",
|
|
132
|
+
"pre_tool_use",
|
|
133
|
+
"post_tool_use",
|
|
134
|
+
"subagent_stop",
|
|
135
|
+
"session_end",
|
|
136
|
+
"notification",
|
|
137
|
+
// Kernel lifecycle phases fired by Core's own subsystems (the workflow
|
|
138
|
+
// executor), not by any app.
|
|
139
|
+
"workflow_run_started",
|
|
140
|
+
"workflow_run_finished",
|
|
141
|
+
"workflow_run_failed",
|
|
142
|
+
]);
|
|
143
|
+
|
|
144
|
+
/** An **app event** — a phase some app declares in `contributes.hook_events`,
|
|
145
|
+
* always `<owning plugin id>#<event name>`. Matched by SHAPE, not against a list:
|
|
146
|
+
* which app events exist depends on which apps are installed, so no fixed set
|
|
147
|
+
* could be right. The mandatory `#` is also what keeps an app event from ever
|
|
148
|
+
* colliding with one of the bare-word Core phases above. */
|
|
149
|
+
const APP_EVENT_PHASE = /^[@A-Za-z0-9][A-Za-z0-9._/-]*#[a-z0-9][a-z0-9._-]*$/;
|
|
150
|
+
|
|
151
|
+
/** Whether `on` names something Core can actually dispatch. */
|
|
152
|
+
function isDispatchablePhase(on: string): boolean {
|
|
153
|
+
return VALID_HOOK_PHASES.has(on) || APP_EVENT_PHASE.test(on);
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/** SDK schema default for an omitted `on` (mirrors `TurnHookContributionSchema`). */
|
|
157
|
+
const DEFAULT_HOOK_PHASE = "post_assistant_turn";
|
|
158
|
+
|
|
159
|
+
// Semver mirror of the regex in `PluginManifestSchema` — duplicated here so the
|
|
160
|
+
// fixture check does not depend on a full schema parse (engine/policy fixtures
|
|
161
|
+
// cannot parse, yet still must carry a valid version).
|
|
162
|
+
const SEMVER = /^\d+\.\d+\.\d+(?:-[\w.]+)?(?:\+[\w.]+)?$/;
|
|
163
|
+
|
|
164
|
+
// ── glob_match oracle (ported verbatim from Core) ─────────────────────────────
|
|
165
|
+
//
|
|
166
|
+
// `apps/core/src/plugin_host/mod.rs::glob_match`. Ported so the fixture gate
|
|
167
|
+
// check and its unit cases test the *same* semantics Core enforces. Crucially
|
|
168
|
+
// there is NO compile/parse step in Core: a pattern with interior `*` (e.g.
|
|
169
|
+
// "a*b") is not an error — it silently falls through to an exact-literal match.
|
|
170
|
+
function globMatch(pattern: string, name: string): boolean {
|
|
171
|
+
if (pattern === "*") {
|
|
172
|
+
return true;
|
|
173
|
+
}
|
|
174
|
+
const leading = pattern.startsWith("*");
|
|
175
|
+
const trailing = pattern.endsWith("*");
|
|
176
|
+
if (leading && trailing) {
|
|
177
|
+
// both ends starred: substring on the inner (strip one star each side)
|
|
178
|
+
const inner = pattern.slice(1, -1);
|
|
179
|
+
return name.includes(inner);
|
|
180
|
+
}
|
|
181
|
+
if (leading) {
|
|
182
|
+
return name.endsWith(pattern.slice(1));
|
|
183
|
+
}
|
|
184
|
+
if (trailing) {
|
|
185
|
+
return name.startsWith(pattern.slice(0, -1));
|
|
186
|
+
}
|
|
187
|
+
return pattern === name;
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* A "well-formed" tool gate for Core's tiny matcher: an optional single leading
|
|
192
|
+
* and/or trailing `*` with a plain literal body (no interior `*`). Anything with
|
|
193
|
+
* an interior star is NOT rejected by Core — it just degrades to a literal match
|
|
194
|
+
* that can never fire — so this is a lint, not a hard schema rule.
|
|
195
|
+
*/
|
|
196
|
+
function isWellFormedToolGlob(pattern: string): boolean {
|
|
197
|
+
if (pattern === "*") {
|
|
198
|
+
return true;
|
|
199
|
+
}
|
|
200
|
+
const body = pattern.replace(/^\*/, "").replace(/\*$/, "");
|
|
201
|
+
return !body.includes("*");
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
// ── Suite sanity ──────────────────────────────────────────────────────────────
|
|
205
|
+
|
|
206
|
+
describe("fixture discovery", () => {
|
|
207
|
+
it("finds the full shipped set (guards against a broken path)", () => {
|
|
208
|
+
// A wrong path would read zero files and every downstream test would
|
|
209
|
+
// vacuously pass; assert a floor well below the current count (71).
|
|
210
|
+
expect(FIXTURES.length).toBeGreaterThan(50);
|
|
211
|
+
// Both homes must contribute. A floor alone cannot catch losing one root:
|
|
212
|
+
// the packaged manifests are ~58 of the set, so dropping the Core-only dir
|
|
213
|
+
// still clears 50 while silently skipping `layers`, `memory`, `rag`, … —
|
|
214
|
+
// and dropping the package roots leaves only 13, which the floor does catch.
|
|
215
|
+
expect(FIXTURES.some((f) => f.file.includes("plugins-store/"))).toBe(true);
|
|
216
|
+
expect(FIXTURES.some((f) => f.file.includes("apps-store/"))).toBe(true);
|
|
217
|
+
expect(FIXTURES.some((f) => !f.file.includes("/"))).toBe(true);
|
|
218
|
+
});
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// ── 1. Every fixture is well-formed ───────────────────────────────────────────
|
|
222
|
+
|
|
223
|
+
describe("every fixture is well-formed", () => {
|
|
224
|
+
for (const { file, manifest } of FIXTURES) {
|
|
225
|
+
it(`${file}: has a non-empty id and name`, () => {
|
|
226
|
+
expect(typeof manifest.id).toBe("string");
|
|
227
|
+
expect((manifest.id as string).length).toBeGreaterThan(0);
|
|
228
|
+
expect(typeof manifest.name).toBe("string");
|
|
229
|
+
expect((manifest.name as string).length).toBeGreaterThan(0);
|
|
230
|
+
});
|
|
231
|
+
|
|
232
|
+
it(`${file}: version is valid semver`, () => {
|
|
233
|
+
expect(manifest.version).toMatch(SEMVER);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it(`${file}: every runnable kind is a known Core RunnableKind`, () => {
|
|
237
|
+
for (const r of manifest.runnables ?? []) {
|
|
238
|
+
expect(CORE_RUNNABLE_KINDS.has(r.kind as string)).toBe(true);
|
|
239
|
+
expect(typeof r.id).toBe("string");
|
|
240
|
+
expect((r.id as string).length).toBeGreaterThan(0);
|
|
241
|
+
expect(typeof r.name).toBe("string");
|
|
242
|
+
}
|
|
243
|
+
});
|
|
244
|
+
|
|
245
|
+
it(`${file}: companion label does not impersonate system chrome`, () => {
|
|
246
|
+
const label = manifest.companion?.label;
|
|
247
|
+
if (typeof label === "string") {
|
|
248
|
+
expect(labelImpersonatesSystemChrome(label)).toBe(false);
|
|
249
|
+
}
|
|
250
|
+
});
|
|
251
|
+
|
|
252
|
+
it(`${file}: every target is a valid Core surface`, () => {
|
|
253
|
+
for (const t of manifest.targets ?? []) {
|
|
254
|
+
expect(SurfaceSchema.safeParse(t).success).toBe(true);
|
|
255
|
+
}
|
|
256
|
+
});
|
|
257
|
+
}
|
|
258
|
+
});
|
|
259
|
+
|
|
260
|
+
// ── 2. Hook phase names ───────────────────────────────────────────────────────
|
|
261
|
+
|
|
262
|
+
describe("turn-hook phase names are valid Core phases", () => {
|
|
263
|
+
for (const { file, manifest } of FIXTURES) {
|
|
264
|
+
const hooks = manifest.contributes?.turn_hooks ?? [];
|
|
265
|
+
if (hooks.length === 0) {
|
|
266
|
+
continue;
|
|
267
|
+
}
|
|
268
|
+
it(`${file}: every turn_hook.on is a real ON_* phase`, () => {
|
|
269
|
+
for (const hook of hooks) {
|
|
270
|
+
// `on` is optional in the schema; absent means the default phase.
|
|
271
|
+
const phase =
|
|
272
|
+
typeof hook.on === "string" ? hook.on : DEFAULT_HOOK_PHASE;
|
|
273
|
+
expect(isDispatchablePhase(phase)).toBe(true);
|
|
274
|
+
}
|
|
275
|
+
});
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
it("collectively exercises more than one distinct phase", () => {
|
|
279
|
+
const seen = new Set<string>();
|
|
280
|
+
for (const { manifest } of FIXTURES) {
|
|
281
|
+
for (const hook of manifest.contributes?.turn_hooks ?? []) {
|
|
282
|
+
seen.add(typeof hook.on === "string" ? hook.on : DEFAULT_HOOK_PHASE);
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Guards against a matcher that trivially accepts everything: the fixtures
|
|
286
|
+
// really do span several phases (post_assistant_turn, pre_tool_use, …).
|
|
287
|
+
expect(seen.size).toBeGreaterThan(1);
|
|
288
|
+
for (const phase of seen) {
|
|
289
|
+
expect(isDispatchablePhase(phase)).toBe(true);
|
|
290
|
+
}
|
|
291
|
+
});
|
|
292
|
+
});
|
|
293
|
+
|
|
294
|
+
// ── 3. Tool-gate glob patterns ────────────────────────────────────────────────
|
|
295
|
+
|
|
296
|
+
describe("glob_match oracle (ported from Core)", () => {
|
|
297
|
+
// The exact assertions from Core's `glob_match_supports_wildcards` #[test].
|
|
298
|
+
it("'*' matches anything", () => {
|
|
299
|
+
expect(globMatch("*", "anything")).toBe(true);
|
|
300
|
+
});
|
|
301
|
+
it("a bare literal matches only itself", () => {
|
|
302
|
+
expect(globMatch("bash", "bash")).toBe(true);
|
|
303
|
+
expect(globMatch("bash", "bashx")).toBe(false);
|
|
304
|
+
});
|
|
305
|
+
it("trailing star is a prefix match", () => {
|
|
306
|
+
expect(globMatch("bash*", "bash__run")).toBe(true);
|
|
307
|
+
expect(globMatch("bash*", "sh")).toBe(false);
|
|
308
|
+
});
|
|
309
|
+
it("leading star is a suffix match", () => {
|
|
310
|
+
expect(globMatch("*write", "fs__write")).toBe(true);
|
|
311
|
+
expect(globMatch("*write", "writer")).toBe(false);
|
|
312
|
+
});
|
|
313
|
+
it("double star is a substring match", () => {
|
|
314
|
+
expect(globMatch("*edit*", "editor__do_edit")).toBe(true);
|
|
315
|
+
expect(globMatch("*edit*", "read_only")).toBe(false);
|
|
316
|
+
});
|
|
317
|
+
it("an interior star degrades to an exact-literal match (Core has no compile step)", () => {
|
|
318
|
+
// This is the footgun `isWellFormedToolGlob` lints for: "a*b" never behaves
|
|
319
|
+
// as a wildcard — it matches only the literal string "a*b".
|
|
320
|
+
expect(globMatch("a*b", "axb")).toBe(false);
|
|
321
|
+
expect(globMatch("a*b", "a*b")).toBe(true);
|
|
322
|
+
expect(isWellFormedToolGlob("a*b")).toBe(false);
|
|
323
|
+
});
|
|
324
|
+
});
|
|
325
|
+
|
|
326
|
+
describe("every fixture tool-gate glob is well-formed", () => {
|
|
327
|
+
for (const { file, manifest } of FIXTURES) {
|
|
328
|
+
const patterns: string[] = [];
|
|
329
|
+
for (const hook of manifest.contributes?.turn_hooks ?? []) {
|
|
330
|
+
for (const t of hook.match?.tools ?? []) {
|
|
331
|
+
if (typeof t === "string") {
|
|
332
|
+
patterns.push(t);
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
}
|
|
336
|
+
if (patterns.length === 0) {
|
|
337
|
+
continue;
|
|
338
|
+
}
|
|
339
|
+
it(`${file}: gates are leading/trailing-star only (no dead interior star)`, () => {
|
|
340
|
+
for (const p of patterns) {
|
|
341
|
+
expect(p.length).toBeGreaterThan(0);
|
|
342
|
+
expect(isWellFormedToolGlob(p)).toBe(true);
|
|
343
|
+
}
|
|
344
|
+
});
|
|
345
|
+
}
|
|
346
|
+
});
|
|
347
|
+
|
|
348
|
+
// ── 4. SDK-schema parity for the packable subset ──────────────────────────────
|
|
349
|
+
|
|
350
|
+
describe("SDK PluginManifestSchema parses every SDK-kind fixture", () => {
|
|
351
|
+
const sdkKindFixtures = FIXTURES.filter(({ manifest }) =>
|
|
352
|
+
(manifest.runnables ?? []).every((r) =>
|
|
353
|
+
SDK_KNOWN_KINDS.has(r.kind as string)
|
|
354
|
+
)
|
|
355
|
+
);
|
|
356
|
+
|
|
357
|
+
it("covers the bulk of the shipped set", () => {
|
|
358
|
+
// The partition itself is meaningful: most shipped plugins are packable.
|
|
359
|
+
expect(sdkKindFixtures.length).toBeGreaterThan(40);
|
|
360
|
+
});
|
|
361
|
+
|
|
362
|
+
for (const { file, manifest } of sdkKindFixtures) {
|
|
363
|
+
it(`${file}: parses cleanly through PluginManifestSchema`, () => {
|
|
364
|
+
const result = PluginManifestSchema.safeParse(manifest);
|
|
365
|
+
if (!result.success) {
|
|
366
|
+
throw new Error(
|
|
367
|
+
`${file} failed SDK schema parse: ${JSON.stringify(
|
|
368
|
+
result.error.issues.slice(0, 5),
|
|
369
|
+
null,
|
|
370
|
+
2
|
|
371
|
+
)}`
|
|
372
|
+
);
|
|
373
|
+
}
|
|
374
|
+
expect(result.success).toBe(true);
|
|
375
|
+
});
|
|
376
|
+
}
|
|
377
|
+
});
|
|
378
|
+
|
|
379
|
+
describe("Core-only-kind fixtures are outside the SDK schema (documented divergence)", () => {
|
|
380
|
+
// engine/policy/channel are real Core RunnableKinds the SDK's zod enum omits by
|
|
381
|
+
// design. Pinning this keeps the divergence visible: when the SDK grows these
|
|
382
|
+
// kinds, this expectation flips and the test tells you to update the schema
|
|
383
|
+
// mirror. It documents CURRENT behavior, it does not endorse it.
|
|
384
|
+
const coreOnly = FIXTURES.filter(({ manifest }) =>
|
|
385
|
+
(manifest.runnables ?? []).some(
|
|
386
|
+
(r) => !SDK_KNOWN_KINDS.has(r.kind as string)
|
|
387
|
+
)
|
|
388
|
+
);
|
|
389
|
+
|
|
390
|
+
it("exist and every one is rejected by the SDK schema", () => {
|
|
391
|
+
expect(coreOnly.length).toBeGreaterThan(0);
|
|
392
|
+
for (const { manifest } of coreOnly) {
|
|
393
|
+
expect(PluginManifestSchema.safeParse(manifest).success).toBe(false);
|
|
394
|
+
}
|
|
395
|
+
});
|
|
396
|
+
});
|
|
397
|
+
|
|
398
|
+
// ── Regression: SDK schema must preserve turn-hook `match` gates ────────────────
|
|
399
|
+
|
|
400
|
+
describe("PluginManifestSchema preserves turn_hook.match", () => {
|
|
401
|
+
// `ryu pack` / `ryu publish` persist `safeParse(...).data`, so any field
|
|
402
|
+
// missing from `TurnHookContributionSchema` is silently stripped before
|
|
403
|
+
// signing. That once dropped `match` entirely: an SDK-authored `pre_tool_use`
|
|
404
|
+
// hook gating to specific tools (e.g. `["bash*"]`) lost its gate and ran on
|
|
405
|
+
// EVERY tool call. Same failure class as widgets/requires/targets. This test
|
|
406
|
+
// pins the fix — the gate must survive the parse the CLI applies.
|
|
407
|
+
it("tool-firewall's match:{tools:['*']} survives the parse", () => {
|
|
408
|
+
const raw = JSON.parse(
|
|
409
|
+
// `tool-firewall` is a packaged plugin, so its manifest lives in its
|
|
410
|
+
// package directory — there is no fixture copy any more.
|
|
411
|
+
readFileSync(
|
|
412
|
+
join(REPO_ROOT, "plugins-store/tool-firewall/manifest.json"),
|
|
413
|
+
"utf8"
|
|
414
|
+
)
|
|
415
|
+
);
|
|
416
|
+
expect(raw.contributes.turn_hooks[0].match).toEqual({ tools: ["*"] });
|
|
417
|
+
|
|
418
|
+
const parsed = PluginManifestSchema.safeParse(raw);
|
|
419
|
+
expect(parsed.success).toBe(true);
|
|
420
|
+
if (!parsed.success) {
|
|
421
|
+
return;
|
|
422
|
+
}
|
|
423
|
+
const hook = parsed.data.contributes?.turn_hooks[0] as {
|
|
424
|
+
match?: { tools: string[] };
|
|
425
|
+
};
|
|
426
|
+
expect(hook.match?.tools).toEqual(["*"]);
|
|
427
|
+
});
|
|
428
|
+
|
|
429
|
+
it("a hook without match stays gateless (field remains absent)", () => {
|
|
430
|
+
const parsed = PluginManifestSchema.safeParse({
|
|
431
|
+
id: "com.example.hooks",
|
|
432
|
+
name: "Hooks",
|
|
433
|
+
version: "1.0.0",
|
|
434
|
+
contributes: {
|
|
435
|
+
turn_hooks: [{ id: "h1", code: "return { action: 'none' };" }],
|
|
436
|
+
},
|
|
437
|
+
});
|
|
438
|
+
expect(parsed.success).toBe(true);
|
|
439
|
+
if (!parsed.success) {
|
|
440
|
+
return;
|
|
441
|
+
}
|
|
442
|
+
const hook = parsed.data.contributes?.turn_hooks[0] as {
|
|
443
|
+
match?: unknown;
|
|
444
|
+
};
|
|
445
|
+
expect(hook.match).toBeUndefined();
|
|
446
|
+
});
|
|
447
|
+
});
|