feat(daemon): Agent Starter v1 vertical M1 — schema + resolver scaffolding

M1 milestone of the agent-starter-v1-vertical slice. Schema + resolver
scaffolding only; M2 wires the resolver into the launch path; M3/M4
are real-runtime e2e proofs; M5 is disposable Tart VM proof.

THIS COMMIT IS M1 ONLY. M2/M3/M4/M5 surfaces are explicitly NOT touched
per delivery-orch directive ("marshal-acceptance gate at M1 must pass
before M2 dispatches separately").

What M1 ships (6 files, +361 / -1):
=================================

Schema (3 files):
- packages/daemon/src/domain/types.ts: new `StarterRefSpec` interface +
  `starterRef?: StarterRefSpec` field on `RigSpecPodMember` after
  `sessionSource?`. Doc-comment notes: artifact-seeded fresh-launch
  context; composes additively with `session_source`.
- packages/daemon/src/domain/rigspec-schema.ts: new
  `validateStarterRef()` invocation after `validateSessionSource` at
  line 308, plus the function itself enforcing four rules:
    1. malformed `name` rejection (must be alphanumeric + optional
       `_`/`-`; path-traversal guard);
    2. terminal runtime + `starter_ref` rejection (analogous to
       existing terminal `session_source` rejection at line 321 —
       terminal `deliverStartup` is a no-op so a starter has nothing
       to seed);
    3. **`starter_ref` + `session_source.mode="fork"` REJECTION**
       (per slice review-independent finding 1 — the v1+ "Real
       native-fork-from-registered-thread-id starter proof" trigger
       covers that composition);
    4. `starter_ref` + `session_source.mode="rebuild"` ACCEPTED
       (additive: both apply on `fresh_start`; rebuild artifacts and
       starter artifacts compose independently in the launch pipeline).
  Plus new `normalizeStarterRef` helper invoked at line 758 of the
  pod-aware normalization map.

Resolver (1 new file):
- packages/daemon/src/domain/agent-starter-resolver.ts (NEW): new
  `AgentStarterResolver` class with `resolveStarter(name)` returning
  `{ files: ResolvedStartupFile[], registryPath }`. Plus
  `AgentStarterCredentialScanFailedError` class. Resolver semantics:
    - Constructor lookup chain (via static helper):
      `opts.registryRoot` > `env[envVarName]` > `homeDirRoot` (if
      exists) > `substrateFallback`. Default env var name is
      `OPENRIG_AGENT_STARTER_ROOT`; default home subpath is
      `~/.openrig/agent-starters/`; default substrate fallback is
      the v0 prototype location.
    - `resolveStarter(name)`:
      * THROWS on invalid name (path-traversal guard).
      * THROWS on missing registry entry.
      * THROWS on empty file or malformed YAML (best-effort: requires
        `starter_id:` field; M1 doesn't pull in a YAML parser to keep
        the dependency surface narrow — M2 can swap in real parsing
        when it walks the priming-pack manifest).
      * **THROWS `AgentStarterCredentialScanFailedError` on a failed
        no-credentials scan** (per review-independent finding 2 —
        resolver does NOT return a structured "ok: false" the
        orchestrator could ignore; failed scan is a hard refusal point).
      * On clean scan: emits `ResolvedStartupFile[]` rooted at the
        registry directory. M1 scaffolding shape: one entry per
        starter (the registry YAML itself, marked
        `deliveryHint: "guidance_merge"`, `appliesOn: ["fresh_start"]`).
        M2 can extend the resolver to walk the priming-pack manifest's
        `read_full` paths if M3/M4 e2e proof needs richer per-layer
        artifacts; the type contract and refusal semantics are what
        M1 locks in.
- No-credentials scan implementation mirrors v0 wrapper at
  `specs/agent-starters/lib/agent-starter-helpers.sh::scan_starter_file`:
  three deny families (literal credential paths;
  `(credentials|auth\.json|secrets|tokens?\.json)` path regex;
  case-insensitive credential-string regex including `api_key`,
  `secret_key`, `bearer_token`, `sk-` token shapes, `gh[ps]_` token
  shapes, `password=`/`password:`). Allowlist exception: `transcript_path`
  values under `~/.claude/projects/` or `~/.openrig/transcripts/` are
  accepted; same paths under any other field/key/comment are rejected.

Tests (3 files):
- packages/daemon/test/agent-starter-schema.test.ts (NEW, 12 tests):
  acceptance (alone, with rebuild composition); codec roundtrip
  (normalize produces `starterRef`; absent → undefined; preserved
  alongside `sessionSource`); rejection (empty/non-string/non-object
  name; disallowed characters; path-traversal); composition rejection
  (fork; terminal runtime); forward-compat (existing specs validate).
- packages/daemon/test/agent-starter-resolver.test.ts (NEW, 14 tests):
  lookup-chain branches (4 tests covering opts.registryRoot, env var,
  homeDirRoot when exists, substrateFallback when home doesn't exist);
  successful resolve emits `ResolvedStartupFile[]` with correct
  `ownerRoot`/`appliesOn`/`required`; credential-scan refusal (THROWS
  on path match; THROWS on content match; THROWS on case-insensitive
  uppercase API_KEY); allowlist exception (transcript_path under
  `~/.claude/projects/` accepted); error throws (missing entry; missing
  `starter_id:` field; empty file; invalid name with traversal chars;
  invalid name with slash).
- packages/daemon/test/pod-rigspec-instantiator.test.ts (UPDATE, +1
  test): forward-compat smoke that a pod-aware spec carrying
  `starterRef` on a member instantiates without error (M2 will wire
  it into the launch path; M1 verifies no regression).

M1 Marshal-Acceptance Gate (per IMPL § M1):
- [x] All new schema/normalization/codec tests green INCLUDING:
  - rebuild composition accepted ✓
  - fork composition rejected with explicit error (Finding 1) ✓
  - terminal + starter_ref rejected with explicit error ✓
- [x] All resolver fetch tests green INCLUDING:
  - no-credentials negative case throws
    AgentStarterCredentialScanFailedError (Finding 2) ✓
- [x] Registry-root lookup chain tests green for all four branches ✓
- [x] No regression in existing rigspec-schema.test.ts (33/33) ✓
- [x] Posture: full QA/testing window (declared) ✓

Daemon full regression: 2216/2216 passing (was 2189 + 1 skipped at
codex-auth-refusal closeout / `53e9409`; now 2216 + 0 skipped — +27
net new tests; the 1 prior skip was the documented codex auth-refusal
deferral that revision-2 closed). 140 test files (was 138; +2 new files).

STOP-AND-ESCALATE triggers (per delivery-orch verbatim) all clean:
- No M2/M3/M4/M5 surface touched (instantiator left untouched at the
  `launchExistingAgentMember` site; adapter `deliverStartup` left
  untouched; CLI `up.ts` left untouched; route `up.ts` left untouched)
- No `starter_ref + session_source.mode=fork` accepted (Finding 1
  enforced)
- Credential-scan hard refusal preserved (Finding 2 enforced — resolver
  THROWS, does not return)
- No M5 shrinkage; M5 still requires both Claude AND Codex disposable
  VM proof
- No fleet-ok-posture for any milestone past M2
- Cross-runtime alignment flag from prior codex-auth-refusal slice
  preserved as follow-up (NOT settled)

Greenfield confirmation (planner-verified at audit time, re-confirmed
at commit):
- `grep -rn "starter_ref|agent_starter|AgentStarter|starterRef|StarterRefSpec" packages/`
  returned zero hits before this commit; M1 lands in untouched
  territory.

Out-of-slice (preserved untouched):
- `docs/as-built/cli-reference.md`, `packages/cli/src/commands/ps.ts`,
  `packages/cli/test/ps.test.ts` — pre-existing dirty work from
  concurrent agents
- `packages/daemon/src/adapters/codex-runtime-adapter.ts`,
  `packages/daemon/test/codex-runtime-adapter.test.ts` — pre-existing
  Codex TOML config-fragment dedup work flagged as untouched in prior
  slices
- Untracked `CLAUDE.md` at openrig root — substrate/host overlay,
  preserved
M1 commit staged ONLY the 6 in-slice files via explicit pathspec.

Slice rails:
substrate/shared-docs/openrig-work/missions/recursive-self-improvement-v2/work-discovery-and-slice-shaping/slices/knowledge-context-agent-starter-v1-vertical/

Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
mvschwarzandClaude Opus 4.7 committed 2026-05-01 09:36:25 -07:00
1 parent 53e9409306
commit eb4acf2c1d
6 files changed
+778

No files matched your search

@@ -0,0 +1,286 @@
import { existsSync, readFileSync } from "node:fs";
import { join, basename } from "node:path";
import os from "node:os";
import type { ResolvedStartupFile } from "./runtime-adapter.js";
/**
* Daemon-side productized resolver for the Agent Starter v1 vertical (M1).
* Mirrors the v0 wrapper-tier `bin/agent-starter-resolve` semantics:
* read the named registry entry; run the path-aware no-credentials scan;
* THROW on a failed scan (the orchestrator MUST treat any throw as a
* hard launch failure — see review-independent finding 2 in the slice
* IMPL); on a clean scan, emit a `ResolvedStartupFile[]` rooted at the
* registry directory.
*
* v0 wrapper references:
* - Schema spec: `specs/agent-starters/SCHEMA.md` § No-Credentials Proof
* (path-aware + content-aware; allowlist for transcript_path under
* ~/.claude/projects/ or ~/.openrig/transcripts/).
* - Refusal verb: `specs/agent-starters/bin/agent-starter-resolve` lines
* 22-31 (preflights via `scan_starter_file`; refuses with
* credential_path_disallowed / credential_content_disallowed).
*
* Pattern reference for the resolver shape:
* `session-source-rebuild-resolver.ts:31-90`.
*/
export type ExistsFn = (path: string) => boolean;
export type ReadFileFn = (path: string) => string;
export interface AgentStarterResolverOpts {
/** Optional absolute override; bypasses the lookup chain. */
registryRoot?: string;
/**
* Env var consulted second in the lookup chain. Defaults to
* `OPENRIG_AGENT_STARTER_ROOT`.
*/
envVarName?: string;
/**
* Home-directory root consulted third. Defaults to
* `~/.openrig/agent-starters/` (resolved against `process.env.HOME` or
* `os.homedir()`).
*/
homeDirRoot?: string;
/**
* Substrate fallback consulted last for dogfood proofs. Defaults to
* the v0 prototype location at
* `~/code/substrate/shared-docs/openrig-work/specs/agent-starters/`.
*/
substrateFallback?: string;
exists?: ExistsFn;
readFile?: ReadFileFn;
/** Test seam: lets unit tests inject a controlled env map. */
env?: Record<string, string | undefined>;
}
export interface AgentStarterResolveResult {
files: ResolvedStartupFile[];
registryPath: string;
}
/**
* Thrown by `resolveStarter` when the no-credentials scan fails. Per
* review-independent finding 2, the resolver MUST throw rather than
* return a structured "ok: false" result the orchestrator could ignore;
* a failed scan is a hard refusal point that aborts the launch.
*/
export class AgentStarterCredentialScanFailedError extends Error {
readonly starterName: string;
readonly reason: string;
constructor(starterName: string, reason: string) {
super(`Agent Starter "${starterName}" credential scan failed: ${reason}`);
this.name = "AgentStarterCredentialScanFailedError";
this.starterName = starterName;
this.reason = reason;
}
}
const DEFAULT_ENV_VAR = "OPENRIG_AGENT_STARTER_ROOT";
const DEFAULT_HOME_SUBPATH = ".openrig/agent-starters";
const DEFAULT_SUBSTRATE_FALLBACK =
"/Users/wrandom/code/substrate/shared-docs/openrig-work/specs/agent-starters";
export class AgentStarterResolver {
private readonly registryRoot: string;
private readonly exists: ExistsFn;
private readonly readFile: ReadFileFn;
constructor(opts: AgentStarterResolverOpts = {}) {
this.exists = opts.exists ?? existsSync;
this.readFile = opts.readFile ?? ((p: string) => readFileSync(p, "utf-8"));
this.registryRoot = AgentStarterResolver.resolveRegistryRoot(opts, this.exists);
}
/** Exposed for tests + diagnostics. */
getRegistryRoot(): string {
return this.registryRoot;
}
/**
* Look up the registry root via the documented chain:
* opts.registryRoot > env[envVarName] > homeDirRoot (if exists) > substrateFallback
*
* The first three are explicit; the substrate fallback is the dogfood
* default and always returns even if the path doesn't exist (resolveStarter
* will throw with a missing-entry error when the fallback is dead). This
* keeps the constructor side-effect-free; the file-existence check lives
* inside `resolveStarter`.
*/
static resolveRegistryRoot(
opts: AgentStarterResolverOpts,
exists: ExistsFn,
): string {
if (typeof opts.registryRoot === "string" && opts.registryRoot !== "") {
return opts.registryRoot;
}
const env = opts.env ?? process.env;
const envVarName = opts.envVarName ?? DEFAULT_ENV_VAR;
const fromEnv = env[envVarName];
if (typeof fromEnv === "string" && fromEnv !== "") {
return fromEnv;
}
const homeBase = opts.homeDirRoot
?? join(env.HOME ?? os.homedir(), DEFAULT_HOME_SUBPATH);
if (exists(homeBase)) {
return homeBase;
}
return opts.substrateFallback ?? DEFAULT_SUBSTRATE_FALLBACK;
}
/**
* Resolve a starter by registry name. THROWS on:
* - missing registry entry (no `<root>/<name>.yaml`);
* - malformed YAML (best-effort detection — empty file, unparseable
* front-matter shape — driver does not pull in a YAML parser at
* M1 to keep the dependency surface narrow);
* - failed no-credentials scan (`AgentStarterCredentialScanFailedError`).
*
* On a clean scan, emits `ResolvedStartupFile[]`. v1 M1 scaffolding
* shape: one entry per registry-relative file the starter declares
* (currently the registry entry YAML itself, treated as guidance_merge
* content; later milestones can extend the resolver to walk the
* priming-pack manifest's `read_full` paths once M2/M3 wiring is in
* place). All emitted files are tagged `appliesOn: ["fresh_start"]`
* because starter context seeds a fresh-launch conversation.
*/
resolveStarter(name: string): AgentStarterResolveResult {
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) {
throw new Error(
`Agent Starter resolver: invalid name ${JSON.stringify(name)} (must be alphanumeric with optional "_" or "-")`,
);
}
const registryPath = join(this.registryRoot, `${name}.yaml`);
if (!this.exists(registryPath)) {
throw new Error(
`Agent Starter resolver: no registry entry found at ${registryPath}`,
);
}
let content: string;
try {
content = this.readFile(registryPath);
} catch (err) {
throw new Error(
`Agent Starter resolver: failed to read ${registryPath}: ${(err as Error).message}`,
);
}
if (typeof content !== "string" || content.trim() === "") {
throw new Error(
`Agent Starter resolver: ${registryPath} is empty or unreadable`,
);
}
// Smoke check that the file parses as YAML front-matter shape: it must
// contain `starter_id:` line. Keeps M1 dependency surface narrow (no
// yaml parser pulled in); M2 can swap in a real parser when it walks
// the priming-pack manifest.
if (!/^starter_id:\s*\S+/m.test(content)) {
throw new Error(
`Agent Starter resolver: ${registryPath} does not match registry-entry shape (missing "starter_id:" field)`,
);
}
// Path-aware + content-aware credential scan. Mirrors the v0 wrapper
// helper at `specs/agent-starters/lib/agent-starter-helpers.sh`
// `scan_starter_file`. THROWS on match (per finding 2: refuse, do
// not return).
const scanResult = scanForCredentials(content, registryPath);
if (!scanResult.ok) {
throw new AgentStarterCredentialScanFailedError(name, scanResult.reason);
}
// M1 scaffolding shape: one ResolvedStartupFile pointing at the
// registry entry itself. The orchestrator's `deliverStartup` seam
// already accepts `deliveryHint: "guidance_merge"`. Later milestones
// (M2 instantiator integration; M3 Claude e2e; M4 Codex parity) can
// extend the resolver to walk the priming-pack manifest's `read_full`
// paths if the e2e proof requires richer per-layer artifacts. The
// type contract and refusal semantics are what M1 is locking in.
const files: ResolvedStartupFile[] = [
{
path: basename(registryPath),
absolutePath: registryPath,
ownerRoot: this.registryRoot,
deliveryHint: "guidance_merge",
required: true,
appliesOn: ["fresh_start"],
},
];
return { files, registryPath };
}
}
// --- No-credentials scan (mirrors `lib/agent-starter-helpers.sh::scan_starter_file`) ---
const CRED_PATH_LITERALS = [
".claude/.credentials.json",
".codex/auth.json",
".aws/credentials",
".ssh/",
];
const CRED_PATH_RE = /(credentials|auth\.json|secrets|tokens?\.json)([^a-zA-Z0-9._-]|$)/;
const CRED_STRING_RE = /(api[_-]?key|secret[_-]?key|bearer[_-]?token|sk-[A-Za-z0-9]{20,}|gh[ps]_[A-Za-z0-9]{20,}|password[\s]*[:=])/i;
function scanForCredentials(
content: string,
filePath: string,
): { ok: true } | { ok: false; reason: string } {
// Allowlist exception: `transcript_path` field values under
// `~/.claude/projects/` or `~/.openrig/transcripts/` are accepted
// (mirrors the v0 wrapper); same paths under any other field/key/comment
// are rejected as suspicious copying.
const transcriptPath = extractFieldValue(content, "transcript_path");
let lineNo = 0;
for (const rawLine of content.split("\n")) {
lineNo += 1;
const line = rawLine;
// Allowlist check for transcript_path's own line.
const stripped = line.replace(/^\s+/, "");
let isAllowlistedTranscript = false;
if (transcriptPath && stripped.startsWith("transcript_path:")) {
if (
transcriptPath.includes("/.claude/projects/")
|| transcriptPath.includes("/.openrig/transcripts/")
) {
isAllowlistedTranscript = true;
}
}
// 1. Literal credential paths.
for (const literal of CRED_PATH_LITERALS) {
if (line.includes(literal)) {
return {
ok: false,
reason: `credential_path_disallowed: line ${lineNo} of ${filePath} contains credential path ${JSON.stringify(literal)} (line: ${JSON.stringify(line)})`,
};
}
}
// 2. Path regex (credentials|auth.json|secrets|tokens.json).
if (!isAllowlistedTranscript && CRED_PATH_RE.test(line)) {
return {
ok: false,
reason: `credential_path_disallowed: line ${lineNo} of ${filePath} matches credential path pattern (line: ${JSON.stringify(line)})`,
};
}
// 3. Credential string regex (case-insensitive).
if (CRED_STRING_RE.test(line)) {
return {
ok: false,
reason: `credential_content_disallowed: line ${lineNo} of ${filePath} matches credential content pattern (line: ${JSON.stringify(line)})`,
};
}
}
return { ok: true };
}
function extractFieldValue(content: string, field: string): string {
const re = new RegExp(`^\\s*${field}:\\s*(.*?)\\s*$`, "m");
const match = content.match(re);
if (!match || !match[1]) return "";
return match[1].replace(/^['"]|['"]$/g, "").trim();
}
@@ -308,6 +308,67 @@ function validateMember(member: Record<string, unknown>, index: number, podPrefi
errors.push(...validateSessionSource(member["session_source"], `${prefix}.session_source`, isTerminalRuntime));
}
// starter_ref validation (Agent Starter v1 vertical M1).
// - Rejects malformed name.
// - Rejects terminal runtime (analogous to terminal session_source rejection;
// terminal `deliverStartup` is a no-op so a starter has nothing to seed).
// - Rejects starter_ref + session_source.mode=fork composition (per slice
// review-independent finding 1; the v1+ "Real native-fork-from-registered-
// thread-id starter proof" trigger covers that case).
// - Accepts starter_ref + session_source.mode=rebuild (additive: both
// apply on fresh_start; rebuild artifacts and starter artifacts compose
// independently in the launch pipeline).
// - Accepts starter_ref alone.
if (member["starter_ref"] !== undefined) {
errors.push(...validateStarterRef(
member["starter_ref"],
member["session_source"],
isTerminalRuntime,
`${prefix}.starter_ref`,
));
}
return errors;
}
function validateStarterRef(
raw: unknown,
sessionSourceRaw: unknown,
isTerminalRuntime: boolean,
prefix: string,
): string[] {
const errors: string[] = [];
if (raw === null || typeof raw !== "object") {
errors.push(`${prefix}: must be an object with a non-empty "name" string`);
return errors;
}
if (isTerminalRuntime) {
errors.push(`${prefix}: terminal runtime has no agent context to seed; starter_ref is meaningless on terminal members and is rejected (analogous to existing terminal session_source rejection)`);
return errors;
}
const sr = raw as Record<string, unknown>;
const name = sr["name"];
if (typeof name !== "string" || name.trim() === "") {
errors.push(`${prefix}.name: required non-empty string`);
return errors;
}
// Allowed character set: registry keys are lowercase alphanumeric plus
// hyphen + underscore + double-hyphen-as-separator. Reject anything that
// would create an unsafe filesystem path (no `/`, no `..`, no leading dot).
if (!/^[a-zA-Z0-9][a-zA-Z0-9_-]*$/.test(name)) {
errors.push(`${prefix}.name: must be alphanumeric with optional "_" or "-" (got ${JSON.stringify(name)})`);
return errors;
}
// Cross-field rejection: starter_ref + session_source.mode=fork is the
// v1+ named trigger ("Real native-fork-from-registered-thread-id starter
// proof"). v0 schema refuses the composition rather than silently mixing
// the two semantics. Rebuild composition is allowed and lands additively.
if (sessionSourceRaw !== null && typeof sessionSourceRaw === "object") {
const ssRec = sessionSourceRaw as Record<string, unknown>;
if (ssRec["mode"] === "fork") {
errors.push(`${prefix}: starter_ref + session_source.mode="fork" composition is rejected in v0 (the v1+ "Real native-fork-from-registered-thread-id starter proof" trigger covers that combination); use either starter_ref alone or session_source.mode="rebuild" for artifact-additive composition`);
}
}
return errors;
}
@@ -715,6 +776,14 @@ function validateContinuityPolicy(raw: unknown, prefix: string): string[] {
// -- Normalization helpers --
function normalizeStarterRef(raw: unknown): import("./types.js").StarterRefSpec | undefined {
if (raw === null || typeof raw !== "object") return undefined;
const sr = raw as Record<string, unknown>;
const name = sr["name"];
if (typeof name !== "string" || name.trim() === "") return undefined;
return { name };
}
function normalizeSessionSource(raw: unknown): import("./types.js").SessionSourceSpec | undefined {
if (raw === null || typeof raw !== "object") return undefined;
const ss = raw as Record<string, unknown>;
@@ -756,6 +825,7 @@ function normalizePod(raw: Record<string, unknown>): RigSpecPod {
restorePolicy: m["restore_policy"] as string | undefined,
startup: m["startup"] ? normalizeStartupBlock(m["startup"]) : undefined,
sessionSource: normalizeSessionSource(m["session_source"]),
starterRef: normalizeStarterRef(m["starter_ref"]),
}));
const edges = Array.isArray(raw["edges"])
+24
View File
@@ -541,6 +541,22 @@ export interface SessionSourceRebuildSpec {
};
}
/**
* Reference to a named agent-starter registry entry. Artifact-seeded
* fresh-launch context: the starter's curated artifacts are added to the
* member's startup-file chain at launch time. Composes additively with
* `sessionSource` (independent semantics: starter_ref seeds context;
* session_source declares the runtime-source mode). v0 schema constraint:
* `starter_ref` MAY combine with `session_source.mode: "rebuild"` (both
* apply on `fresh_start`) but MAY NOT combine with `session_source.mode:
* "fork"` (the v1+ "Real native-fork-from-registered-thread-id starter
* proof" trigger covers that composition).
*/
export interface StarterRefSpec {
/** Registry key. Matches an entry at `<registryRoot>/<name>.yaml`. */
name: string;
}
export interface RigSpecPodMember {
id: string;
label?: string;
@@ -558,6 +574,14 @@ export interface RigSpecPodMember {
* to the runtime adapter's `forkSource` opt at launch time.
*/
sessionSource?: SessionSourceSpec;
/**
* Optional reference to a named starter registry entry.
* See {@link StarterRefSpec}. Resolved by `AgentStarterResolver` at
* launch time; resolved artifacts seed the STARTER layer of the
* member's startup-file chain. Mutually exclusive with
* `sessionSource.mode: "fork"` per v0 schema (validateStarterRef).
*/
starterRef?: StarterRefSpec;
}
export interface RigSpecPodEdge {
@@ -0,0 +1,189 @@
// Tier 1 proof for the Agent Starter v1 vertical M1 resolver:
// AgentStarterResolver class + AgentStarterCredentialScanFailedError.
// Per slice IMPL § File: agent-starter-resolver.ts: the resolver MUST
// throw on a failed no-credentials scan (review-independent finding 2 —
// resolver does NOT return a "result" the orchestrator can ignore).
import { describe, it, expect } from "vitest";
import {
AgentStarterResolver,
AgentStarterCredentialScanFailedError,
} from "../src/domain/agent-starter-resolver.js";
function makeFs(files: Record<string, string>) {
const exists = (p: string) => Object.prototype.hasOwnProperty.call(files, p);
const readFile = (p: string) => {
if (!exists(p)) throw new Error(`ENOENT: ${p}`);
return files[p]!;
};
return { exists, readFile };
}
const CLEAN_ENTRY = `draft: false
starter_id: fixture-clean
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
`;
describe("AgentStarterResolver (M1)", () => {
// === Lookup chain ===
it("registry root: opts.registryRoot wins over env, home, and fallback", () => {
const fs = makeFs({});
const resolver = new AgentStarterResolver({
registryRoot: "/explicit/root",
env: { OPENRIG_AGENT_STARTER_ROOT: "/env/root", HOME: "/home/test" },
homeDirRoot: "/home/test/.openrig/agent-starters",
substrateFallback: "/substrate/fallback",
...fs,
});
expect(resolver.getRegistryRoot()).toBe("/explicit/root");
});
it("registry root: env var wins when opts.registryRoot is absent", () => {
const fs = makeFs({});
const resolver = new AgentStarterResolver({
env: { OPENRIG_AGENT_STARTER_ROOT: "/env/root", HOME: "/home/test" },
homeDirRoot: "/home/test/.openrig/agent-starters",
substrateFallback: "/substrate/fallback",
...fs,
});
expect(resolver.getRegistryRoot()).toBe("/env/root");
});
it("registry root: homeDirRoot wins when registryRoot+env absent AND home dir exists", () => {
const fs = makeFs({ "/home/test/.openrig/agent-starters": "dir" });
const resolver = new AgentStarterResolver({
env: { HOME: "/home/test" },
homeDirRoot: "/home/test/.openrig/agent-starters",
substrateFallback: "/substrate/fallback",
...fs,
});
expect(resolver.getRegistryRoot()).toBe("/home/test/.openrig/agent-starters");
});
it("registry root: substrate fallback wins when registryRoot+env absent AND home dir does NOT exist", () => {
const fs = makeFs({});
const resolver = new AgentStarterResolver({
env: { HOME: "/home/test" },
homeDirRoot: "/home/test/.openrig/agent-starters",
substrateFallback: "/substrate/fallback",
...fs,
});
expect(resolver.getRegistryRoot()).toBe("/substrate/fallback");
});
// === Successful resolve ===
it("clean entry resolves to one ResolvedStartupFile rooted at registryRoot", () => {
const fs = makeFs({ "/registry/fixture-clean.yaml": CLEAN_ENTRY });
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
const result = resolver.resolveStarter("fixture-clean");
expect(result.registryPath).toBe("/registry/fixture-clean.yaml");
expect(result.files).toHaveLength(1);
const file = result.files[0]!;
expect(file.path).toBe("fixture-clean.yaml");
expect(file.absolutePath).toBe("/registry/fixture-clean.yaml");
expect(file.ownerRoot).toBe("/registry");
expect(file.appliesOn).toEqual(["fresh_start"]);
expect(file.required).toBe(true);
});
// === Credential-scan refusal (Finding 2: THROWS, does not return) ===
it("THROWS AgentStarterCredentialScanFailedError on credential-path match", () => {
const malicious = CLEAN_ENTRY.replace(
"ready_check_evidence: ../evidence/fixture.md",
"ready_check_evidence: ~/.claude/.credentials.json",
);
const fs = makeFs({ "/registry/fixture-mal.yaml": malicious });
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
expect(() => resolver.resolveStarter("fixture-mal"))
.toThrow(AgentStarterCredentialScanFailedError);
});
it("THROWS AgentStarterCredentialScanFailedError on credential-content match (api_key)", () => {
const malicious = `${CLEAN_ENTRY}api_key: example-not-real
`;
const fs = makeFs({ "/registry/fixture-mal2.yaml": malicious });
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
let caught: AgentStarterCredentialScanFailedError | undefined;
try {
resolver.resolveStarter("fixture-mal2");
} catch (err) {
if (err instanceof AgentStarterCredentialScanFailedError) caught = err;
else throw err;
}
expect(caught).toBeDefined();
expect(caught!.starterName).toBe("fixture-mal2");
expect(caught!.reason).toContain("credential_content_disallowed");
});
it("THROWS on case-insensitive credential marker (API_KEY uppercase)", () => {
const malicious = `${CLEAN_ENTRY}API_KEY: uppercase-not-real
`;
const fs = makeFs({ "/registry/fixture-mal3.yaml": malicious });
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
expect(() => resolver.resolveStarter("fixture-mal3"))
.toThrow(AgentStarterCredentialScanFailedError);
});
it("allowlist exception: transcript_path under ~/.claude/projects/ is accepted", () => {
const withTranscript = `${CLEAN_ENTRY}transcript_path: /Users/x/.claude/projects/fixture/abc.jsonl
`;
const fs = makeFs({ "/registry/fixture-allow.yaml": withTranscript });
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
const result = resolver.resolveStarter("fixture-allow");
expect(result.files).toHaveLength(1);
});
// === Missing entry / malformed YAML ===
it("throws on missing registry entry", () => {
const fs = makeFs({});
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
expect(() => resolver.resolveStarter("nonexistent"))
.toThrow(/no registry entry found/);
});
it("throws on malformed YAML (missing starter_id field)", () => {
const fs = makeFs({ "/registry/fixture-bad.yaml": "this is not a starter entry\n" });
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
expect(() => resolver.resolveStarter("fixture-bad"))
.toThrow(/registry-entry shape/);
});
it("throws on empty file", () => {
const fs = makeFs({ "/registry/fixture-empty.yaml": "" });
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
expect(() => resolver.resolveStarter("fixture-empty"))
.toThrow(/empty or unreadable/);
});
// === Name-shape validation (path-traversal guard) ===
it("throws on invalid name with path-traversal characters", () => {
const fs = makeFs({});
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
expect(() => resolver.resolveStarter("../etc/passwd"))
.toThrow(/invalid name/);
});
it("throws on invalid name with slash", () => {
const fs = makeFs({});
const resolver = new AgentStarterResolver({ registryRoot: "/registry", ...fs });
expect(() => resolver.resolveStarter("foo/bar"))
.toThrow(/invalid name/);
});
});
@@ -0,0 +1,176 @@
// Tier 1 schema/normalization/codec proof for the Agent Starter v1
// vertical M1: types.ts adds StarterRefSpec + starterRef? on
// RigSpecPodMember; rigspec-schema.ts adds validateStarterRef +
// normalizeStarterRef. The packet enforces three rejection rules at v0:
// (1) starter_ref + session_source.mode="fork" — review-independent
// finding 1; (2) terminal runtime + starter_ref — secondary cleanup;
// (3) malformed starter_ref.name. Composition with mode="rebuild" is
// accepted; starter_ref alone is accepted.
import { describe, it, expect } from "vitest";
import { RigSpecSchema } from "../src/domain/rigspec-schema.js";
function rigWithMember(overrides: Record<string, unknown>): Record<string, unknown> {
return {
version: "0.2",
name: "starter-test-rig",
pods: [
{
id: "dev",
label: "Dev",
members: [
{
id: "impl",
agent_ref: "local:agents/impl",
profile: "default",
runtime: "claude-code",
cwd: ".",
...overrides,
},
],
edges: [],
},
],
edges: [],
};
}
describe("RigSpec starter_ref schema (M1)", () => {
// === Acceptance ===
it("accepts starter_ref alone", () => {
const rig = rigWithMember({ starter_ref: { name: "openrig-builder-base--claude-code" } });
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
it("accepts starter_ref + session_source.mode='rebuild' (additive composition allowed)", () => {
const rig = rigWithMember({
starter_ref: { name: "openrig-builder-base--claude-code" },
session_source: {
mode: "rebuild",
ref: { kind: "artifact_set", value: ["/tmp/fixture-artifact.md"] },
},
});
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(true);
expect(result.errors).toEqual([]);
});
// === Codec roundtrip ===
it("normalize produces starterRef on RigSpecPodMember", () => {
const rig = rigWithMember({ starter_ref: { name: "fixture-starter" } });
const normalized = RigSpecSchema.normalize(rig);
const member = normalized.pods[0]!.members[0]!;
expect(member.starterRef).toEqual({ name: "fixture-starter" });
});
it("normalize is undefined when starter_ref absent (no breakage on existing specs)", () => {
const rig = rigWithMember({});
const normalized = RigSpecSchema.normalize(rig);
const member = normalized.pods[0]!.members[0]!;
expect(member.starterRef).toBeUndefined();
// sessionSource also undefined — confirms no cross-contamination.
expect(member.sessionSource).toBeUndefined();
});
it("normalize preserves both starterRef and sessionSource (mode=rebuild) under composition", () => {
const rig = rigWithMember({
starter_ref: { name: "fixture-starter" },
session_source: {
mode: "rebuild",
ref: { kind: "artifact_set", value: ["/tmp/a.md", "/tmp/b.md"] },
},
});
const normalized = RigSpecSchema.normalize(rig);
const member = normalized.pods[0]!.members[0]!;
expect(member.starterRef).toEqual({ name: "fixture-starter" });
expect(member.sessionSource).toEqual({
mode: "rebuild",
ref: { kind: "artifact_set", value: ["/tmp/a.md", "/tmp/b.md"] },
});
});
// === Rejection: malformed name ===
it("rejects starter_ref with empty name", () => {
const rig = rigWithMember({ starter_ref: { name: "" } });
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("starter_ref.name") && e.includes("non-empty string"))).toBe(true);
});
it("rejects starter_ref with non-string name", () => {
const rig = rigWithMember({ starter_ref: { name: 42 } });
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("starter_ref.name"))).toBe(true);
});
it("rejects starter_ref with disallowed characters in name (path traversal guard)", () => {
const rig = rigWithMember({ starter_ref: { name: "../etc/passwd" } });
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("alphanumeric"))).toBe(true);
});
it("rejects starter_ref that is not an object", () => {
const rig = rigWithMember({ starter_ref: "fixture-starter" });
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("starter_ref") && e.includes("object"))).toBe(true);
});
// === Rejection: composition with session_source.mode='fork' (Finding 1) ===
it("REJECTS starter_ref + session_source.mode='fork' (v1+ named-trigger covers this composition)", () => {
const rig = rigWithMember({
starter_ref: { name: "fixture-starter" },
session_source: {
mode: "fork",
ref: { kind: "native_id", value: "abc-123" },
},
});
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("fork") && e.includes("v1+"))).toBe(true);
});
// === Rejection: terminal runtime + starter_ref (secondary cleanup) ===
it("REJECTS terminal runtime + starter_ref (analogous to terminal session_source rejection)", () => {
const rig = rigWithMember({
runtime: "terminal",
agent_ref: "builtin:terminal",
starter_ref: { name: "fixture-starter" },
});
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(false);
expect(result.errors.some((e) => e.includes("terminal") && e.includes("starter_ref"))).toBe(true);
});
// === Forward-compat: existing rigs without starter_ref still validate ===
it("forward-compat: spec with no starter_ref on any member validates as before", () => {
const rig = {
version: "0.2",
name: "compat-test",
pods: [
{
id: "dev",
label: "Dev",
members: [
{ id: "impl", agent_ref: "local:agents/impl", profile: "default", runtime: "claude-code", cwd: "." },
{ id: "qa", agent_ref: "local:agents/qa", profile: "reviewer", runtime: "codex", cwd: "." },
],
edges: [],
},
],
edges: [],
};
const result = RigSpecSchema.validate(rig);
expect(result.valid).toBe(true);
});
});
@@ -645,4 +645,37 @@ profiles:
db.close();
});
// Agent Starter v1 vertical M1 — forward-compat smoke. A member spec
// carrying the new `starter_ref` field (normalized as `starterRef`)
// must pass through pod-aware instantiation without breaking the
// existing pipeline. M1 only lands schema + resolver scaffolding; M2
// wires `starterRef` into the launch path. This test verifies M1 does
// not regress existing instantiation when the new field is present.
it("forward-compat: pod with starter_ref on a member instantiates without error (M2 wires the field)", 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" },
}],
edges: [],
}],
}));
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();
});
});