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