feat(slice-05): bundle-agent-images-router module — Item 6 cross-primitive bundling part 6b (Checkpoint 7.3g step 2) (OPR.0.3.2.5)

Pure-function router module for the agent_images cross-primitive kind.
Per PRD §Item 6 line 197 + e7a0b253 PRD-coherent contract: manifest
declares agent_images entries as paths to agent-image DIRECTORIES (not
manifest paths — distinct shape from context_packs). Router copies the
declared directory to <targetAgentImagesDir>/<basename(sourceAbs)>/
(operator-host canonical layout per agent-image-types.ts:9-10).

Consumer contract (agent-image-library-service.ts:77-95) is structurally
IDENTICAL to context-pack-library-service.scan but the ROUTER'S code
path differs in WHICH check it does WHERE (per banked tenth-catch
discipline: PRD field-shape ≠ consumer target-shape even when
consumer scan is the same).

ALL FOUR banked router-level catches from the context_packs cycle built
in PROACTIVELY from the first commit (no B1 cycles expected):
- Source path containment (mirrors all sibling routers)
- sourceAbs exists + isDirectory (declared path IS the image dir — PRD
  contract)
- manifest.yaml-INSIDE-image-dir exists AS A FILE (file-presence and
  not-isDirectory checks; banked d491eca9 + 3cd581e3 lessons)
- basename(sourceAbs) collision detection (routedDirNames Set; banked
  16ebb8af lesson)

bundle-agent-images-router.ts (+205):
- AgentImagesRouterFsOps interface (exists, isDirectory, mkdirp, copyDir)
- 6-value status enum: routed / missing / unsafe / not_directory /
  not_manifest / conflict (not_manifest covers both manifest-absent and
  manifest-is-directory cases per the banked file-vs-dir discrimination)
- routeAgentImages pure function with the full discrimination matrix

bundle-agent-images-router.test.ts (+225): 10 tests A1-A10 — mock
supports files+dirs sets per the d491eca9 lesson; imageFixture(dir)
helper builds a complete fixture (image dir + manifest.yaml file
inside) for clarity. C9 mixed-list aggregate exercises ALL 6 statuses.

Discriminator-flips BOTH PROACTIVELY:

Flip A — remove the manifest-file existence + manifest-is-directory
checks (both banked from context_packs). Result: 3/10 fail — exactly
A7 (manifest absent) + A8 (manifest-is-directory) + A10 mixed-list
aggregate. Proves both banked checks load-bearing. Restored.

Flip B — remove the routedDirNames collision detection. Result: 2/10
fail — exactly A9 (basename collision) + A10 mixed-list aggregate.
Proves collision detection load-bearing. Restored.

Final: 46/46 across all 5 router modules:
- bundle-skills-router 8
- bundle-plugins-router 9
- bundle-workflow-specs-router 11
- bundle-context-packs-router 11
- bundle-agent-images-router 10 (NEW)

Per banked tenth-catch discipline: did NOT copy the context_packs
manifest-path routing shape into agent_images. Followed the PRD field
shape (dir-path) and the consumer contract (manifest.yaml inside the
dir) separately. Step 3 will mirror context_packs /install integration
shape with the agent_images path-contract adaptation.

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-31 20:07:35 -07:00
1 parent e7a0b253b0
commit 13d2adf992
2 files changed
+445

No files matched your search

@@ -0,0 +1,198 @@
/**
* Bundle agent_images router (Item 6 / slice-05 Checkpoint 7.3g step 2).
*
* Pure function. Copies agent-image directories declared in a bundle
* manifest's agent_images[] block from the bundle's extracted tree to the
* operator agent-images library. No daemon dependencies — fully
* unit-testable via FsOps injection.
*
* Per PRD §Item 6 line 197: agent_images entries are paths to the
* agent-image DIRECTORY (not manifest paths — distinct shape from
* context_packs per the e7a0b253 PRD-coherent repair). The router copies
* the declared directory to <targetAgentImagesDir>/<basename(sourceAbs)>/
* — the operator-host canonical layout per agent-image-types.ts:9-10.
*
* Consumer contract (agent-image-library-service.ts:77-95): the consumer
* walks roots whose immediate children are image DIRS, requires each
* image dir to be a directory + contain a manifest.yaml FILE. The router
* enforces these consumer-visibility invariants at copy time so
* routedCount stays truthful at the consumer-visible boundary.
*
* All four banked router-level catches from the context_packs cycle
* (d491eca9 + 3cd581e3 + 16ebb8af + a0e7e0e1) apply identically and are
* built in from the first commit:
* 1. source path containment (mirrors all sibling routers)
* 2. sourceAbs exists + isDirectory (declared path IS the image dir)
* 3. manifest.yaml inside exists AS A FILE (consumer requires
* readable manifest.yaml; rejects manifest-missing AND manifest-
* is-a-directory cases — file-vs-dir discrimination axis)
* 4. basename(sourceAbs) collision detection (first wins, second
* flagged status=conflict)
*
* /install handler integration lands at Checkpoint 7.3g step 3 with a
* real consumer-scan() proof against the routed dir (mirror of the
* cb0bf7b9 context_packs ContextPackLibraryService.scan reachability
* proof).
*/
import nodePath from "node:path";
/** Filesystem injection point — real impl wraps node:fs. Tests substitute in-memory. */
export interface AgentImagesRouterFsOps {
exists: (path: string) => boolean;
isDirectory: (path: string) => boolean;
mkdirp: (path: string) => void;
copyDir: (src: string, dest: string) => void;
}
/** Inputs to routeAgentImages. */
export interface RouteAgentImagesInput {
/** Absolute path to the bundle's extracted root (tmp dir from unpack). */
bundleRoot: string;
/** Relative paths to agent-image DIRECTORIES declared in the bundle
* manifest's agent_images[] block (per PRD §Item 6 line 197). */
declaredAgentImages: string[];
/** Absolute path to the operator agent-images library (per
* agent-image-types.ts:9-10: typically `<openrigHome>/agent-images/`
* or workspace-local `.openrig/agent-images/`). Caller resolves. */
targetAgentImagesDir: string;
}
/** One routed agent_image (or one rejection). */
export interface RoutedAgentImageRecord {
/** Declared path from manifest.agent_images[]. */
declaredPath: string;
/** "routed" = image dir copied successfully and consumer-visible.
* "missing" = declared image dir not in bundle (honest skip).
* "unsafe" = source escapes bundleRoot OR target escapes
* targetAgentImagesDir (structurally impossible given basename, kept
* defensive).
* "not_directory" = declared path exists but is not a directory.
* "not_manifest" = image dir exists but manifest.yaml inside is
* absent OR is itself a directory — consumer requires a readable
* manifest.yaml file at the image dir root.
* "conflict" = basename collides with an earlier routed image; first
* wins, second flagged so routedCount stays truthful at the consumer-
* visible boundary (banked 16ebb8af lesson). */
status: "routed" | "missing" | "unsafe" | "not_directory" | "not_manifest" | "conflict";
/** Where the image landed in the target library (absolute image dir
* path), if routed. */
installedAt?: string;
/** Human-readable detail (3-part error shape input for caller). */
detail?: string;
}
/** Aggregate routing result. */
export interface RouteAgentImagesResult {
records: RoutedAgentImageRecord[];
routedCount: number;
rejectedCount: number;
}
/**
* Route each declared agent_image from the bundle tree to the operator
* agent-images library. Per-entry safety: SOURCE containment under
* bundleRoot + TARGET containment under targetAgentImagesDir. Consumer-
* visibility invariants (declared dir IS a directory + manifest.yaml
* inside exists AS A FILE) enforced BEFORE write so routedCount stays
* truthful at the consumer-visible boundary. Caller writes the install
* audit record using the records returned here.
*/
export function routeAgentImages(
input: RouteAgentImagesInput,
fs: AgentImagesRouterFsOps,
): RouteAgentImagesResult {
const records: RoutedAgentImageRecord[] = [];
const bundleRootResolved = nodePath.resolve(input.bundleRoot);
const targetRootResolved = nodePath.resolve(input.targetAgentImagesDir);
fs.mkdirp(input.targetAgentImagesDir);
// Track basenames already routed so collisions surface as conflict
// records, not silent overwrites (banked workflow_specs +
// context_packs lessons).
const routedDirNames = new Set<string>();
for (const declared of input.declaredAgentImages) {
const sourceAbs = nodePath.resolve(input.bundleRoot, declared);
// Defense-in-depth path-containment on SOURCE (mirrors sibling routers).
if (sourceAbs !== bundleRootResolved && !sourceAbs.startsWith(bundleRootResolved + nodePath.sep)) {
records.push({
declaredPath: declared,
status: "unsafe",
detail: `agent_image path '${declared}' escapes bundle workspace; rejected`,
});
continue;
}
if (!fs.exists(sourceAbs)) {
records.push({
declaredPath: declared,
status: "missing",
detail: `agent_image dir '${declared}' not present in bundle; skipped`,
});
continue;
}
if (!fs.isDirectory(sourceAbs)) {
records.push({
declaredPath: declared,
status: "not_directory",
detail: `agent_image '${declared}' exists but is not a directory; consumer requires the declared path to be an image dir`,
});
continue;
}
// Manifest-file existence check (banked d491eca9 lesson on
// context_packs): the consumer (agent-image-library-service.ts:93)
// skips any image dir whose manifest.yaml is absent. Reject pre-write.
const manifestPath = nodePath.join(sourceAbs, "manifest.yaml");
if (!fs.exists(manifestPath)) {
records.push({
declaredPath: declared,
status: "not_manifest",
detail: `agent_image '${declared}' is missing manifest.yaml; consumer-invisible (agent-image-library scan requires it)`,
});
continue;
}
// Manifest-type check (banked 3cd581e3 lesson on context_packs):
// exists() returns true for both files and directories. If manifest.yaml
// is a directory, the consumer's readFileSync would throw and scan()
// records an error diagnostic instead of indexing the image. Reject.
if (fs.isDirectory(manifestPath)) {
records.push({
declaredPath: declared,
status: "not_manifest",
detail: `agent_image '${declared}' has manifest.yaml as a directory, not a file; consumer requires a readable file`,
});
continue;
}
const dirName = nodePath.basename(sourceAbs);
const targetAbs = nodePath.resolve(input.targetAgentImagesDir, dirName);
// Sanity check (basename is structurally safe but resolve could
// theoretically be a no-op for "" → keep the check, expected always-
// true).
if (targetAbs !== targetRootResolved && !targetAbs.startsWith(targetRootResolved + nodePath.sep)) {
records.push({
declaredPath: declared,
status: "unsafe",
detail: `agent_image target path for '${declared}' escapes target agent-images library; rejected`,
});
continue;
}
if (routedDirNames.has(dirName)) {
records.push({
declaredPath: declared,
status: "conflict",
detail: `agent_image basename '${dirName}' collides with an earlier declared path; only the first is routed (banked collision-detection lesson)`,
});
continue;
}
fs.copyDir(sourceAbs, targetAbs);
routedDirNames.add(dirName);
records.push({
declaredPath: declared,
status: "routed",
installedAt: targetAbs,
});
}
const routedCount = records.filter((r) => r.status === "routed").length;
const rejectedCount = records.length - routedCount;
return { records, routedCount, rejectedCount };
}
@@ -0,0 +1,247 @@
import { describe, it, expect } from "vitest";
import { routeAgentImages, type AgentImagesRouterFsOps, type RouteAgentImagesInput } from "../src/domain/bundle-agent-images-router.js";
// Item 6 / slice-05 Checkpoint 7.3g step 2: bundle-agent-images-router
// pure-function tests. PRD line 197: declared paths are agent-image
// DIRECTORIES (not manifest paths — distinct from context_packs).
// All 4 banked router-level catches (file-existence, isDirectory check
// on manifest, declared-path-must-be-dir, basename collision) built in
// from the first commit per banked discipline.
function mockFs(initial: { dirs?: string[]; files?: string[] } = {}): AgentImagesRouterFsOps & {
_copyCalls: Array<{ src: string; dest: string }>;
_mkdirpCalls: string[];
_dirs: Set<string>;
_files: Set<string>;
} {
const dirs = new Set<string>(initial.dirs ?? []);
const files = new Set<string>(initial.files ?? []);
const copyCalls: Array<{ src: string; dest: string }> = [];
const mkdirpCalls: string[] = [];
return {
_copyCalls: copyCalls,
_mkdirpCalls: mkdirpCalls,
_dirs: dirs,
_files: files,
exists: (p: string) => dirs.has(p) || files.has(p),
isDirectory: (p: string) => dirs.has(p),
mkdirp: (p: string) => { mkdirpCalls.push(p); dirs.add(p); },
copyDir: (src: string, dest: string) => { copyCalls.push({ src, dest }); dirs.add(dest); },
};
}
/** Convenience: build a complete agent-image fixture (image dir + manifest.yaml file inside). */
function imageFixture(imageDir: string): { dirs: string[]; files: string[] } {
return { dirs: [imageDir], files: [`${imageDir}/manifest.yaml`] };
}
const BUNDLE_ROOT = "/bundle/root";
const TARGET = "/operator/.openrig/agent-images";
function makeInput(overrides?: Partial<RouteAgentImagesInput>): RouteAgentImagesInput {
return {
bundleRoot: BUNDLE_ROOT,
declaredAgentImages: [],
targetAgentImagesDir: TARGET,
...overrides,
};
}
describe("routeAgentImages", () => {
// A1: empty list → empty records + target dir mkdirp'd
it("empty declaredAgentImages produces empty records but still mkdirp's target", () => {
const fs = mockFs();
const result = routeAgentImages(makeInput(), fs);
expect(result.records).toEqual([]);
expect(result.routedCount).toBe(0);
expect(result.rejectedCount).toBe(0);
expect(fs._mkdirpCalls).toContain(TARGET);
});
// A2: routes one image: copyDir from sourceAbs (declared dir) to target/<basename>
it("routes one agent_image: declared dir copied to target/<basename>", () => {
const fs = mockFs(imageFixture(`${BUNDLE_ROOT}/agent-images/seat-a`));
const result = routeAgentImages(
makeInput({ declaredAgentImages: ["agent-images/seat-a"] }),
fs,
);
expect(result.routedCount).toBe(1);
expect(result.records[0]!.status).toBe("routed");
expect(result.records[0]!.installedAt).toBe(`${TARGET}/seat-a`);
expect(fs._copyCalls).toHaveLength(1);
expect(fs._copyCalls[0]).toEqual({
src: `${BUNDLE_ROOT}/agent-images/seat-a`,
dest: `${TARGET}/seat-a`,
});
});
// A3: multiple distinct images route correctly
it("routes multiple distinct agent_images each to target/<basename>", () => {
const fs = mockFs({
dirs: [`${BUNDLE_ROOT}/agent-images/seat-a`, `${BUNDLE_ROOT}/agent-images/seat-b`],
files: [
`${BUNDLE_ROOT}/agent-images/seat-a/manifest.yaml`,
`${BUNDLE_ROOT}/agent-images/seat-b/manifest.yaml`,
],
});
const result = routeAgentImages(
makeInput({
declaredAgentImages: [
"agent-images/seat-a",
"agent-images/seat-b",
],
}),
fs,
);
expect(result.routedCount).toBe(2);
expect(fs._copyCalls).toHaveLength(2);
});
// A4: declared source dir absent → status=missing
it("missing declared image dir → status=missing (honest-scoping)", () => {
const fs = mockFs();
const result = routeAgentImages(
makeInput({ declaredAgentImages: ["agent-images/absent"] }),
fs,
);
expect(result.routedCount).toBe(0);
expect(result.records[0]!.status).toBe("missing");
expect(result.records[0]!.detail).toContain("not present");
expect(fs._copyCalls).toHaveLength(0);
});
// A5: unsafe source path escaping bundle workspace rejected
it("unsafe source path escaping bundle workspace → status=unsafe", () => {
const fs = mockFs();
const result = routeAgentImages(
makeInput({ declaredAgentImages: ["../escape"] }),
fs,
);
expect(result.records[0]!.status).toBe("unsafe");
expect(result.records[0]!.detail).toContain("escapes bundle workspace");
expect(fs._copyCalls).toHaveLength(0);
});
// A6 (PRD-coherent discrimination): declared path exists but is a FILE
// not a directory. PRD line 197 says declared path is an agent-image
// DIRECTORY; if operator-declared path is a file, reject.
it("declared path exists but is a file (not directory) → status=not_directory", () => {
const fs = mockFs({ files: [`${BUNDLE_ROOT}/agent-images/seat-as-file`] });
const result = routeAgentImages(
makeInput({ declaredAgentImages: ["agent-images/seat-as-file"] }),
fs,
);
expect(result.records[0]!.status).toBe("not_directory");
expect(result.records[0]!.detail).toContain("not a directory");
expect(fs._copyCalls).toHaveLength(0);
});
// A7 (banked d491eca9 lesson): image dir exists but manifest.yaml inside
// is absent. Consumer skips the image — false-positive routedCount class.
it("image dir exists but manifest.yaml inside is absent → status=not_manifest", () => {
const fs = mockFs({
dirs: [`${BUNDLE_ROOT}/agent-images/halfimage`],
// NO manifest.yaml file
});
const result = routeAgentImages(
makeInput({ declaredAgentImages: ["agent-images/halfimage"] }),
fs,
);
expect(result.records[0]!.status).toBe("not_manifest");
expect(result.records[0]!.detail).toContain("missing manifest.yaml");
expect(fs._copyCalls).toHaveLength(0);
});
// A8 (banked 3cd581e3 lesson): image dir exists, manifest.yaml inside is
// itself a DIRECTORY (not file). Consumer's readFileSync throws — false-
// positive class. Reject.
it("image dir exists but manifest.yaml inside is itself a directory → status=not_manifest", () => {
const fs = mockFs({
dirs: [
`${BUNDLE_ROOT}/agent-images/dirmanifest`,
`${BUNDLE_ROOT}/agent-images/dirmanifest/manifest.yaml`,
],
});
const result = routeAgentImages(
makeInput({ declaredAgentImages: ["agent-images/dirmanifest"] }),
fs,
);
expect(result.records[0]!.status).toBe("not_manifest");
expect(result.records[0]!.detail).toContain("directory");
expect(fs._copyCalls).toHaveLength(0);
});
// A9 (banked 16ebb8af lesson): two declared images sharing basename →
// first wins (status=routed), second flagged status=conflict.
it("two declared images sharing basename → first routed, second conflict (truthful routedCount)", () => {
const fs = mockFs({
dirs: [
`${BUNDLE_ROOT}/a/seat-a`,
`${BUNDLE_ROOT}/b/seat-a`,
],
files: [
`${BUNDLE_ROOT}/a/seat-a/manifest.yaml`,
`${BUNDLE_ROOT}/b/seat-a/manifest.yaml`,
],
});
const result = routeAgentImages(
makeInput({
declaredAgentImages: ["a/seat-a", "b/seat-a"],
}),
fs,
);
expect(result.records).toHaveLength(2);
expect(result.routedCount).toBe(1);
expect(result.rejectedCount).toBe(1);
expect(result.records[0]!.status).toBe("routed");
expect(result.records[0]!.installedAt).toBe(`${TARGET}/seat-a`);
expect(result.records[1]!.status).toBe("conflict");
expect(result.records[1]!.detail).toContain("seat-a");
expect(result.records[1]!.detail).toContain("collides");
expect(fs._copyCalls).toHaveLength(1);
expect(fs._copyCalls[0]!.src).toBe(`${BUNDLE_ROOT}/a/seat-a`);
});
// A10: mixed list — routed + missing + unsafe + not_directory +
// not_manifest + conflict aggregate
it("mixed declared list aggregates correctly across all rejection classes", () => {
const fs = mockFs({
dirs: [
`${BUNDLE_ROOT}/agent-images/ok`,
`${BUNDLE_ROOT}/agent-images/halfimage`, // no manifest.yaml inside
`${BUNDLE_ROOT}/agent-images/dup`,
`${BUNDLE_ROOT}/elsewhere/dup`,
],
files: [
`${BUNDLE_ROOT}/agent-images/ok/manifest.yaml`,
`${BUNDLE_ROOT}/agent-images/seat-as-file`, // file at declared location
`${BUNDLE_ROOT}/agent-images/dup/manifest.yaml`,
`${BUNDLE_ROOT}/elsewhere/dup/manifest.yaml`,
],
});
const result = routeAgentImages(
makeInput({
declaredAgentImages: [
"agent-images/ok", // routed
"agent-images/absent", // missing
"../escape", // unsafe
"agent-images/seat-as-file", // not_directory
"agent-images/halfimage", // not_manifest (no manifest.yaml inside)
"agent-images/dup", // routed (1st dup)
"elsewhere/dup", // conflict (basename dup)
],
}),
fs,
);
expect(result.records).toHaveLength(7);
expect(result.routedCount).toBe(2);
expect(result.rejectedCount).toBe(5);
expect(result.records[0]!.status).toBe("routed");
expect(result.records[1]!.status).toBe("missing");
expect(result.records[2]!.status).toBe("unsafe");
expect(result.records[3]!.status).toBe("not_directory");
expect(result.records[4]!.status).toBe("not_manifest");
expect(result.records[5]!.status).toBe("routed");
expect(result.records[6]!.status).toBe("conflict");
});
});