mirror of
https://github.com/mvschwarz/openrig.git
synced 2026-11-09 03:39:51 -10:00
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:
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);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
Reference in new issue
Block a user