feat(daemon): Agent Starter v1 vertical M2 — adapter pass-through + rig up integration

Wire AgentStarterResolver into pod-rig instantiation and prepend a STARTER
layer in `resolvedStartupFiles`, so an agent member with `starter_ref` flows
its starter artifacts through the existing `deliverStartup` adapter seam
without any adapter-side code change. Resolver-throw aborts the launch with
a clear startup error and no `deliverStartup` call (load-bearing
credential-safety contract — Finding 2).

Source changes:
- packages/daemon/src/domain/rigspec-instantiator.ts: in `launchExistingAgentMember`,
  invoke the resolver when `member.starterRef` is set; on
  `AgentStarterCredentialScanFailedError` / missing-entry / malformed-yaml,
  return `{status: "failed"}` before any adapter `deliverStartup` runs.
  STARTER artifacts are prepended ahead of `dedupedResolvedFiles` to form
  the new STARTER layer at the front of the layer chain.

New tests:
- agent-starter-instantiator.test.ts (5 tests): resolver invoked + STARTER
  prepended; resolver-throw aborts launch with no `deliverStartup` call;
  missing entry aborts; absent starterRef = no STARTER layer; rebuild
  composition allowed.
- agent-starter-adapter-claude.test.ts (1 test): claude-code adapter
  receives STARTER artifacts at front of `deliverStartup`.
- agent-starter-adapter-codex.test.ts (1 test): codex adapter receives
  STARTER artifacts at front of `deliverStartup`.

Updated tests:
- up-route.test.ts: 3 new M2 cases (positive plan-mode wiring proof,
  fork+starter_ref rejected per Finding 1, terminal+starter_ref rejected
  per secondary cleanup). Required threading optional `upRouterFsOps`
  through `createTestApp` so the route can read a real fixture spec.
- pod-rigspec-instantiator.test.ts: M1 forward-compat smoke now feeds the
  resolver a fixture registry entry (M2 actively reads the registry; M1
  test was a passthrough).
- packages/cli/test/up.test.ts: smoke proving `rig up <fixture-with-starter_ref> --plan`
  forwards the spec path to the daemon and surfaces "planned".

Marshal-acceptance:
- daemon vitest: 143 files / 2233 tests pass (M1 R2 baseline 2223 + 10
  new M2 tests).
- cli up.test.ts: 28/28 pass. Other CLI failures (preflight, chatroom,
  config-store) are environmental — host has a real daemon running on
  port 7433 — and pre-date M2.
This commit is contained in:
mvschwarz committed 2026-05-01 10:18:32 -07:00
1 parent 2673e30ec6
commit 2f8f26952b
8 files changed
+971 -29

No files matched your search

+64
View File
@@ -894,4 +894,68 @@ describe("Up CLI", () => {
expect(output).not.toContain("Restoring from manual snapshot");
expect(output).toContain('Rig "auto-rig" restored');
});
// Agent Starter v1 vertical M2 — CLI smoke proof for `rig up --plan`
// with a starter_ref-bearing fixture spec on disk.
//
// The CLI passes the resolved spec path to the daemon and surfaces the
// daemon's response. The proof is end-to-end: the spec exists, the CLI
// resolves the path, POSTs sourceRef, and prints "planned" when the
// daemon accepts it. The CLI itself does NOT validate spec contents —
// that's the daemon's job (covered by up-route.test.ts M2 cases).
it("up --plan smoke against a starter_ref fixture spec prints planned and forwards sourceRef", async () => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "cli-up-starter-"));
const specPath = path.join(tmpDir, "starter-fixture.yaml");
fs.writeFileSync(specPath, `version: "0.2"
name: starter-cli-smoke
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: local:agents/impl
profile: default
runtime: claude-code
cwd: .
starter_ref:
name: openrig-builder-base--claude-code
edges: []
edges: []
`, "utf-8");
let lastBody: Record<string, unknown> = {};
const origListeners = server.listeners("request");
server.removeAllListeners("request");
server.on("request", async (req: http.IncomingMessage, res: http.ServerResponse) => {
let body = "";
for await (const chunk of req) body += chunk;
if (req.url === "/api/up" && req.method === "POST") {
lastBody = JSON.parse(body);
res.writeHead(200, { "Content-Type": "application/json" });
res.end(JSON.stringify({ status: "planned", runId: "starter-run", stages: [{ stage: "resolve_spec", status: "ok" }], errors: [], warnings: [] }));
} else {
res.writeHead(404);
res.end();
}
});
try {
const { logs } = await captureLogs(async () => {
await makeCmd().parseAsync(["node", "rig", "up", specPath, "--plan"]);
});
const output = logs.join("\n");
expect(output).toContain("planned");
expect(lastBody.sourceRef).toBe(specPath);
expect(lastBody.plan).toBe(true);
} finally {
server.removeAllListeners("request");
for (const l of origListeners) server.on("request", l as (...args: unknown[]) => void);
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
@@ -1063,6 +1063,40 @@ export class PodRigInstantiator {
rebuildArtifactsOpt = { rebuildArtifacts: resolved.files };
}
// Agent Starter resolver dispatch (Agent Starter v1 vertical M2). When
// `member.starterRef` is set, resolve the named registry entry into a
// `ResolvedStartupFile[]` that prepends ahead of the member's per-agent
// and per-pod startup files (the new STARTER layer at the front of the
// layer chain). The resolver THROWS on a failed credential scan, missing
// registry entry, or malformed YAML; on any throw we abort the launch
// BEFORE `startNode` runs (no STARTER layer added; no adapter
// `deliverStartup` called) — load-bearing credential-safety contract per
// M1 R1 finding 2.
let starterArtifacts: import("./runtime-adapter.js").ResolvedStartupFile[] | undefined;
if (input.member.starterRef) {
const { AgentStarterResolver } = await import("./agent-starter-resolver.js");
const resolver = new AgentStarterResolver();
try {
const resolved = resolver.resolveStarter(input.member.starterRef.name);
starterArtifacts = resolved.files;
} catch (err) {
return {
status: "failed",
error: `Agent Starter resolver failed: ${(err as Error).message}`,
sessionName: canonicalSessionName,
warnings: launchResult.warnings,
};
}
}
// STARTER layer (artifact-seeded fresh-launch context; precedes per-agent
// and per-pod layers). Prepended to dedupedResolvedFiles so the existing
// `startupOrchestrator.startNode` consumes the combined chain via the
// existing `resolvedStartupFiles` input — NO new orchestrator branch.
const finalResolvedStartupFiles = starterArtifacts
? [...starterArtifacts, ...dedupedResolvedFiles]
: dedupedResolvedFiles;
const startupResult = await this.deps.startupOrchestrator.startNode({
rigId: input.rigId,
nodeId: input.nodeId,
@@ -1070,7 +1104,7 @@ export class PodRigInstantiator {
binding,
adapter,
plan: planResult.plan,
resolvedStartupFiles: dedupedResolvedFiles,
resolvedStartupFiles: finalResolvedStartupFiles,
startupActions: [
...configResult.config.startup.actions,
this.buildSessionIdentityAction({
@@ -0,0 +1,148 @@
// Tier 1 proof for the Agent Starter v1 vertical M2 — Claude adapter
// integration. Verifies that the STARTER layer artifacts (from
// AgentStarterResolver) flow through the existing
// `claude-code-adapter.deliverStartup` seam without any adapter-side
// code change. The resolver emits ResolvedStartupFile with
// `deliveryHint: "guidance_merge"` for the starter YAML; the adapter
// merges into CLAUDE.md.
import { describe, it, expect, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { PodRepository } from "../src/domain/pod-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
import type { RuntimeAdapter, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { RigSpec } from "../src/domain/types.js";
function mockTmux(): TmuxAdapter {
return {
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
sendText: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => []),
sendKeys: vi.fn(async () => ({ ok: true as const })),
} as unknown as TmuxAdapter;
}
function mockClaudeAdapter(): RuntimeAdapter {
return {
runtime: "claude-code",
listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
checkReady: vi.fn(async () => ({ ready: true })),
launchHarness: vi.fn(async () => ({ ok: true })),
};
}
const RIG_ROOT = "/project/rigs/test-rig";
const CLAUDE_STARTER = `draft: false
starter_id: claude-fixture-starter
runtime: claude-code
manifest_id: openrig-builder-base
manifest_version: "0.2"
session_source:
mode: fork
ref:
kind: native_id
value: "claude-fixture-native-id"
captured_at: 2026-05-01T00:00:00Z
captured_by: fixture
ready_check_evidence: ../evidence/fixture.md
status: captured
state: 2-named
`;
describe("Agent Starter v1 vertical — Claude adapter integration (M2)", () => {
it("claude-code-adapter.deliverStartup receives STARTER layer artifact when starterRef is set", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-adapter-claude-"));
const registryRoot = path.join(tmpDir, "registry");
fs.mkdirSync(registryRoot, { recursive: true });
fs.writeFileSync(path.join(registryRoot, "claude-fixture-starter.yaml"), CLAUDE_STARTER);
process.env.OPENRIG_AGENT_STARTER_ROOT = registryRoot;
try {
const db = createFullTestDb();
const rigRepo = new RigRepository(db);
const podRepo = new PodRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const tmux = mockTmux();
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
const claudeAdapter = mockClaudeAdapter();
const fsOps: AgentResolverFsOps = {
readFile: (p: string) => {
if (p === `${RIG_ROOT}/agents/impl/agent.yaml`) {
return `name: impl\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []`;
}
throw new Error(`Not found: ${p}`);
},
exists: (p: string) => p === `${RIG_ROOT}/agents/impl/agent.yaml`,
};
const inst = new PodRigInstantiator({
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
startupOrchestrator: startupOrch, fsOps,
adapters: {
"claude-code": claudeAdapter,
"codex": mockClaudeAdapter(),
"terminal": mockClaudeAdapter(),
},
tmuxAdapter: tmux,
});
const spec: RigSpec = {
version: "0.2",
name: "claude-starter-rig",
pods: [{
id: "dev",
label: "Dev",
members: [{
id: "impl",
agentRef: "local:agents/impl",
profile: "default",
runtime: "claude-code",
cwd: ".",
starterRef: { name: "claude-fixture-starter" },
}],
edges: [],
}],
edges: [],
};
const yaml = RigSpecCodec.serialize(spec);
const result = await inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(true);
const deliverStartupSpy = claudeAdapter.deliverStartup as ReturnType<typeof vi.fn>;
expect(deliverStartupSpy).toHaveBeenCalled();
const files = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
// STARTER layer prepended at front
expect(files[0]!.path).toBe("claude-fixture-starter.yaml");
expect(files[0]!.ownerRoot).toBe(registryRoot);
expect(files[0]!.appliesOn).toEqual(["fresh_start"]);
expect(files[0]!.required).toBe(true);
expect(files[0]!.deliveryHint).toBe("guidance_merge");
db.close();
} finally {
delete process.env.OPENRIG_AGENT_STARTER_ROOT;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,146 @@
// Tier 1 proof for the Agent Starter v1 vertical M2 — Codex adapter
// integration. Mirror of the Claude adapter test but for Codex.
// Verifies that the STARTER layer artifacts (from AgentStarterResolver)
// flow through the existing `codex-runtime-adapter.deliverStartup`
// seam without any adapter-side code change.
import { describe, it, expect, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { PodRepository } from "../src/domain/pod-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
import type { RuntimeAdapter, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { RigSpec } from "../src/domain/types.js";
function mockTmux(): TmuxAdapter {
return {
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
sendText: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => []),
sendKeys: vi.fn(async () => ({ ok: true as const })),
} as unknown as TmuxAdapter;
}
function mockCodexAdapter(): RuntimeAdapter {
return {
runtime: "codex",
listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
checkReady: vi.fn(async () => ({ ready: true })),
launchHarness: vi.fn(async () => ({ ok: true })),
};
}
const RIG_ROOT = "/project/rigs/test-rig";
const CODEX_STARTER = `draft: false
starter_id: codex-fixture-starter
runtime: codex
manifest_id: openrig-builder-base
manifest_version: "0.2"
session_source:
mode: fork
ref:
kind: native_id
value: "codex-fixture-native-id"
captured_at: 2026-05-01T00:00:00Z
captured_by: fixture
ready_check_evidence: ../evidence/fixture.md
status: captured
state: 2-named
`;
describe("Agent Starter v1 vertical — Codex adapter integration (M2)", () => {
it("codex-runtime-adapter.deliverStartup receives STARTER layer artifact when starterRef is set", async () => {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-adapter-codex-"));
const registryRoot = path.join(tmpDir, "registry");
fs.mkdirSync(registryRoot, { recursive: true });
fs.writeFileSync(path.join(registryRoot, "codex-fixture-starter.yaml"), CODEX_STARTER);
process.env.OPENRIG_AGENT_STARTER_ROOT = registryRoot;
try {
const db = createFullTestDb();
const rigRepo = new RigRepository(db);
const podRepo = new PodRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const tmux = mockTmux();
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
const codexAdapter = mockCodexAdapter();
const fsOps: AgentResolverFsOps = {
readFile: (p: string) => {
if (p === `${RIG_ROOT}/agents/impl/agent.yaml`) {
return `name: impl\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []`;
}
throw new Error(`Not found: ${p}`);
},
exists: (p: string) => p === `${RIG_ROOT}/agents/impl/agent.yaml`,
};
const inst = new PodRigInstantiator({
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
startupOrchestrator: startupOrch, fsOps,
adapters: {
"claude-code": mockCodexAdapter(),
"codex": codexAdapter,
"terminal": mockCodexAdapter(),
},
tmuxAdapter: tmux,
});
const spec: RigSpec = {
version: "0.2",
name: "codex-starter-rig",
pods: [{
id: "dev",
label: "Dev",
members: [{
id: "impl",
agentRef: "local:agents/impl",
profile: "default",
runtime: "codex",
cwd: ".",
starterRef: { name: "codex-fixture-starter" },
}],
edges: [],
}],
edges: [],
};
const yaml = RigSpecCodec.serialize(spec);
const result = await inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(true);
const deliverStartupSpy = codexAdapter.deliverStartup as ReturnType<typeof vi.fn>;
expect(deliverStartupSpy).toHaveBeenCalled();
const files = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
// STARTER layer prepended at front
expect(files[0]!.path).toBe("codex-fixture-starter.yaml");
expect(files[0]!.ownerRoot).toBe(registryRoot);
expect(files[0]!.appliesOn).toEqual(["fresh_start"]);
expect(files[0]!.required).toBe(true);
expect(files[0]!.deliveryHint).toBe("guidance_merge");
db.close();
} finally {
delete process.env.OPENRIG_AGENT_STARTER_ROOT;
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
@@ -0,0 +1,337 @@
// Tier 1 proof for the Agent Starter v1 vertical M2 — instantiator
// integration. M1 shipped the schema + resolver scaffolding; M2 wires
// the resolver into `launchExistingAgentMember`, prepending the resolved
// starter artifacts as a STARTER layer ahead of the per-agent /
// per-pod startup files.
//
// Key invariants:
// - Resolver invoked when `member.starterRef` is set.
// - STARTER layer prepended in `resolvedStartupFiles` (not a separate
// orchestrator branch).
// - Resolver THROW (any cause: missing entry, malformed YAML, failed
// credential scan) aborts the launch BEFORE `startNode` runs — no
// adapter `deliverStartup` is called and no STARTER layer lands
// downstream. Load-bearing credential-safety contract per M1 R1
// finding 2.
// - Composition with `session_source.mode: "rebuild"` works
// independently (both apply on fresh_start; per slice schema).
import { describe, it, expect, vi } from "vitest";
import fs from "node:fs";
import os from "node:os";
import path from "node:path";
import { createFullTestDb } from "./helpers/test-app.js";
import { RigRepository } from "../src/domain/rig-repository.js";
import { PodRepository } from "../src/domain/pod-repository.js";
import { SessionRegistry } from "../src/domain/session-registry.js";
import { EventBus } from "../src/domain/event-bus.js";
import { NodeLauncher } from "../src/domain/node-launcher.js";
import { StartupOrchestrator } from "../src/domain/startup-orchestrator.js";
import { PodRigInstantiator } from "../src/domain/rigspec-instantiator.js";
import { RigSpecCodec } from "../src/domain/rigspec-codec.js";
import type { AgentResolverFsOps } from "../src/domain/agent-resolver.js";
import type { RuntimeAdapter, ResolvedStartupFile } from "../src/domain/runtime-adapter.js";
import type { TmuxAdapter } from "../src/adapters/tmux.js";
import type { RigSpec } from "../src/domain/types.js";
function mockTmux(): TmuxAdapter {
return {
createSession: vi.fn(async () => ({ ok: true as const })),
killSession: vi.fn(async () => ({ ok: true as const })),
sendText: vi.fn(async () => ({ ok: true as const })),
hasSession: vi.fn(async () => true),
listSessions: vi.fn(async () => []),
listWindows: vi.fn(async () => []),
listPanes: vi.fn(async () => []),
sendKeys: vi.fn(async () => ({ ok: true as const })),
} as unknown as TmuxAdapter;
}
function mockAdapter(runtime = "claude-code"): RuntimeAdapter {
return {
runtime,
listInstalled: vi.fn(async () => []),
project: vi.fn(async () => ({ projected: [], skipped: [], failed: [] })),
deliverStartup: vi.fn(async () => ({ delivered: 0, failed: [] })),
checkReady: vi.fn(async () => ({ ready: true })),
launchHarness: vi.fn(async () => ({ ok: true })),
};
}
function mockFs(files: Record<string, string>): AgentResolverFsOps {
return {
readFile: (p: string) => { if (p in files) return files[p]!; throw new Error(`Not found: ${p}`); },
exists: (p: string) => p in files,
};
}
const RIG_ROOT = "/project/rigs/test-rig";
function agentYaml(name: string): string {
return `name: ${name}\nversion: "1.0.0"\nresources:\n skills: []\nprofiles:\n default:\n uses:\n skills: []`;
}
function specWithStarterRef(): RigSpec {
return {
version: "0.2",
name: "test-rig",
pods: [{
id: "dev",
label: "Dev",
members: [{
id: "impl",
agentRef: "local:agents/impl",
profile: "default",
runtime: "claude-code",
cwd: ".",
starterRef: { name: "fixture-starter" },
}],
edges: [],
}],
edges: [],
};
}
const CLEAN_STARTER = `draft: false
starter_id: fixture-starter
runtime: claude-code
manifest_id: openrig-builder-base
manifest_version: "0.2"
session_source:
mode: fork
ref:
kind: native_id
value: "fixture-native-id"
captured_at: 2026-05-01T00:00:00Z
captured_by: fixture
ready_check_evidence: ../evidence/fixture.md
status: captured
state: 2-named
`;
const MALICIOUS_STARTER = `draft: false
starter_id: fixture-mal
runtime: claude-code
manifest_id: openrig-builder-base
manifest_version: "0.2"
session_source:
mode: fork
ref:
kind: native_id
value: "x"
captured_at: 2026-05-01T00:00:00Z
captured_by: fixture
ready_check_evidence: ../evidence/fixture.md
status: captured
state: 2-named
api_key: example-not-real
`;
function setupWithStarter(opts: {
starterContent?: string | null; // null → registry directory empty
starterFilename?: string; // default: fixture-starter.yaml
}) {
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-instantiator-"));
const registryRoot = path.join(tmpDir, "registry");
fs.mkdirSync(registryRoot, { recursive: true });
if (opts.starterContent) {
const filename = opts.starterFilename ?? "fixture-starter.yaml";
fs.writeFileSync(path.join(registryRoot, filename), opts.starterContent);
}
// Tell the resolver to use this fixture directory via the documented
// env-var lookup branch.
process.env.OPENRIG_AGENT_STARTER_ROOT = registryRoot;
const db = createFullTestDb();
const rigRepo = new RigRepository(db);
const podRepo = new PodRepository(db);
const sessionRegistry = new SessionRegistry(db);
const eventBus = new EventBus(db);
const tmux = mockTmux();
const nodeLauncher = new NodeLauncher({ db, rigRepo, sessionRegistry, eventBus, tmuxAdapter: tmux });
const startupOrch = new StartupOrchestrator({ db, sessionRegistry, eventBus, tmuxAdapter: tmux });
const adapter = mockAdapter("claude-code");
const codexAdapter = mockAdapter("codex");
const fsOps = mockFs({ [`${RIG_ROOT}/agents/impl/agent.yaml`]: agentYaml("impl") });
const inst = new PodRigInstantiator({
db, rigRepo, podRepo, sessionRegistry, eventBus, nodeLauncher,
startupOrchestrator: startupOrch,
fsOps,
adapters: { "claude-code": adapter, "codex": codexAdapter, "terminal": mockAdapter("terminal") },
tmuxAdapter: tmux,
});
const cleanup = () => {
delete process.env.OPENRIG_AGENT_STARTER_ROOT;
db.close();
fs.rmSync(tmpDir, { recursive: true, force: true });
};
return { db, rigRepo, sessionRegistry, eventBus, inst, adapter, codexAdapter, tmux, registryRoot, cleanup };
}
describe("Agent Starter v1 vertical — instantiator integration (M2)", () => {
it("invokes resolver and prepends STARTER layer when member.starterRef is set", async () => {
const ctx = setupWithStarter({ starterContent: CLEAN_STARTER });
try {
const yaml = RigSpecCodec.serialize(specWithStarterRef());
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(true);
// adapter.deliverStartup should have been called; the first arg is the
// ResolvedStartupFile[] that includes the STARTER layer at the front.
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
expect(deliverStartupSpy).toHaveBeenCalled();
const filesArg = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
// STARTER layer prepended: the first ResolvedStartupFile must come from
// the registryRoot we set up.
expect(filesArg.length).toBeGreaterThan(0);
expect(filesArg[0]!.ownerRoot).toBe(ctx.registryRoot);
expect(filesArg[0]!.path).toBe("fixture-starter.yaml");
expect(filesArg[0]!.appliesOn).toEqual(["fresh_start"]);
} finally {
ctx.cleanup();
}
});
it("aborts launch when resolver throws — adapter.deliverStartup NOT called", async () => {
// Malicious starter (api_key field) → resolver throws
// AgentStarterCredentialScanFailedError → instantiator returns failed
// before adapter.deliverStartup runs. Load-bearing credential-safety
// contract per M1 R1 finding 2.
const ctx = setupWithStarter({ starterContent: MALICIOUS_STARTER });
try {
const yaml = RigSpecCodec.serialize(specWithStarterRef());
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
// Some node failed (the impl member) — instantiator may still report
// ok=true with a failed node entry, depending on partial-failure
// policy. The load-bearing assertion is: deliverStartup was NEVER
// called for this node, AND the node's status is "failed".
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
expect(deliverStartupSpy).not.toHaveBeenCalled();
if (result.ok) {
const node = result.result.nodes.find((n) => n.logicalId === "dev.impl");
expect(node?.status).toBe("failed");
expect(node?.error).toContain("Agent Starter resolver failed");
} else {
// result.ok=false is also acceptable closure
expect(result.ok).toBe(false);
}
} finally {
ctx.cleanup();
}
});
it("aborts launch when starter registry entry is missing (resolver throws)", async () => {
// No registry file → resolver throws "no registry entry found".
const ctx = setupWithStarter({ starterContent: null });
try {
const yaml = RigSpecCodec.serialize(specWithStarterRef());
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
expect(deliverStartupSpy).not.toHaveBeenCalled();
if (result.ok) {
const node = result.result.nodes.find((n) => n.logicalId === "dev.impl");
expect(node?.status).toBe("failed");
expect(node?.error).toContain("Agent Starter resolver failed");
}
} finally {
ctx.cleanup();
}
});
it("does NOT invoke resolver when member.starterRef is absent (no STARTER layer)", async () => {
const ctx = setupWithStarter({ starterContent: CLEAN_STARTER });
try {
// Spec without starterRef on the member.
const spec: RigSpec = {
version: "0.2",
name: "no-starter-rig",
pods: [{
id: "dev",
label: "Dev",
members: [{
id: "impl",
agentRef: "local:agents/impl",
profile: "default",
runtime: "claude-code",
cwd: ".",
}],
edges: [],
}],
edges: [],
};
const yaml = RigSpecCodec.serialize(spec);
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(true);
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
expect(deliverStartupSpy).toHaveBeenCalled();
const filesArg = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
// No file from the registryRoot should appear in the chain.
const fromRegistry = filesArg.find((f) => f.ownerRoot === ctx.registryRoot);
expect(fromRegistry).toBeUndefined();
} finally {
ctx.cleanup();
}
});
it("composition: starterRef + sessionSource.mode='rebuild' both fire on fresh_start", async () => {
// Create a real artifact file that the rebuild resolver can find.
const tmpDir = fs.mkdtempSync(path.join(os.tmpdir(), "starter-rebuild-"));
const rebuildArtifactPath = path.join(tmpDir, "rebuild-artifact.md");
fs.writeFileSync(rebuildArtifactPath, "rebuild context fixture");
const ctx = setupWithStarter({ starterContent: CLEAN_STARTER });
try {
const spec: RigSpec = {
version: "0.2",
name: "compose-rig",
pods: [{
id: "dev",
label: "Dev",
members: [{
id: "impl",
agentRef: "local:agents/impl",
profile: "default",
runtime: "claude-code",
cwd: ".",
starterRef: { name: "fixture-starter" },
sessionSource: {
mode: "rebuild",
ref: { kind: "artifact_set", value: [rebuildArtifactPath] },
},
}],
edges: [],
}],
edges: [],
};
const yaml = RigSpecCodec.serialize(spec);
const result = await ctx.inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(true);
// Adapter.deliverStartup receives the STARTER layer prepended.
// The rebuild artifacts are passed via a separate `rebuildArtifacts`
// kwarg to startNode (not via resolvedStartupFiles), so they don't
// appear in deliverStartup's files arg — but the launch should
// still succeed.
const deliverStartupSpy = ctx.adapter.deliverStartup as ReturnType<typeof vi.fn>;
expect(deliverStartupSpy).toHaveBeenCalled();
const filesArg = deliverStartupSpy.mock.calls[0]![0] as ResolvedStartupFile[];
// STARTER layer is at the front
expect(filesArg[0]!.ownerRoot).toBe(ctx.registryRoot);
} finally {
ctx.cleanup();
fs.rmSync(tmpDir, { recursive: true, force: true });
}
});
});
+20 -2
View File
@@ -124,6 +124,17 @@ export function createTestApp(
adapters?: Partial<Record<string, RuntimeAdapter>>;
activityHookToken?: string;
activityFreshnessMs?: number;
/**
* Agent Starter v1 vertical M2: optional real-fs upRouter for tests
* that POST /api/up with a YAML spec on disk. Default behavior
* (always-false fsOps) preserved when this is omitted, so existing
* rig-name-path tests are not affected.
*/
upRouterFsOps?: {
exists: (p: string) => boolean;
readFile: (p: string) => string;
readHead: (p: string, n: number) => Buffer;
};
},
) {
const rigRepo = new RigRepository(db);
@@ -225,6 +236,13 @@ export function createTestApp(
const podBundleSourceResolver = new PodBundleSourceResolver();
const upRouterFs = opts?.upRouterFsOps ?? {
exists: (_p: string) => false,
readFile: (_p: string) => "",
readHead: (_p: string, _n: number) => Buffer.alloc(0),
};
const upRouter = new UpCommandRouter({ fsOps: upRouterFs });
const app = createApp({
rigRepo, sessionRegistry, eventBus, nodeLauncher, tmuxAdapter: tmux, cmuxAdapter: cmux,
snapshotCapture, snapshotRepo, restoreOrchestrator,
@@ -234,7 +252,7 @@ export function createTestApp(
discoveryCoordinator, discoveryRepo, claimService, selfAttachService, rigExpansionService,
rigLifecycleService,
psProjectionService: new PsProjectionService({ db }),
upRouter: new UpCommandRouter({ fsOps: { exists: () => false, readFile: () => "", readHead: () => Buffer.alloc(0) } }),
upRouter,
teardownOrchestrator: new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux, snapshotCapture, eventBus }),
podInstantiator,
podBundleSourceResolver,
@@ -253,7 +271,7 @@ export function createTestApp(
discoveryCoordinator, discoveryRepo, claimService, selfAttachService, rigExpansionService, tmuxScanner,
rigLifecycleService,
psProjectionService: new PsProjectionService({ db }),
upRouter: new UpCommandRouter({ fsOps: { exists: () => false, readFile: () => "", readHead: () => Buffer.alloc(0) } }),
upRouter,
teardownOrchestrator: new RigTeardownOrchestrator({ db, rigRepo, sessionRegistry, tmuxAdapter: tmux, snapshotCapture, eventBus }),
podInstantiator, podBundleSourceResolver, db, tmuxAdapter: tmux,
agentActivityStore,
@@ -660,33 +660,67 @@ profiles:
// pass — the existing pipeline always handled YAML without
// `starter_ref:` already.
it("forward-compat: pod with starter_ref on a member instantiates without error AND the codec emits it (R2 repair)", async () => {
const { db, inst, rigRepo } = setup();
const yaml = RigSpecCodec.serialize(makeRigSpec({
pods: [{
id: "dev",
label: "Dev",
members: [{
id: "impl",
agentRef: "local:agents/impl",
profile: "default",
runtime: "claude-code",
cwd: ".",
starterRef: { name: "fixture-starter" },
// M2 update: the resolver now actively reads the registry when
// starterRef is set (M2.1/M2.2 wiring). Provide a fixture registry
// entry so the resolver succeeds; the smoke proves the field flows
// through to launch without breaking the existing pipeline. The
// dedicated end-to-end + abort behaviors live in
// agent-starter-instantiator.test.ts; this test stays as a
// forward-compat regression catch.
// eslint-disable-next-line @typescript-eslint/no-require-imports
const fs = await import("node:fs");
const os = await import("node:os");
const path = await import("node:path");
const tmpRegistry = fs.mkdtempSync(path.join(os.tmpdir(), "starter-fwd-compat-"));
fs.writeFileSync(path.join(tmpRegistry, "fixture-starter.yaml"), `draft: false
starter_id: fixture-starter
runtime: claude-code
manifest_id: openrig-builder-base
manifest_version: "0.2"
session_source:
mode: fork
ref:
kind: native_id
value: "fwd-compat-fixture"
captured_at: 2026-05-01T00:00:00Z
captured_by: fixture
ready_check_evidence: ../evidence/fixture.md
status: captured
state: 2-named
`);
process.env.OPENRIG_AGENT_STARTER_ROOT = tmpRegistry;
try {
const { db, inst, rigRepo } = setup();
const yaml = RigSpecCodec.serialize(makeRigSpec({
pods: [{
id: "dev",
label: "Dev",
members: [{
id: "impl",
agentRef: "local:agents/impl",
profile: "default",
runtime: "claude-code",
cwd: ".",
starterRef: { name: "fixture-starter" },
}],
edges: [],
}],
edges: [],
}],
}));
// R2: prove the serialized YAML actually carries the new field, then
// verify instantiation still succeeds with it present.
expect(yaml).toContain("starter_ref:");
expect(yaml).toContain("fixture-starter");
const result = await inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(true);
if (result.ok) {
const rig = rigRepo.getRig(result.result.rigId);
expect(rig).not.toBeNull();
expect(rig!.nodes).toHaveLength(1);
}));
// R2: prove the serialized YAML actually carries the new field, then
// verify instantiation still succeeds with it present.
expect(yaml).toContain("starter_ref:");
expect(yaml).toContain("fixture-starter");
const result = await inst.instantiate(yaml, RIG_ROOT);
expect(result.ok).toBe(true);
if (result.ok) {
const rig = rigRepo.getRig(result.result.rigId);
expect(rig).not.toBeNull();
expect(rig!.nodes).toHaveLength(1);
}
db.close();
} finally {
delete process.env.OPENRIG_AGENT_STARTER_ROOT;
fs.rmSync(tmpRegistry, { recursive: true, force: true });
}
db.close();
});
});
+161
View File
@@ -188,4 +188,165 @@ describe("Up API route", () => {
expect(body.error).not.toContain("auto-pre-down snapshot");
});
});
// Agent Starter v1 vertical M2 — POST /api/up wiring proof for starter_ref.
//
// These tests exercise the spec-resolution path (sourceKind=rig_spec) by
// injecting a real-fs upRouter into createTestApp. The positive case
// demonstrates that a pod-aware spec with `starter_ref:` flows through
// validation cleanly. The two negatives demonstrate that the schema's
// composition rules surface as 400 responses through the route.
//
// The "failed-scan launch abort" negative is intentionally NOT tested at
// this level — the resolver runs only at apply-time inside the
// instantiator, and that behavior is already proven in
// `agent-starter-instantiator.test.ts` ("resolver throw aborts launch").
// Re-proving it through the HTTP layer would require fully wiring the
// bootstrap apply path with real fixtures, which is broader than this
// wiring proof needs.
describe("M2: POST /api/up with starter_ref", () => {
let specDir: string;
let app2: ReturnType<typeof createTestApp>["app"];
let db2: Database.Database;
beforeEach(() => {
specDir = fs.mkdtempSync(path.join(os.tmpdir(), "up-route-starter-"));
db2 = createFullTestDb();
const fsOps = {
exists: (p: string) => fs.existsSync(p),
readFile: (p: string) => fs.readFileSync(p, "utf-8"),
readHead: (p: string, n: number) => {
const buf = Buffer.alloc(n);
const fd = fs.openSync(p, "r");
try {
fs.readSync(fd, buf, 0, n, 0);
} finally {
fs.closeSync(fd);
}
return buf;
},
};
const setup = createTestApp(db2, { upRouterFsOps: fsOps });
app2 = setup.app;
});
afterEach(() => {
db2.close();
fs.rmSync(specDir, { recursive: true, force: true });
});
function writeSpec(name: string, body: string): string {
const p = path.join(specDir, name);
fs.writeFileSync(p, body, "utf-8");
return p;
}
it("accepts a pod-aware spec with starter_ref in plan mode (positive wiring proof)", async () => {
const yaml = `version: "0.2"
name: starter-positive
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: local:agents/impl
profile: default
runtime: claude-code
cwd: .
starter_ref:
name: openrig-builder-base--claude-code
edges: []
edges: []
`;
const specPath = writeSpec("starter-positive.yaml", yaml);
const res = await app2.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: specPath, plan: true }),
});
// The upRouter classifies as rig_spec (validation passed). Bootstrap
// plan may fail later stages (e.g., agent file resolution against an
// empty test cwd), but the spec MUST clear validation — proving
// `starter_ref` does not trip the schema. Status 200/400 are both
// acceptable wiring proofs as long as the body is NOT a top-level
// validation_failed error from upRouter.route().
const body = await res.json();
expect(body.error ?? "").not.toContain("not a valid rig spec");
expect(body.error ?? "").not.toContain("does not match composition rules");
});
it("rejects fork + starter_ref composition with 400 (terminal-equivalent route surface)", async () => {
const yaml = `version: "0.2"
name: starter-fork-reject
pods:
- id: dev
label: Development
members:
- id: impl
agent_ref: local:agents/impl
profile: default
runtime: claude-code
cwd: .
starter_ref:
name: openrig-builder-base--claude-code
session_source:
mode: fork
ref:
kind: native_id
value: some-id
edges: []
edges: []
`;
const specPath = writeSpec("starter-fork-reject.yaml", yaml);
const res = await app2.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: specPath, plan: true }),
});
// Pod-aware composition rule (validateStarterRef in rigspec-schema)
// rejects fork+starter_ref, so the upRouter does NOT classify the
// YAML as a valid pod-aware rig_spec. The route returns 400.
// (Pre-existing UX caveat: the error message bubbles from the
// legacy fallthrough, not from the pod-aware validator — but the
// contract behavior of REJECTING the spec is what M2 requires.)
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain("not a valid rig spec");
});
it("rejects terminal + starter_ref composition with 400", async () => {
const yaml = `version: "0.2"
name: starter-terminal-reject
pods:
- id: dev
label: Development
members:
- id: t1
agent_ref: local:agents/t1
profile: default
runtime: terminal
cwd: .
starter_ref:
name: openrig-builder-base--claude-code
edges: []
edges: []
`;
const specPath = writeSpec("starter-terminal-reject.yaml", yaml);
const res = await app2.request("/api/up", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ sourceRef: specPath, plan: true }),
});
// Pod-aware composition rule rejects terminal+starter_ref. Route
// returns 400 (same caveat as the fork case above re: error
// message provenance — the contract behavior of rejection is what
// M2 requires).
expect(res.status).toBe(400);
const body = await res.json();
expect(body.error).toContain("not a valid rig spec");
});
});
});