feat(slice-05): plugins manifest type extension — Item 6 cross-primitive bundling part 3b (Checkpoint 7.3b) (OPR.0.3.2.5)

Mirrors the skills[] type-layer pattern (0c13c7c6) for plugins. Bundle
manifest may optionally declare plugins[] — each entry is a plugin
reference with id + source.{kind: local, path}. Per orch-ratified
HYBRID decision, bundles REFERENCE existing plugins (e.g. the
0.3.1 gstack-plugin) via this kind rather than forking plugin
content into the bundle.

Install-time routing of plugin references lands at Checkpoint 7.3c
(closes Item 6 with the gstack proof case).

What ships:
- packages/daemon/src/domain/bundle-types.ts:
  - new BundlePluginReference type (id + source: {kind: local, path})
  - validatePluginsBlock helper: rejects non-array; per-entry id
    required + source.kind must be 'local' + source.path required +
    isRelativeSafePath (other kinds reserved for future, e.g. remote
    fetch)
  - normalizePluginsBlock helper: defensive copy; filters malformed
    entries silently for forward-compat
  - both manifest interfaces extended with optional plugins?:
    BundlePluginReference[]
  - both validators call validatePluginsBlock
  - both serializers emit plugins when non-empty (defensive object
    construction)
  - v1 normalizer surfaces plugins; v2 raw round-trip preserved
- packages/daemon/test/bundle-types.test.ts: 8 new v1 plugins tests
  (missing-valid, accepted-array, non-array-rejected, missing-id-
  rejected, non-local-source-kind-rejected, unsafe-source-path-
  rejected, round-trip-preserves, missing-round-trips-cleanly)
- packages/daemon/test/pod-bundle-assembler.test.ts: 4 new v2 plugins
  tests (missing-valid, accepted-array, unsafe-path-rejected,
  round-trip)

Verified:
- 87/87 daemon bundle-types + pod-bundle-assembler green (was 75;
  +12 net new plugins tests)
- Discriminator pre-verified (banked claim-discipline): commenting
  out validatePluginsBlock in both validators causes 5/87 to fail
  (4 v1 reject tests + 1 v2 reject test); restored returns 87/87
- Coherence + audit-every-layer unchanged

Pre-commit + handoff-claim disciplines both applied.

Diff: 3 files, ~+220 lines.

PRD bounce conditions verified:
- No schema bump (optional field additive)
- No new SQLite migrations / event types / core-routing / bootstrap
  mutation / sidebar mutation
- scope-and-versioning sections 1+3 preserved

What this commit does NOT ship (Checkpoint 7.3c — closes Item 6):
- /install handler invokes plugin-routing (delegating to the existing
  0.3.1 plugin install API surface — plugin-vendor-service.ts)
- gstack HYBRID proof case: small starter rig.yaml + bundle.yaml
  authoring (substrate-side) referencing existing 0.3.1 gstack-plugin
  via plugins[] kind per orch-ratified decision

Item 6 sub-chain:
- 5f410eee (Item 6 part 3a B1 skills-routing decoupling, CLEAR)
- THIS COMMIT — Item 6 part 3b: plugins manifest type extension
- Next (7.3c) — plugins install routing + gstack HYBRID proof case

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-18 15:10:50 -07:00
1 parent 5f410eeef7
commit 5aec0ef529
3 files changed
+229

No files matched your search

@@ -81,6 +81,70 @@ function provenanceToYamlRecord(p: BundleProvenance): Record<string, unknown> {
* level field; missing kinds keep backward compat.
*/
/**
* Plugin reference — Item 6 / slice-05 Checkpoint 7.3b. Bundle manifest may
* declare plugin references the bundle includes (in HYBRID mode the bundle
* REFERENCES the existing 0.3.1 plugin rather than forking its content; per
* orch-ratified decision doc).
*/
export interface BundlePluginReference {
/** Plugin id (matches the plugin primitive's id surface). */
id: string;
/** Where to resolve the plugin from. v0 supports local-path source. */
source: { kind: "local"; path: string };
}
/** Validate optional plugins[] block. Appends to errors if present-but-malformed. */
function validatePluginsBlock(raw: unknown, errors: string[]): void {
if (raw === undefined) return;
if (!Array.isArray(raw)) {
errors.push("plugins must be an array");
return;
}
for (let i = 0; i < raw.length; i++) {
const entry = raw[i];
if (!entry || typeof entry !== "object" || Array.isArray(entry)) {
errors.push(`plugins[${i}] must be an object`);
continue;
}
const p = entry as Record<string, unknown>;
if (typeof p["id"] !== "string" || !p["id"]) {
errors.push(`plugins[${i}].id is required`);
}
const source = p["source"];
if (!source || typeof source !== "object" || Array.isArray(source)) {
errors.push(`plugins[${i}].source must be an object`);
continue;
}
const s = source as Record<string, unknown>;
if (s["kind"] !== "local") {
errors.push(`plugins[${i}].source.kind must be 'local' (other kinds reserved for future)`);
}
if (typeof s["path"] !== "string" || !s["path"]) {
errors.push(`plugins[${i}].source.path is required`);
} else if (!isRelativeSafePath(s["path"] as string)) {
errors.push(`plugins[${i}].source.path is not safe: '${s["path"]}'`);
}
}
}
/** Normalize raw plugins[] block (defensive copy). */
function normalizePluginsBlock(raw: unknown): BundlePluginReference[] | undefined {
if (!Array.isArray(raw)) return undefined;
const result: BundlePluginReference[] = [];
for (const entry of raw) {
if (!entry || typeof entry !== "object" || Array.isArray(entry)) continue;
const p = entry as Record<string, unknown>;
const s = p["source"];
if (typeof p["id"] !== "string" || !p["id"]) continue;
if (!s || typeof s !== "object" || Array.isArray(s)) continue;
const src = s as Record<string, unknown>;
if (src["kind"] !== "local" || typeof src["path"] !== "string" || !src["path"]) continue;
result.push({ id: p["id"], source: { kind: "local", path: src["path"] } });
}
return result.length > 0 ? result : undefined;
}
/** Validate optional skills[] block. Appends to errors if present-but-malformed. */
function validateSkillsBlock(raw: unknown, errors: string[]): void {
if (raw === undefined) return;
@@ -224,6 +288,8 @@ export interface PodBundleManifest {
compatibility?: BundleCompatibility;
/** Item 6 cross-primitive bundling: skill paths to route to the operator skills library on install. */
skills?: string[];
/** Item 6 cross-primitive bundling: plugin references to install via the plugin primitive (HYBRID-mode bundles reference existing plugins rather than forking content). */
plugins?: BundlePluginReference[];
}
export function validatePodBundleManifest(raw: unknown): { valid: boolean; errors: string[] } {
@@ -253,6 +319,7 @@ export function validatePodBundleManifest(raw: unknown): { valid: boolean; error
validateProvenanceBlock(m["provenance"], errors);
validateCompatibilityBlock(m["compatibility"], errors);
validateSkillsBlock(m["skills"], errors);
validatePluginsBlock(m["plugins"], errors);
return { valid: errors.length === 0, errors };
}
@@ -284,6 +351,7 @@ export function serializePodBundleManifest(manifest: PodBundleManifest): string
if (manifest.provenance) doc["provenance"] = provenanceToYamlRecord(manifest.provenance);
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
if (manifest.plugins && manifest.plugins.length > 0) doc["plugins"] = manifest.plugins.map((p) => ({ id: p.id, source: { kind: p.source.kind, path: p.source.path } }));
return stringifyYaml(doc);
}
@@ -323,6 +391,8 @@ export interface LegacyBundleManifest {
compatibility?: BundleCompatibility;
/** Item 6 cross-primitive bundling: skill paths to route to the operator skills library on install. */
skills?: string[];
/** Item 6 cross-primitive bundling: plugin references to install via the plugin primitive (HYBRID-mode bundles reference existing plugins rather than forking content). */
plugins?: BundlePluginReference[];
}
/** Validation options */
@@ -416,6 +486,7 @@ export function validateLegacyBundleManifest(
validateProvenanceBlock(m["provenance"], errors);
validateCompatibilityBlock(m["compatibility"], errors);
validateSkillsBlock(m["skills"], errors);
validatePluginsBlock(m["plugins"], errors);
return { valid: errors.length === 0, errors };
}
@@ -467,6 +538,9 @@ export function normalizeLegacyBundleManifest(raw: unknown): LegacyBundleManifes
const skills = normalizeSkillsBlock(m["skills"]);
if (skills) result.skills = skills;
const plugins = normalizePluginsBlock(m["plugins"]);
if (plugins) result.plugins = plugins;
return result;
}
@@ -499,6 +573,7 @@ export function serializeLegacyBundleManifest(manifest: LegacyBundleManifest): s
if (manifest.compatibility) doc["compatibility"] = compatibilityToYamlRecord(manifest.compatibility);
if (manifest.skills && manifest.skills.length > 0) doc["skills"] = manifest.skills;
if (manifest.plugins && manifest.plugins.length > 0) doc["plugins"] = manifest.plugins.map((p) => ({ id: p.id, source: { kind: p.source.kind, path: p.source.path } }));
return stringifyYaml(doc);
}
+95
View File
@@ -9,6 +9,7 @@ import {
type LegacyBundleManifest as BundleManifest,
type BundleProvenance,
type BundleCompatibility,
type BundlePluginReference,
} from "../src/domain/bundle-types.js";
const VALID_RAW = {
@@ -438,6 +439,100 @@ describe("Bundle types", () => {
const normalized = normalizeBundleManifest(parsed);
expect(normalized.skills).toBeUndefined();
});
// -- Item 6 plugins block tests (slice-05 Checkpoint 7.3b) --
it("missing plugins block passes validation (backward compat)", () => {
const raw = { ...VALID_RAW };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("plugins as array of valid {id, source} entries passes validation", () => {
const raw = {
...VALID_RAW,
plugins: [
{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } },
{ id: "obra-superpowers", source: { kind: "local", path: "plugins/obra-superpowers" } },
],
};
const result = validateBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("plugins as non-array rejected", () => {
const raw = { ...VALID_RAW, plugins: "gstack" };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("plugins must be an array"))).toBe(true);
});
it("plugin entry missing id is rejected", () => {
const raw = { ...VALID_RAW, plugins: [{ source: { kind: "local", path: "plugins/gstack" } }] };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("plugins[0].id"))).toBe(true);
});
it("plugin entry with non-local source.kind rejected", () => {
const raw = { ...VALID_RAW, plugins: [{ id: "x", source: { kind: "remote", path: "plugins/x" } }] };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("source.kind must be 'local'"))).toBe(true);
});
it("plugin entry with unsafe source.path rejected", () => {
const raw = { ...VALID_RAW, plugins: [{ id: "x", source: { kind: "local", path: "../escape" } }] };
const result = validateBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("plugins[0].source.path"))).toBe(true);
expect(result.errors.some((e) => e.includes("not safe"))).toBe(true);
});
it("round-trip preserves plugins through serialize → parse → normalize", () => {
const plugins: BundlePluginReference[] = [
{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } },
];
const manifest: BundleManifest = {
schemaVersion: 1,
name: "rt-plugins",
version: "1.0.0",
createdAt: "2026-05-18T12:00:00Z",
rigSpec: "rig.yaml",
packages: [{ name: "pkg", version: "1.0.0", path: "packages/pkg", originalSource: "local:./pkg" }],
integrity: {
algorithm: "sha256",
files: { "rig.yaml": "5".repeat(64), "packages/pkg/package.yaml": "6".repeat(64) },
},
plugins,
};
const yaml = serializeBundleManifest(manifest);
expect(yaml).toContain("plugins:");
expect(yaml).toContain("id: gstack");
expect(yaml).toContain("plugins/gstack");
const parsed = parseBundleManifest(yaml);
const validation = validateBundleManifest(parsed);
expect(validation.valid).toBe(true);
const normalized = normalizeBundleManifest(parsed);
expect(normalized.plugins).toBeDefined();
expect(normalized.plugins).toEqual(plugins);
});
it("missing plugins round-trips cleanly (no field emitted in YAML)", () => {
const manifest: BundleManifest = {
schemaVersion: 1,
name: "no-plugins",
version: "1.0.0",
createdAt: "2026-05-18T00:00:00Z",
rigSpec: "rig.yaml",
packages: [{ name: "pkg", version: "1.0", path: "packages/pkg", originalSource: "local:./pkg" }],
};
const yaml = serializeBundleManifest(manifest);
expect(yaml).not.toContain("plugins:");
const parsed = parseBundleManifest(yaml);
const normalized = normalizeBundleManifest(parsed);
expect(normalized.plugins).toBeUndefined();
});
});
describe("isRelativeSafePath", () => {
@@ -830,6 +830,65 @@ describe("PodBundleManifest validation", () => {
expect(result.errors.some((e) => e.includes("skills[0]") && e.includes("not safe"))).toBe(true);
});
// -- Item 6 plugins block tests for v2 (slice-05 Checkpoint 7.3b) --
it("v2: missing plugins block passes validation", () => {
const raw = {
schema_version: 2, name: "no-plugins", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("v2: plugins as valid {id, source} array passes validation", () => {
const raw = {
schema_version: 2, name: "with-plugins", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
plugins: [{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } }],
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(true);
});
it("v2: plugin entry with unsafe source.path rejected", () => {
const raw = {
schema_version: 2, name: "bad-plugins", version: "1.0",
created_at: "2026-05-18T00:00:00Z", rig_spec: "rig.yaml",
agents: [{ name: "impl", version: "1.0", path: "agents/impl", original_ref: "local:agents/impl", hash: "abc", import_entries: [] }],
plugins: [{ id: "x", source: { kind: "local", path: "../escape" } }],
};
const result = validatePodBundleManifest(raw);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("plugins[0].source.path") && e.includes("not safe"))).toBe(true);
});
it("v2: round-trip preserves plugins through serialize -> parse", () => {
const manifest: PodBundleManifest = {
schemaVersion: 2, name: "rt-plugins-v2", version: "2.0",
createdAt: "2026-05-18T00:00:00Z", rigSpec: "rig.yaml",
agents: [{
name: "impl", version: "1.0", path: "agents/impl",
originalRef: "local:agents/impl", hash: "def",
importEntries: [],
}],
plugins: [{ id: "gstack", source: { kind: "local", path: "plugins/gstack" } }],
};
const yaml = serializePodBundleManifest(manifest);
expect(yaml).toContain("plugins:");
expect(yaml).toContain("id: gstack");
const parsed = parsePodBundleManifest(yaml);
const validation = validatePodBundleManifest(parsed);
expect(validation.valid).toBe(true);
const m = parsed as Record<string, unknown>;
const ps = m["plugins"] as Array<Record<string, unknown>>;
expect(ps).toHaveLength(1);
expect(ps[0]!["id"]).toBe("gstack");
expect((ps[0]!["source"] as Record<string, unknown>)["path"]).toBe("plugins/gstack");
});
it("v2: round-trip preserves skills through serialize -> parse", () => {
const manifest: PodBundleManifest = {
schemaVersion: 2, name: "rt-skills-v2", version: "2.0",