@irisrun/agent 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/agentfile.d.ts +46 -0
- package/dist/agentfile.js +161 -0
- package/dist/bundle.d.ts +13 -0
- package/dist/bundle.js +31 -0
- package/dist/image.d.ts +41 -0
- package/dist/image.js +144 -0
- package/dist/index.d.ts +17 -0
- package/dist/index.js +11 -0
- package/dist/lock.d.ts +39 -0
- package/dist/lock.js +46 -0
- package/dist/pin.d.ts +32 -0
- package/dist/pin.js +77 -0
- package/dist/resolver.d.ts +12 -0
- package/dist/resolver.js +16 -0
- package/dist/schema.d.ts +11 -0
- package/dist/schema.js +238 -0
- package/dist/verify.d.ts +8 -0
- package/dist/verify.js +58 -0
- package/dist/yaml.d.ts +6 -0
- package/dist/yaml.js +148 -0
- package/package.json +33 -0
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
export interface CapabilityProfile {
|
|
2
|
+
long_running?: boolean;
|
|
3
|
+
local_subprocess?: boolean;
|
|
4
|
+
filesystem?: boolean;
|
|
5
|
+
websockets?: boolean;
|
|
6
|
+
tool_locality?: "in-process" | "local" | "remote";
|
|
7
|
+
}
|
|
8
|
+
export interface ToolRef {
|
|
9
|
+
ref: string;
|
|
10
|
+
}
|
|
11
|
+
export interface AgentfileModel {
|
|
12
|
+
apiVersion: "iris/v1";
|
|
13
|
+
kind: "Agent";
|
|
14
|
+
name: string;
|
|
15
|
+
model: string;
|
|
16
|
+
instructions: string;
|
|
17
|
+
skills: string[];
|
|
18
|
+
tools: ToolRef[];
|
|
19
|
+
connections: ToolRef[];
|
|
20
|
+
harness: {
|
|
21
|
+
bundle?: string;
|
|
22
|
+
tactics?: Record<string, string>;
|
|
23
|
+
};
|
|
24
|
+
requires: CapabilityProfile;
|
|
25
|
+
sandbox: {
|
|
26
|
+
backend: string;
|
|
27
|
+
workspace?: string;
|
|
28
|
+
network: string;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** Parse Agentfile JSON text into a validated model (throws loudly on bad JSON or shape). */
|
|
32
|
+
export declare function parseAgentfileJson(text: string): AgentfileModel;
|
|
33
|
+
/**
|
|
34
|
+
* Validate a raw parsed object into an AgentfileModel. Guards every boundary
|
|
35
|
+
* (shape, required fields) and enforces the content-vs-contract split: a tool /
|
|
36
|
+
* connection entry is REJECTED if it carries an inline-behavior field
|
|
37
|
+
* (code/script/source) or a ref whose scheme is not mcp/grpc/subprocess
|
|
38
|
+
* (ADR-0005 — no behavior in the manifest). Throws loudly; never coerces.
|
|
39
|
+
*/
|
|
40
|
+
export declare function validateAgentfile(raw: unknown): AgentfileModel;
|
|
41
|
+
/** Content paths embedded by hash: instructions, skills, then sandbox.workspace (if any). */
|
|
42
|
+
export declare function contentPaths(model: AgentfileModel): string[];
|
|
43
|
+
/** Contract refs pinned by digest: tools then connections. */
|
|
44
|
+
export declare function contractRefs(model: AgentfileModel): ToolRef[];
|
|
45
|
+
/** The scheme of a contract ref, e.g. "mcp" for "mcp://registry/x@^2". */
|
|
46
|
+
export declare function refScheme(ref: string): string;
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
// A contract ref must use one of these schemes; anything else (incl. an inline
|
|
2
|
+
// `code`/`script`/`source` field) is inlined behavior and is rejected (ADR-0005).
|
|
3
|
+
const CONTRACT_SCHEMES = ["mcp", "grpc", "subprocess"];
|
|
4
|
+
const INLINE_BEHAVIOR_FIELDS = ["code", "script", "source"];
|
|
5
|
+
// `requires` (the capability profile) is strict-when-present so the runtime
|
|
6
|
+
// validator agrees with the published JSON schema (schema.ts): a present
|
|
7
|
+
// `tool_locality` must be one of these, and a present boolean cap must be a
|
|
8
|
+
// boolean. (Initiative 20260620-agentfile-schema — these were untyped before.)
|
|
9
|
+
const TOOL_LOCALITIES = ["in-process", "local", "remote"];
|
|
10
|
+
const BOOLEAN_CAPS = ["long_running", "local_subprocess", "filesystem", "websockets"];
|
|
11
|
+
/** Parse Agentfile JSON text into a validated model (throws loudly on bad JSON or shape). */
|
|
12
|
+
export function parseAgentfileJson(text) {
|
|
13
|
+
let raw;
|
|
14
|
+
try {
|
|
15
|
+
raw = JSON.parse(text);
|
|
16
|
+
}
|
|
17
|
+
catch (e) {
|
|
18
|
+
throw new Error(`Agentfile: invalid JSON — ${e.message}`);
|
|
19
|
+
}
|
|
20
|
+
return validateAgentfile(raw);
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Validate a raw parsed object into an AgentfileModel. Guards every boundary
|
|
24
|
+
* (shape, required fields) and enforces the content-vs-contract split: a tool /
|
|
25
|
+
* connection entry is REJECTED if it carries an inline-behavior field
|
|
26
|
+
* (code/script/source) or a ref whose scheme is not mcp/grpc/subprocess
|
|
27
|
+
* (ADR-0005 — no behavior in the manifest). Throws loudly; never coerces.
|
|
28
|
+
*/
|
|
29
|
+
export function validateAgentfile(raw) {
|
|
30
|
+
const o = asObject(raw, "Agentfile");
|
|
31
|
+
if (o.apiVersion !== "iris/v1") {
|
|
32
|
+
throw new Error(`Agentfile: apiVersion must be "iris/v1" (got ${JSON.stringify(o.apiVersion)})`);
|
|
33
|
+
}
|
|
34
|
+
if (o.kind !== "Agent") {
|
|
35
|
+
throw new Error(`Agentfile: kind must be "Agent" (got ${JSON.stringify(o.kind)})`);
|
|
36
|
+
}
|
|
37
|
+
const name = requireString(o, "name");
|
|
38
|
+
const model = requireString(o, "model");
|
|
39
|
+
const instructions = requireString(o, "instructions");
|
|
40
|
+
const skills = requireStringArray(o, "skills");
|
|
41
|
+
const tools = requireRefArray(o, "tools");
|
|
42
|
+
const connections = requireRefArray(o, "connections");
|
|
43
|
+
// Default ONLY when ABSENT (undefined). An explicit JSON `null` is wrong-typed
|
|
44
|
+
// — the schema types harness/requires as `object`, so a present null must be
|
|
45
|
+
// rejected, not coerced to {} (which `?? {}` would do). sandbox needs no guard:
|
|
46
|
+
// it is required, so null/absent already throws via asObject below.
|
|
47
|
+
const harness = asObject(o.harness === undefined ? {} : o.harness, "harness");
|
|
48
|
+
const requires = asObject(o.requires === undefined ? {} : o.requires, "requires");
|
|
49
|
+
validateCapabilityProfile(requires);
|
|
50
|
+
const sandbox = asObject(o.sandbox, "sandbox");
|
|
51
|
+
// Build optional sub-objects WITHOUT undefined keys — canonicalize (used for
|
|
52
|
+
// the imageDigest) rejects undefined values, and omission keeps the YAML/JSON
|
|
53
|
+
// models deep-equal. A present-but-wrong-typed `bundle`/`workspace` now throws
|
|
54
|
+
// (was silently dropped) so the runtime agrees with the JSON schema; a
|
|
55
|
+
// well-typed value behaves exactly as before, so existing digests are unchanged.
|
|
56
|
+
const harnessOut = {};
|
|
57
|
+
if (harness.bundle !== undefined) {
|
|
58
|
+
if (typeof harness.bundle !== "string")
|
|
59
|
+
throw new Error("Agentfile: harness.bundle must be a string");
|
|
60
|
+
harnessOut.bundle = harness.bundle;
|
|
61
|
+
}
|
|
62
|
+
if (harness.tactics !== undefined) {
|
|
63
|
+
harnessOut.tactics = asObject(harness.tactics, "harness.tactics");
|
|
64
|
+
}
|
|
65
|
+
const sandboxOut = {
|
|
66
|
+
backend: requireString(sandbox, "sandbox.backend", "backend"),
|
|
67
|
+
network: requireString(sandbox, "sandbox.network", "network"),
|
|
68
|
+
};
|
|
69
|
+
if (sandbox.workspace !== undefined) {
|
|
70
|
+
if (typeof sandbox.workspace !== "string")
|
|
71
|
+
throw new Error("Agentfile: sandbox.workspace must be a string");
|
|
72
|
+
sandboxOut.workspace = sandbox.workspace;
|
|
73
|
+
}
|
|
74
|
+
return {
|
|
75
|
+
apiVersion: "iris/v1",
|
|
76
|
+
kind: "Agent",
|
|
77
|
+
name,
|
|
78
|
+
model,
|
|
79
|
+
instructions,
|
|
80
|
+
skills,
|
|
81
|
+
tools,
|
|
82
|
+
connections,
|
|
83
|
+
harness: harnessOut,
|
|
84
|
+
requires: requires,
|
|
85
|
+
sandbox: sandboxOut,
|
|
86
|
+
};
|
|
87
|
+
}
|
|
88
|
+
// Strict-when-present validation of the capability profile (`requires`), so the
|
|
89
|
+
// runtime agrees with the published JSON schema. Unknown keys are still ignored
|
|
90
|
+
// (retained in the model) — only the KNOWN fields are type-checked when present.
|
|
91
|
+
function validateCapabilityProfile(o) {
|
|
92
|
+
for (const cap of BOOLEAN_CAPS) {
|
|
93
|
+
if (cap in o && typeof o[cap] !== "boolean") {
|
|
94
|
+
throw new Error(`Agentfile: requires.${cap} must be a boolean`);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
const tl = o.tool_locality;
|
|
98
|
+
if (tl !== undefined && !TOOL_LOCALITIES.includes(tl)) {
|
|
99
|
+
throw new Error(`Agentfile: requires.tool_locality must be one of ${TOOL_LOCALITIES.join("/")} (got ${JSON.stringify(tl)})`);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
function asObject(v, what) {
|
|
103
|
+
if (v === null || typeof v !== "object" || Array.isArray(v)) {
|
|
104
|
+
throw new Error(`Agentfile: ${what} must be an object`);
|
|
105
|
+
}
|
|
106
|
+
return v;
|
|
107
|
+
}
|
|
108
|
+
function requireString(o, label, key = label) {
|
|
109
|
+
const v = o[key];
|
|
110
|
+
if (typeof v !== "string" || v.length === 0) {
|
|
111
|
+
throw new Error(`Agentfile: required field "${label}" must be a non-empty string`);
|
|
112
|
+
}
|
|
113
|
+
return v;
|
|
114
|
+
}
|
|
115
|
+
function requireStringArray(o, key) {
|
|
116
|
+
const v = o[key];
|
|
117
|
+
if (!Array.isArray(v) || !v.every((x) => typeof x === "string")) {
|
|
118
|
+
throw new Error(`Agentfile: "${key}" must be an array of strings`);
|
|
119
|
+
}
|
|
120
|
+
return v;
|
|
121
|
+
}
|
|
122
|
+
// Validate a tools/connections array, rejecting inlined behavior (ADR-0005).
|
|
123
|
+
function requireRefArray(o, key) {
|
|
124
|
+
const v = o[key];
|
|
125
|
+
if (!Array.isArray(v)) {
|
|
126
|
+
throw new Error(`Agentfile: "${key}" must be an array`);
|
|
127
|
+
}
|
|
128
|
+
return v.map((entry, i) => {
|
|
129
|
+
const e = asObject(entry, `${key}[${i}]`);
|
|
130
|
+
for (const field of INLINE_BEHAVIOR_FIELDS) {
|
|
131
|
+
if (field in e) {
|
|
132
|
+
throw new Error(`Agentfile: ${key}[${i}] carries inline behavior ("${field}") — tools are contracts referenced by digest, not inlined code (ADR-0005)`);
|
|
133
|
+
}
|
|
134
|
+
}
|
|
135
|
+
const ref = e.ref;
|
|
136
|
+
if (typeof ref !== "string" || ref.length === 0) {
|
|
137
|
+
throw new Error(`Agentfile: ${key}[${i}].ref must be a non-empty string`);
|
|
138
|
+
}
|
|
139
|
+
const scheme = refScheme(ref);
|
|
140
|
+
if (!CONTRACT_SCHEMES.includes(scheme)) {
|
|
141
|
+
throw new Error(`Agentfile: ${key}[${i}] ref scheme "${scheme}" is not a contract scheme (must be one of ${CONTRACT_SCHEMES.join("/")}) — got "${ref}"`);
|
|
142
|
+
}
|
|
143
|
+
return { ref };
|
|
144
|
+
});
|
|
145
|
+
}
|
|
146
|
+
/** Content paths embedded by hash: instructions, skills, then sandbox.workspace (if any). */
|
|
147
|
+
export function contentPaths(model) {
|
|
148
|
+
const paths = [model.instructions, ...model.skills];
|
|
149
|
+
if (model.sandbox.workspace !== undefined)
|
|
150
|
+
paths.push(model.sandbox.workspace);
|
|
151
|
+
return paths;
|
|
152
|
+
}
|
|
153
|
+
/** Contract refs pinned by digest: tools then connections. */
|
|
154
|
+
export function contractRefs(model) {
|
|
155
|
+
return [...model.tools, ...model.connections];
|
|
156
|
+
}
|
|
157
|
+
/** The scheme of a contract ref, e.g. "mcp" for "mcp://registry/x@^2". */
|
|
158
|
+
export function refScheme(ref) {
|
|
159
|
+
const i = ref.indexOf("://");
|
|
160
|
+
return i > 0 ? ref.slice(0, i) : "";
|
|
161
|
+
}
|
package/dist/bundle.d.ts
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
import { type Json } from "@irisrun/core";
|
|
2
|
+
export interface BundleDefinition {
|
|
3
|
+
id: string;
|
|
4
|
+
version: string;
|
|
5
|
+
seams: string[];
|
|
6
|
+
location?: string;
|
|
7
|
+
[k: string]: Json | undefined;
|
|
8
|
+
}
|
|
9
|
+
export interface BundleResolver {
|
|
10
|
+
resolve(ref: string): Promise<BundleDefinition | null>;
|
|
11
|
+
}
|
|
12
|
+
/** sha256 over the canonical behavior surface — stable across location float. */
|
|
13
|
+
export declare function bundleDigest(def: BundleDefinition): string;
|
package/dist/bundle.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// Bundle resolution + digest (spec §4.2, ADR-0004) — the M6 strengthening of the
|
|
2
|
+
// M4 tactic pin. A tactic BUNDLE (e.g. `@irisrun/bundle-coding`) is distributed like
|
|
3
|
+
// a tool contract: an Agentfile `harness.bundle` ref (e.g. "iris/coding@^1")
|
|
4
|
+
// resolves — via an injected BundleResolver, mirroring the M4 RegistryResolver —
|
|
5
|
+
// to a concrete BundleDefinition, and the image pins `bundleDigest(def)` (a real
|
|
6
|
+
// content digest over the BEHAVIOR SURFACE) instead of the M4 sha256Hex(id)
|
|
7
|
+
// placeholder. The digest is STABLE across a floating `location` (re-resolve by
|
|
8
|
+
// stable ref, not by location — [[lrn-resolve-by-stable-ref-not-floating-location]])
|
|
9
|
+
// yet detects any change to the behavior surface (id/version/seams/...).
|
|
10
|
+
// Host-side (node:crypto + @irisrun/core canonicalize).
|
|
11
|
+
import { createHash } from "node:crypto";
|
|
12
|
+
import { canonicalize } from "@irisrun/core";
|
|
13
|
+
// The behavior surface the digest is computed over: the full definition MINUS the
|
|
14
|
+
// floating `location` (and any undefined fields, which canonicalize rejects). Two
|
|
15
|
+
// definitions that differ only in `location` produce the SAME surface → the SAME
|
|
16
|
+
// digest (the ADR-0004 float-impl property); any behavior-surface change differs.
|
|
17
|
+
function behaviorSurface(def) {
|
|
18
|
+
const surface = {};
|
|
19
|
+
for (const [k, v] of Object.entries(def)) {
|
|
20
|
+
if (k === "location")
|
|
21
|
+
continue; // floats — excluded from the digest
|
|
22
|
+
if (v === undefined)
|
|
23
|
+
continue; // canonicalize rejects undefined
|
|
24
|
+
surface[k] = v;
|
|
25
|
+
}
|
|
26
|
+
return surface;
|
|
27
|
+
}
|
|
28
|
+
/** sha256 over the canonical behavior surface — stable across location float. */
|
|
29
|
+
export function bundleDigest(def) {
|
|
30
|
+
return createHash("sha256").update(canonicalize(behaviorSurface(def))).digest("hex");
|
|
31
|
+
}
|
package/dist/image.d.ts
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type Json } from "@irisrun/core";
|
|
2
|
+
import { type AgentfileModel, type CapabilityProfile } from "./agentfile.js";
|
|
3
|
+
import { type Lock, type LockTool } from "./lock.js";
|
|
4
|
+
import type { RegistryResolver } from "./resolver.js";
|
|
5
|
+
import { type BundleResolver } from "./bundle.js";
|
|
6
|
+
export interface AgentImage {
|
|
7
|
+
agentfile: AgentfileModel;
|
|
8
|
+
lock: Lock;
|
|
9
|
+
content: Record<string, string>;
|
|
10
|
+
}
|
|
11
|
+
export interface BuildOptions {
|
|
12
|
+
resolver: RegistryResolver;
|
|
13
|
+
readFile: (path: string) => Promise<Uint8Array>;
|
|
14
|
+
resolveBundle?: BundleResolver;
|
|
15
|
+
}
|
|
16
|
+
export declare function sha256Hex(bytes: Uint8Array | string): string;
|
|
17
|
+
export declare function normalizeContentKey(path: string): string;
|
|
18
|
+
export declare function canonicalImageOf(image: AgentImage): Json;
|
|
19
|
+
export declare function computeImageDigest(image: AgentImage): string;
|
|
20
|
+
/**
|
|
21
|
+
* Resolve + pin contracts, embed content by hash, validate capabilities, and emit
|
|
22
|
+
* a content-addressed image. Deterministic: identical inputs → identical
|
|
23
|
+
* `imageDigest`.
|
|
24
|
+
*/
|
|
25
|
+
export declare function buildImage(model: AgentfileModel, opts: BuildOptions): Promise<AgentImage>;
|
|
26
|
+
export interface ImageInspection {
|
|
27
|
+
name: string;
|
|
28
|
+
model: string;
|
|
29
|
+
imageDigest: string;
|
|
30
|
+
tools: LockTool[];
|
|
31
|
+
content: Record<string, string>;
|
|
32
|
+
tactics: Record<string, {
|
|
33
|
+
id: string;
|
|
34
|
+
digest: string;
|
|
35
|
+
}>;
|
|
36
|
+
capabilities: CapabilityProfile;
|
|
37
|
+
}
|
|
38
|
+
/** Human-readable resolved intent of an image (what `iris inspect` prints). */
|
|
39
|
+
export declare function inspectImage(image: AgentImage): ImageInspection;
|
|
40
|
+
export declare function writeOciLayout(dir: string, image: AgentImage): Promise<void>;
|
|
41
|
+
export declare function readOciLayout(dir: string, ref?: string): Promise<AgentImage>;
|
package/dist/image.js
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
// Image build (spec §3.5): resolve + pin → embed content by hash → compute the
|
|
2
|
+
// content-addressed, deterministic imageDigest = sha256(canonicalize(canonical
|
|
3
|
+
// image)). The canonical image EXCLUDES the self-referential imageDigest field;
|
|
4
|
+
// content values are base64 STRINGS (canonicalize rejects Buffer/Uint8Array) and
|
|
5
|
+
// content keys are normalized (forward-slash, relative) for cross-platform
|
|
6
|
+
// determinism. Host-side (node:crypto + @irisrun/core canonicalize).
|
|
7
|
+
import { createHash } from "node:crypto";
|
|
8
|
+
import { mkdir, writeFile, readFile as fsReadFile } from "node:fs/promises";
|
|
9
|
+
import { join } from "node:path";
|
|
10
|
+
import { canonicalize } from "@irisrun/core";
|
|
11
|
+
import { contentPaths, contractRefs, } from "./agentfile.js";
|
|
12
|
+
import { resolveLockTools, validateCapabilities, } from "./lock.js";
|
|
13
|
+
import { bundleDigest } from "./bundle.js";
|
|
14
|
+
export function sha256Hex(bytes) {
|
|
15
|
+
return createHash("sha256").update(bytes).digest("hex");
|
|
16
|
+
}
|
|
17
|
+
// Normalize a content path to a canonical, platform-independent key: forward
|
|
18
|
+
// slashes, no leading "./". So two builds on different OSes key the same file
|
|
19
|
+
// identically (canonicalize sorts keys lexicographically).
|
|
20
|
+
export function normalizeContentKey(path) {
|
|
21
|
+
return path.replace(/\\/g, "/").replace(/^\.\//, "");
|
|
22
|
+
}
|
|
23
|
+
function toBase64(bytes) {
|
|
24
|
+
return Buffer.from(bytes).toString("base64");
|
|
25
|
+
}
|
|
26
|
+
// The image MINUS its self-referential imageDigest — the bytes the digest is
|
|
27
|
+
// computed over (and recomputed by verify). Used by both build and verify.
|
|
28
|
+
export function canonicalImageOf(image) {
|
|
29
|
+
const { imageDigest: _omit, ...lockSansDigest } = image.lock;
|
|
30
|
+
return {
|
|
31
|
+
agentfile: image.agentfile,
|
|
32
|
+
lock: lockSansDigest,
|
|
33
|
+
content: image.content,
|
|
34
|
+
};
|
|
35
|
+
}
|
|
36
|
+
export function computeImageDigest(image) {
|
|
37
|
+
return sha256Hex(canonicalize(canonicalImageOf(image)));
|
|
38
|
+
}
|
|
39
|
+
/**
|
|
40
|
+
* Resolve + pin contracts, embed content by hash, validate capabilities, and emit
|
|
41
|
+
* a content-addressed image. Deterministic: identical inputs → identical
|
|
42
|
+
* `imageDigest`.
|
|
43
|
+
*/
|
|
44
|
+
export async function buildImage(model, opts) {
|
|
45
|
+
// 1. Resolve + pin every tool/connection contract.
|
|
46
|
+
const tools = await resolveLockTools(contractRefs(model), opts.resolver);
|
|
47
|
+
// 2. Validate the capability profile against the resolved tools (loud).
|
|
48
|
+
validateCapabilities(model.requires, tools);
|
|
49
|
+
// 3. Embed content by hash (base64 value + sha256 hash, normalized key).
|
|
50
|
+
const content = {};
|
|
51
|
+
const contentHashes = {};
|
|
52
|
+
for (const path of contentPaths(model)) {
|
|
53
|
+
const bytes = await opts.readFile(path);
|
|
54
|
+
const key = normalizeContentKey(path);
|
|
55
|
+
content[key] = toBase64(bytes);
|
|
56
|
+
contentHashes[key] = sha256Hex(bytes);
|
|
57
|
+
}
|
|
58
|
+
// 4. Pin the harness (bundle + explicit tactics) deterministically.
|
|
59
|
+
const tactics = {};
|
|
60
|
+
if (model.harness.bundle !== undefined) {
|
|
61
|
+
const id = model.harness.bundle;
|
|
62
|
+
if (opts.resolveBundle !== undefined) {
|
|
63
|
+
// M6: resolve the bundle ref to a definition and pin its REAL content digest.
|
|
64
|
+
// The id stays the STABLE Agentfile ref (re-resolved by verify, location floats).
|
|
65
|
+
const def = await opts.resolveBundle.resolve(id);
|
|
66
|
+
if (def === null) {
|
|
67
|
+
throw new Error(`build: dangling bundle ref — "${id}" did not resolve to a bundle definition`);
|
|
68
|
+
}
|
|
69
|
+
tactics.bundle = { id, digest: bundleDigest(def) };
|
|
70
|
+
}
|
|
71
|
+
else {
|
|
72
|
+
// Back-compat (every M4 path): keep the sha256Hex(id) placeholder unchanged.
|
|
73
|
+
tactics.bundle = { id, digest: sha256Hex(id) };
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
for (const [seam, id] of Object.entries(model.harness.tactics ?? {})) {
|
|
77
|
+
tactics[seam] = { id, digest: sha256Hex(id) };
|
|
78
|
+
}
|
|
79
|
+
// 5. Assemble the lock (imageDigest filled below) and compute the digest over
|
|
80
|
+
// the imageDigest-less canonical image.
|
|
81
|
+
const lock = {
|
|
82
|
+
imageDigest: "",
|
|
83
|
+
model: { id: model.model },
|
|
84
|
+
content: contentHashes,
|
|
85
|
+
tools,
|
|
86
|
+
tactics,
|
|
87
|
+
capabilities: model.requires,
|
|
88
|
+
};
|
|
89
|
+
const image = { agentfile: model, lock, content };
|
|
90
|
+
image.lock.imageDigest = computeImageDigest(image);
|
|
91
|
+
return image;
|
|
92
|
+
}
|
|
93
|
+
/** Human-readable resolved intent of an image (what `iris inspect` prints). */
|
|
94
|
+
export function inspectImage(image) {
|
|
95
|
+
return {
|
|
96
|
+
name: image.agentfile.name,
|
|
97
|
+
model: image.agentfile.model,
|
|
98
|
+
imageDigest: image.lock.imageDigest,
|
|
99
|
+
tools: image.lock.tools,
|
|
100
|
+
content: image.lock.content,
|
|
101
|
+
tactics: image.lock.tactics,
|
|
102
|
+
capabilities: image.lock.capabilities,
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
// --- OCI image layout (local, files-only — spec §3.5) -------------------------
|
|
106
|
+
// Real registry push/pull (+ cosign) is the manual smoke; this is the install-free
|
|
107
|
+
// path. Shape: `oci-layout` + `index.json` + `blobs/sha256/<hex>`.
|
|
108
|
+
const IRIS_IMAGE_MEDIA_TYPE = "application/vnd.iris.agent.image+json";
|
|
109
|
+
export async function writeOciLayout(dir, image) {
|
|
110
|
+
const blob = canonicalize(image); // canonical bytes → stable blob
|
|
111
|
+
const manifestDigest = sha256Hex(blob);
|
|
112
|
+
await mkdir(join(dir, "blobs", "sha256"), { recursive: true });
|
|
113
|
+
await writeFile(join(dir, "oci-layout"), JSON.stringify({ imageLayoutVersion: "1.0.0" }));
|
|
114
|
+
await writeFile(join(dir, "blobs", "sha256", manifestDigest), blob);
|
|
115
|
+
const index = {
|
|
116
|
+
schemaVersion: 2,
|
|
117
|
+
manifests: [
|
|
118
|
+
{
|
|
119
|
+
mediaType: IRIS_IMAGE_MEDIA_TYPE,
|
|
120
|
+
digest: `sha256:${manifestDigest}`,
|
|
121
|
+
size: Buffer.byteLength(blob),
|
|
122
|
+
annotations: { "org.opencontainers.image.ref.name": image.lock.imageDigest },
|
|
123
|
+
},
|
|
124
|
+
],
|
|
125
|
+
};
|
|
126
|
+
await writeFile(join(dir, "index.json"), JSON.stringify(index, null, 2));
|
|
127
|
+
}
|
|
128
|
+
// Note: the manifest blob's own digest (over the full image, incl. imageDigest) is
|
|
129
|
+
// NOT the imageDigest (computed over the imageDigest-LESS canonical image). Both
|
|
130
|
+
// are internally consistent; the ref.name annotation carries the imageDigest.
|
|
131
|
+
export async function readOciLayout(dir, ref) {
|
|
132
|
+
const index = JSON.parse(await fsReadFile(join(dir, "index.json"), "utf8"));
|
|
133
|
+
const manifests = index.manifests ?? [];
|
|
134
|
+
// Select by ref.name annotation when a ref is given; else the sole manifest.
|
|
135
|
+
const manifest = ref !== undefined
|
|
136
|
+
? manifests.find((m) => m.annotations?.["org.opencontainers.image.ref.name"] === ref)
|
|
137
|
+
: manifests[0];
|
|
138
|
+
if (!manifest?.digest) {
|
|
139
|
+
throw new Error(`readOciLayout: ${ref !== undefined ? `no manifest matching ref "${ref}"` : "no manifest"} in ${join(dir, "index.json")}`);
|
|
140
|
+
}
|
|
141
|
+
const digest = manifest.digest.replace(/^sha256:/, "");
|
|
142
|
+
const blob = await fsReadFile(join(dir, "blobs", "sha256", digest), "utf8");
|
|
143
|
+
return JSON.parse(blob);
|
|
144
|
+
}
|
package/dist/index.d.ts
ADDED
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export declare const PACKAGE = "@irisrun/agent";
|
|
2
|
+
export { parseAgentfileJson, validateAgentfile, contentPaths, contractRefs, refScheme, } from "./agentfile.js";
|
|
3
|
+
export type { AgentfileModel, CapabilityProfile, ToolRef, } from "./agentfile.js";
|
|
4
|
+
export { parseAgentfileYaml, parseYamlValue } from "./yaml.js";
|
|
5
|
+
export { AGENTFILE_SCHEMA, agentfileSchemaJson, checkAgainstSchema } from "./schema.js";
|
|
6
|
+
export { makeLocalResolver, refBase } from "./resolver.js";
|
|
7
|
+
export type { RegistryResolver } from "./resolver.js";
|
|
8
|
+
export { resolveLockTools, validateCapabilities } from "./lock.js";
|
|
9
|
+
export type { Lock, LockTool } from "./lock.js";
|
|
10
|
+
export { buildImage, sha256Hex, normalizeContentKey, canonicalImageOf, computeImageDigest, inspectImage, writeOciLayout, readOciLayout, } from "./image.js";
|
|
11
|
+
export type { AgentImage, BuildOptions, ImageInspection } from "./image.js";
|
|
12
|
+
export { verifyImage } from "./verify.js";
|
|
13
|
+
export type { VerifyOptions } from "./verify.js";
|
|
14
|
+
export { bundleDigest } from "./bundle.js";
|
|
15
|
+
export type { BundleDefinition, BundleResolver } from "./bundle.js";
|
|
16
|
+
export { latestRecord, governingDigest, migrateDefinition } from "./pin.js";
|
|
17
|
+
export type { MigrateOptions } from "./pin.js";
|
package/dist/index.js
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
// @irisrun/agent — public surface (host-side; zero external deps).
|
|
2
|
+
export const PACKAGE = "@irisrun/agent";
|
|
3
|
+
export { parseAgentfileJson, validateAgentfile, contentPaths, contractRefs, refScheme, } from "./agentfile.js";
|
|
4
|
+
export { parseAgentfileYaml, parseYamlValue } from "./yaml.js";
|
|
5
|
+
export { AGENTFILE_SCHEMA, agentfileSchemaJson, checkAgainstSchema } from "./schema.js";
|
|
6
|
+
export { makeLocalResolver, refBase } from "./resolver.js";
|
|
7
|
+
export { resolveLockTools, validateCapabilities } from "./lock.js";
|
|
8
|
+
export { buildImage, sha256Hex, normalizeContentKey, canonicalImageOf, computeImageDigest, inspectImage, writeOciLayout, readOciLayout, } from "./image.js";
|
|
9
|
+
export { verifyImage } from "./verify.js";
|
|
10
|
+
export { bundleDigest } from "./bundle.js";
|
|
11
|
+
export { latestRecord, governingDigest, migrateDefinition } from "./pin.js";
|
package/dist/lock.d.ts
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
1
|
+
import { type ToolContract } from "@irisrun/tools";
|
|
2
|
+
import type { CapabilityProfile, ToolRef } from "./agentfile.js";
|
|
3
|
+
import type { RegistryResolver } from "./resolver.js";
|
|
4
|
+
export interface LockTool {
|
|
5
|
+
name: string;
|
|
6
|
+
ref: string;
|
|
7
|
+
contractDigest: string;
|
|
8
|
+
transport: "mcp" | "grpc" | "subprocess";
|
|
9
|
+
location: string;
|
|
10
|
+
retrySafe: boolean;
|
|
11
|
+
}
|
|
12
|
+
export interface Lock {
|
|
13
|
+
imageDigest: string;
|
|
14
|
+
model: {
|
|
15
|
+
id: string;
|
|
16
|
+
digest?: string;
|
|
17
|
+
};
|
|
18
|
+
content: Record<string, string>;
|
|
19
|
+
tools: LockTool[];
|
|
20
|
+
tactics: Record<string, {
|
|
21
|
+
id: string;
|
|
22
|
+
digest: string;
|
|
23
|
+
}>;
|
|
24
|
+
capabilities: CapabilityProfile;
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* Resolve each ref to a concrete contract and pin it. A ref that resolves to
|
|
28
|
+
* nothing → loud dangling-ref error; a resolver returning an `in-process`
|
|
29
|
+
* contract for an Agentfile ref → loud reject (in-process is not authorable, so
|
|
30
|
+
* the lock's `mcp|grpc|subprocess` union stays sound).
|
|
31
|
+
*/
|
|
32
|
+
export declare function resolveLockTools(refs: ToolRef[], resolver: RegistryResolver): Promise<LockTool[]>;
|
|
33
|
+
/**
|
|
34
|
+
* Validate the capability profile against the resolved tools (spec §3.3 step 4),
|
|
35
|
+
* loudly (no silent inconsistency): a `remote` locality forbids subprocess tools,
|
|
36
|
+
* and any subprocess tool requires `local_subprocess: true`.
|
|
37
|
+
*/
|
|
38
|
+
export declare function validateCapabilities(requires: CapabilityProfile, tools: LockTool[]): void;
|
|
39
|
+
export type { ToolContract };
|
package/dist/lock.js
ADDED
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// The lockfile (spec §3.4) — pins model + tools/connections (by contractDigest) +
|
|
2
|
+
// tactics + capabilities + the embedded content hashes. `imageDigest` is filled by
|
|
3
|
+
// the image build (§3.5). This module owns the tool-resolution + pinning + the
|
|
4
|
+
// capability validation; Task 4 completes content/model/tactics. Host-side.
|
|
5
|
+
import { contractDigest } from "@irisrun/tools";
|
|
6
|
+
/**
|
|
7
|
+
* Resolve each ref to a concrete contract and pin it. A ref that resolves to
|
|
8
|
+
* nothing → loud dangling-ref error; a resolver returning an `in-process`
|
|
9
|
+
* contract for an Agentfile ref → loud reject (in-process is not authorable, so
|
|
10
|
+
* the lock's `mcp|grpc|subprocess` union stays sound).
|
|
11
|
+
*/
|
|
12
|
+
export async function resolveLockTools(refs, resolver) {
|
|
13
|
+
const out = [];
|
|
14
|
+
for (const { ref } of refs) {
|
|
15
|
+
const contract = await resolver.resolve(ref);
|
|
16
|
+
if (contract === null) {
|
|
17
|
+
throw new Error(`build: dangling tool ref — "${ref}" did not resolve to a contract`);
|
|
18
|
+
}
|
|
19
|
+
if (contract.transport === "in-process") {
|
|
20
|
+
throw new Error(`build: tool ref "${ref}" resolved to an in-process contract, which is not authorable in an Agentfile (use mcp/grpc/subprocess)`);
|
|
21
|
+
}
|
|
22
|
+
out.push({
|
|
23
|
+
name: contract.name,
|
|
24
|
+
ref, // the stable registry handle — re-resolved by verify (location floats)
|
|
25
|
+
contractDigest: contractDigest(contract),
|
|
26
|
+
transport: contract.transport,
|
|
27
|
+
location: contract.location,
|
|
28
|
+
retrySafe: contract.retrySafe,
|
|
29
|
+
});
|
|
30
|
+
}
|
|
31
|
+
return out;
|
|
32
|
+
}
|
|
33
|
+
/**
|
|
34
|
+
* Validate the capability profile against the resolved tools (spec §3.3 step 4),
|
|
35
|
+
* loudly (no silent inconsistency): a `remote` locality forbids subprocess tools,
|
|
36
|
+
* and any subprocess tool requires `local_subprocess: true`.
|
|
37
|
+
*/
|
|
38
|
+
export function validateCapabilities(requires, tools) {
|
|
39
|
+
const hasSubprocess = tools.some((t) => t.transport === "subprocess");
|
|
40
|
+
if (requires.tool_locality === "remote" && hasSubprocess) {
|
|
41
|
+
throw new Error(`build: capability profile is inconsistent — tool_locality "remote" forbids subprocess:// tools`);
|
|
42
|
+
}
|
|
43
|
+
if (hasSubprocess && requires.local_subprocess !== true) {
|
|
44
|
+
throw new Error(`build: a subprocess:// tool requires the "local_subprocess" capability, but requires.local_subprocess is not true`);
|
|
45
|
+
}
|
|
46
|
+
}
|
package/dist/pin.d.ts
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import type { StateStore, JournalRecord } from "@irisrun/core";
|
|
2
|
+
/**
|
|
3
|
+
* The latest journal record, read SNAPSHOT-SAFELY. After a snapshot the journal
|
|
4
|
+
* holds only `seq > snapshotSeq`; the terminal `wait`/`finish` marker is committed
|
|
5
|
+
* after the snapshot seq (the engine snapshots only in the effect branch), so it
|
|
6
|
+
* always survives `truncateJournal`. Mirrors the engine's recovery read offset.
|
|
7
|
+
* Returns null for a never-started session (empty journal + no snapshot).
|
|
8
|
+
*/
|
|
9
|
+
export declare function latestRecord(store: StateStore, sessionId: string): Promise<JournalRecord | null>;
|
|
10
|
+
/**
|
|
11
|
+
* The governing image digest a session is pinned to = the `defDigest` of its
|
|
12
|
+
* latest journal record. `null` ⇒ a never-started session → the caller adopts the
|
|
13
|
+
* run layout's imageDigest as the birth pin. A LIVE session re-runs under its own
|
|
14
|
+
* governing digest, so a redeploy (a new image digest) does not change its pin.
|
|
15
|
+
*/
|
|
16
|
+
export declare function governingDigest(store: StateStore, sessionId: string): Promise<string | null>;
|
|
17
|
+
export interface MigrateOptions {
|
|
18
|
+
from: string;
|
|
19
|
+
to: string;
|
|
20
|
+
holderId?: string;
|
|
21
|
+
now?: number;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Migrate a LIVE session's pinned definition `from`→`to` at a turn boundary
|
|
25
|
+
* (ADR-0004 hold-and-migrate). Appends an `upgraded {from,to,atTurn}` marker
|
|
26
|
+
* stamped with `defDigest: to`; subsequent turns run with `defDigest = to`. Refuses
|
|
27
|
+
* LOUDLY when the session has not started, is mid-turn, or its governing digest is
|
|
28
|
+
* not `from`. `atTurn` = the boundary journal sequence (the engine emits no
|
|
29
|
+
* turn-start marker to count). Reference impl: core `migrateSession` (the
|
|
30
|
+
* acquireLease → snapshot-aware seq → fenced append pattern). engine.ts untouched.
|
|
31
|
+
*/
|
|
32
|
+
export declare function migrateDefinition(store: StateStore, sessionId: string, opts: MigrateOptions): Promise<void>;
|
package/dist/pin.js
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
// Session pinning + definition migration (spec §3.7, ADR-0002/0004) — ZERO engine
|
|
2
|
+
// change. A session pins the image digest as its governing `defDigest` (the engine
|
|
3
|
+
// already stamps every record). Pinning/migration ride the EXISTING per-record
|
|
4
|
+
// defDigest + the `upgraded` marker; this module uses only the StateStore port +
|
|
5
|
+
// journal types + the lease. Host-side.
|
|
6
|
+
import { acquireLease, releaseLease, encode, decode } from "@irisrun/core";
|
|
7
|
+
/**
|
|
8
|
+
* The latest journal record, read SNAPSHOT-SAFELY. After a snapshot the journal
|
|
9
|
+
* holds only `seq > snapshotSeq`; the terminal `wait`/`finish` marker is committed
|
|
10
|
+
* after the snapshot seq (the engine snapshots only in the effect branch), so it
|
|
11
|
+
* always survives `truncateJournal`. Mirrors the engine's recovery read offset.
|
|
12
|
+
* Returns null for a never-started session (empty journal + no snapshot).
|
|
13
|
+
*/
|
|
14
|
+
export async function latestRecord(store, sessionId) {
|
|
15
|
+
const snap = await store.readLatestSnapshot(sessionId);
|
|
16
|
+
const tail = await store.readJournal(sessionId, (snap?.upToSeq ?? -1) + 1);
|
|
17
|
+
const row = tail.at(-1);
|
|
18
|
+
return row ? decode(row.bytes) : null;
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* The governing image digest a session is pinned to = the `defDigest` of its
|
|
22
|
+
* latest journal record. `null` ⇒ a never-started session → the caller adopts the
|
|
23
|
+
* run layout's imageDigest as the birth pin. A LIVE session re-runs under its own
|
|
24
|
+
* governing digest, so a redeploy (a new image digest) does not change its pin.
|
|
25
|
+
*/
|
|
26
|
+
export async function governingDigest(store, sessionId) {
|
|
27
|
+
const last = await latestRecord(store, sessionId);
|
|
28
|
+
return last ? last.defDigest : null;
|
|
29
|
+
}
|
|
30
|
+
// Markers that mark a turn boundary (the engine emits these; `turn_started` is
|
|
31
|
+
// intentionally NOT used — the engine never emits it).
|
|
32
|
+
const TURN_TERMINAL_MARKERS = new Set(["wait", "finish"]);
|
|
33
|
+
/**
|
|
34
|
+
* Migrate a LIVE session's pinned definition `from`→`to` at a turn boundary
|
|
35
|
+
* (ADR-0004 hold-and-migrate). Appends an `upgraded {from,to,atTurn}` marker
|
|
36
|
+
* stamped with `defDigest: to`; subsequent turns run with `defDigest = to`. Refuses
|
|
37
|
+
* LOUDLY when the session has not started, is mid-turn, or its governing digest is
|
|
38
|
+
* not `from`. `atTurn` = the boundary journal sequence (the engine emits no
|
|
39
|
+
* turn-start marker to count). Reference impl: core `migrateSession` (the
|
|
40
|
+
* acquireLease → snapshot-aware seq → fenced append pattern). engine.ts untouched.
|
|
41
|
+
*/
|
|
42
|
+
export async function migrateDefinition(store, sessionId, opts) {
|
|
43
|
+
const holderId = opts.holderId ?? "migrator";
|
|
44
|
+
const lease = await acquireLease(store, sessionId, holderId);
|
|
45
|
+
if (!lease.ok) {
|
|
46
|
+
throw new Error(`migrateDefinition: could not acquire lease for '${sessionId}' (contended, current ${lease.current})`);
|
|
47
|
+
}
|
|
48
|
+
const fence = lease.fence;
|
|
49
|
+
try {
|
|
50
|
+
const last = await latestRecord(store, sessionId);
|
|
51
|
+
if (last === null) {
|
|
52
|
+
throw new Error(`migrateDefinition: cannot migrate '${sessionId}' — it has not started (no journal)`);
|
|
53
|
+
}
|
|
54
|
+
const marker = last.kind === "marker" ? last.payload.marker : undefined;
|
|
55
|
+
if (last.kind !== "marker" || marker === undefined || !TURN_TERMINAL_MARKERS.has(marker)) {
|
|
56
|
+
throw new Error(`migrateDefinition: '${sessionId}' is not at a turn boundary (latest record is ${last.kind}${marker ? `/${marker}` : ""}) — migrate only between turns`);
|
|
57
|
+
}
|
|
58
|
+
if (last.defDigest !== opts.from) {
|
|
59
|
+
throw new Error(`migrateDefinition: '${sessionId}' governing digest is ${last.defDigest}, not the expected from=${opts.from}`);
|
|
60
|
+
}
|
|
61
|
+
const nextSeq = last.seq + 1;
|
|
62
|
+
const record = {
|
|
63
|
+
seq: nextSeq,
|
|
64
|
+
ts: opts.now ?? 0, // reducers MUST NOT read ts (determinism contract)
|
|
65
|
+
defDigest: opts.to,
|
|
66
|
+
kind: "marker",
|
|
67
|
+
payload: { marker: "upgraded", from: opts.from, to: opts.to, atTurn: nextSeq },
|
|
68
|
+
};
|
|
69
|
+
const res = await store.append(sessionId, nextSeq, [encode(record)], fence);
|
|
70
|
+
if (!res.ok) {
|
|
71
|
+
throw new Error(`migrateDefinition: append failed at seq ${nextSeq} (${res.reason})`);
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
finally {
|
|
75
|
+
await releaseLease(store, sessionId, fence);
|
|
76
|
+
}
|
|
77
|
+
}
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
import type { ToolContract } from "@irisrun/tools";
|
|
2
|
+
export interface RegistryResolver {
|
|
3
|
+
resolve(ref: string): Promise<ToolContract | null>;
|
|
4
|
+
}
|
|
5
|
+
/** Strip a trailing `@<range>` from a ref, e.g. `mcp://r/x@^2` → `mcp://r/x`. */
|
|
6
|
+
export declare function refBase(ref: string): string;
|
|
7
|
+
/**
|
|
8
|
+
* A resolver over an in-memory `refBase → ToolContract` map (install-free). Both
|
|
9
|
+
* `mcp://r/x@^2` and `mcp://r/x@^3` resolve via the shared base `mcp://r/x`,
|
|
10
|
+
* modelling "pin the contract, float the implementation" (ADR-0004).
|
|
11
|
+
*/
|
|
12
|
+
export declare function makeLocalResolver(map: Record<string, ToolContract>): RegistryResolver;
|
package/dist/resolver.js
ADDED
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/** Strip a trailing `@<range>` from a ref, e.g. `mcp://r/x@^2` → `mcp://r/x`. */
|
|
2
|
+
export function refBase(ref) {
|
|
3
|
+
const at = ref.lastIndexOf("@");
|
|
4
|
+
const scheme = ref.indexOf("://");
|
|
5
|
+
return scheme >= 0 && at > scheme + 2 ? ref.slice(0, at) : ref;
|
|
6
|
+
}
|
|
7
|
+
/**
|
|
8
|
+
* A resolver over an in-memory `refBase → ToolContract` map (install-free). Both
|
|
9
|
+
* `mcp://r/x@^2` and `mcp://r/x@^3` resolve via the shared base `mcp://r/x`,
|
|
10
|
+
* modelling "pin the contract, float the implementation" (ADR-0004).
|
|
11
|
+
*/
|
|
12
|
+
export function makeLocalResolver(map) {
|
|
13
|
+
return {
|
|
14
|
+
resolve: (ref) => Promise.resolve(map[refBase(ref)] ?? map[ref] ?? null),
|
|
15
|
+
};
|
|
16
|
+
}
|
package/dist/schema.d.ts
ADDED
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export declare const AGENTFILE_SCHEMA: {
|
|
2
|
+
[k: string]: unknown;
|
|
3
|
+
};
|
|
4
|
+
/** Canonical pretty-printed JSON text of the schema (e.g. `iris schema > file`). */
|
|
5
|
+
export declare function agentfileSchemaJson(): string;
|
|
6
|
+
/**
|
|
7
|
+
* Validate a value against the Agentfile schema. Returns a list of
|
|
8
|
+
* "path: message" errors (empty ⇒ valid). NEVER throws on a malformed instance
|
|
9
|
+
* (only a malformed *schema*, which is this file's own code, could throw).
|
|
10
|
+
*/
|
|
11
|
+
export declare function checkAgainstSchema(value: unknown): string[];
|
package/dist/schema.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// The PUBLISHED Agentfile schema (JSON Schema draft 2020-12) + a zero-dep
|
|
2
|
+
// checker that interprets it. The schema is a SECOND surface over the same
|
|
3
|
+
// contract `validateAgentfile` (agentfile.ts) enforces — the two are pinned to
|
|
4
|
+
// agree by a shared conformance corpus (tests/agentfile-schema.test.ts), so a
|
|
5
|
+
// published schema can never silently drift from the runtime validator (the
|
|
6
|
+
// provider-adapter "one shared conformance suite" idea applied to validation).
|
|
7
|
+
//
|
|
8
|
+
// Why a hand-rolled checker instead of an off-the-shelf JSON-Schema lib: the
|
|
9
|
+
// repo is zero-runtime-deps. `checkAgainstSchema` interprets ONLY the keyword
|
|
10
|
+
// subset this schema uses (documented in `validateNode`), so the schema stays
|
|
11
|
+
// executable in-repo and by any consumer that vendors this file. Host-side.
|
|
12
|
+
// The schema is intentionally LENIENT on unknown keys (`additionalProperties:
|
|
13
|
+
// true` at every level) to match the runtime validator, which ignores unknown
|
|
14
|
+
// keys (forward-compat: a future Agentfile field must not fail an old schema).
|
|
15
|
+
// The closed constraints are exactly what `validateAgentfile` enforces.
|
|
16
|
+
export const AGENTFILE_SCHEMA = {
|
|
17
|
+
$schema: "https://json-schema.org/draft/2020-12/schema",
|
|
18
|
+
$id: "https://iris.run/schema/agentfile/v1.json",
|
|
19
|
+
title: "Iris Agentfile (iris/v1, kind Agent)",
|
|
20
|
+
description: "An Iris Agentfile: a declarative RECIPE that references content (embedded by hash) and tool/connection contracts (pinned by digest). It carries NO executable behavior (ADR-0005).",
|
|
21
|
+
type: "object",
|
|
22
|
+
required: [
|
|
23
|
+
"apiVersion",
|
|
24
|
+
"kind",
|
|
25
|
+
"name",
|
|
26
|
+
"model",
|
|
27
|
+
"instructions",
|
|
28
|
+
"skills",
|
|
29
|
+
"tools",
|
|
30
|
+
"connections",
|
|
31
|
+
"sandbox",
|
|
32
|
+
],
|
|
33
|
+
properties: {
|
|
34
|
+
$schema: {
|
|
35
|
+
type: "string",
|
|
36
|
+
description: "Optional editor/CI schema URI. Ignored by Iris — dropped from the parsed model, so it never affects the image digest.",
|
|
37
|
+
},
|
|
38
|
+
apiVersion: { const: "iris/v1", description: 'Schema version. Must be "iris/v1".' },
|
|
39
|
+
kind: { const: "Agent", description: 'Resource kind. Must be "Agent".' },
|
|
40
|
+
name: { type: "string", minLength: 1, description: "Agent name (non-empty)." },
|
|
41
|
+
model: {
|
|
42
|
+
type: "string",
|
|
43
|
+
minLength: 1,
|
|
44
|
+
description: 'Provider-prefixed model id, e.g. "anthropic/claude-x".',
|
|
45
|
+
},
|
|
46
|
+
instructions: {
|
|
47
|
+
type: "string",
|
|
48
|
+
minLength: 1,
|
|
49
|
+
description: "Path to the instructions file — CONTENT, embedded by hash.",
|
|
50
|
+
},
|
|
51
|
+
skills: {
|
|
52
|
+
type: "array",
|
|
53
|
+
items: { type: "string" },
|
|
54
|
+
description: "Paths to skill files — CONTENT, embedded by hash.",
|
|
55
|
+
},
|
|
56
|
+
tools: {
|
|
57
|
+
type: "array",
|
|
58
|
+
items: { $ref: "#/$defs/contractRef" },
|
|
59
|
+
description: "Tool CONTRACT refs (mcp/grpc/subprocess), pinned by digest.",
|
|
60
|
+
},
|
|
61
|
+
connections: {
|
|
62
|
+
type: "array",
|
|
63
|
+
items: { $ref: "#/$defs/contractRef" },
|
|
64
|
+
description: "Connection CONTRACT refs (mcp/grpc/subprocess), pinned by digest.",
|
|
65
|
+
},
|
|
66
|
+
harness: { $ref: "#/$defs/harness" },
|
|
67
|
+
requires: { $ref: "#/$defs/capabilityProfile" },
|
|
68
|
+
sandbox: { $ref: "#/$defs/sandbox" },
|
|
69
|
+
},
|
|
70
|
+
additionalProperties: true,
|
|
71
|
+
$defs: {
|
|
72
|
+
contractRef: {
|
|
73
|
+
type: "object",
|
|
74
|
+
required: ["ref"],
|
|
75
|
+
properties: {
|
|
76
|
+
ref: {
|
|
77
|
+
type: "string",
|
|
78
|
+
minLength: 1,
|
|
79
|
+
pattern: "^(mcp|grpc|subprocess)://",
|
|
80
|
+
description: "Contract ref. Scheme must be mcp, grpc, or subprocess.",
|
|
81
|
+
},
|
|
82
|
+
// ADR-0005: a tool/connection is a contract referenced by digest, never
|
|
83
|
+
// inlined code. A present code/script/source field validates against the
|
|
84
|
+
// boolean `false` subschema → rejected.
|
|
85
|
+
code: false,
|
|
86
|
+
script: false,
|
|
87
|
+
source: false,
|
|
88
|
+
},
|
|
89
|
+
additionalProperties: true,
|
|
90
|
+
},
|
|
91
|
+
capabilityProfile: {
|
|
92
|
+
type: "object",
|
|
93
|
+
properties: {
|
|
94
|
+
long_running: { type: "boolean" },
|
|
95
|
+
local_subprocess: { type: "boolean" },
|
|
96
|
+
filesystem: { type: "boolean" },
|
|
97
|
+
websockets: { type: "boolean" },
|
|
98
|
+
tool_locality: {
|
|
99
|
+
enum: ["in-process", "local", "remote"],
|
|
100
|
+
description: "Where tools run. One of in-process, local, remote.",
|
|
101
|
+
},
|
|
102
|
+
},
|
|
103
|
+
additionalProperties: true,
|
|
104
|
+
},
|
|
105
|
+
sandbox: {
|
|
106
|
+
type: "object",
|
|
107
|
+
required: ["backend", "network"],
|
|
108
|
+
properties: {
|
|
109
|
+
backend: { type: "string", minLength: 1 },
|
|
110
|
+
network: { type: "string", minLength: 1 },
|
|
111
|
+
workspace: {
|
|
112
|
+
type: "string",
|
|
113
|
+
description: "Optional workspace path — CONTENT, embedded by hash.",
|
|
114
|
+
},
|
|
115
|
+
},
|
|
116
|
+
additionalProperties: true,
|
|
117
|
+
},
|
|
118
|
+
harness: {
|
|
119
|
+
type: "object",
|
|
120
|
+
properties: {
|
|
121
|
+
bundle: { type: "string" },
|
|
122
|
+
tactics: { type: "object" },
|
|
123
|
+
},
|
|
124
|
+
additionalProperties: true,
|
|
125
|
+
},
|
|
126
|
+
},
|
|
127
|
+
};
|
|
128
|
+
/** Canonical pretty-printed JSON text of the schema (e.g. `iris schema > file`). */
|
|
129
|
+
export function agentfileSchemaJson() {
|
|
130
|
+
return JSON.stringify(AGENTFILE_SCHEMA, null, 2);
|
|
131
|
+
}
|
|
132
|
+
/**
|
|
133
|
+
* Validate a value against the Agentfile schema. Returns a list of
|
|
134
|
+
* "path: message" errors (empty ⇒ valid). NEVER throws on a malformed instance
|
|
135
|
+
* (only a malformed *schema*, which is this file's own code, could throw).
|
|
136
|
+
*/
|
|
137
|
+
export function checkAgainstSchema(value) {
|
|
138
|
+
const errors = [];
|
|
139
|
+
validateNode(value, AGENTFILE_SCHEMA, "", errors);
|
|
140
|
+
return errors;
|
|
141
|
+
}
|
|
142
|
+
// The supported JSON-Schema subset: boolean subschemas (true/false), `$ref`
|
|
143
|
+
// (only "#/$defs/<name>"), `type` (string|boolean|number|integer|array|object),
|
|
144
|
+
// `const`, `enum`, `minLength`, `pattern` (compiled with `new RegExp(pattern)` —
|
|
145
|
+
// DEFAULT flags only, so `^` anchors to string-start, matching refScheme), plus
|
|
146
|
+
// `required`, `properties`, and `items`. Unknown keys are NOT constrained (the
|
|
147
|
+
// schema is `additionalProperties: true` everywhere by design); any other schema
|
|
148
|
+
// keyword is ignored.
|
|
149
|
+
function validateNode(value, schema, path, errors) {
|
|
150
|
+
if (schema === true)
|
|
151
|
+
return;
|
|
152
|
+
if (schema === false) {
|
|
153
|
+
errors.push(`${path || "(root)"}: not allowed`);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (typeof schema.$ref === "string") {
|
|
157
|
+
const resolved = resolveRef(schema.$ref);
|
|
158
|
+
validateNode(value, resolved, path, errors);
|
|
159
|
+
return;
|
|
160
|
+
}
|
|
161
|
+
// `const` / `enum` apply regardless of type.
|
|
162
|
+
if ("const" in schema && value !== schema.const) {
|
|
163
|
+
errors.push(`${path || "(root)"}: must equal ${JSON.stringify(schema.const)}`);
|
|
164
|
+
return;
|
|
165
|
+
}
|
|
166
|
+
if (Array.isArray(schema.enum) && !schema.enum.includes(value)) {
|
|
167
|
+
errors.push(`${path || "(root)"}: must be one of ${JSON.stringify(schema.enum)}`);
|
|
168
|
+
return;
|
|
169
|
+
}
|
|
170
|
+
if (typeof schema.type === "string" && !typeMatches(value, schema.type)) {
|
|
171
|
+
errors.push(`${path || "(root)"}: must be of type ${schema.type}`);
|
|
172
|
+
return; // stop — deeper keywords assume the type holds
|
|
173
|
+
}
|
|
174
|
+
if (typeof value === "string") {
|
|
175
|
+
if (typeof schema.minLength === "number" && value.length < schema.minLength) {
|
|
176
|
+
errors.push(`${path || "(root)"}: must be a non-empty string`);
|
|
177
|
+
}
|
|
178
|
+
if (typeof schema.pattern === "string" && !new RegExp(schema.pattern).test(value)) {
|
|
179
|
+
errors.push(`${path || "(root)"}: must match ${schema.pattern}`);
|
|
180
|
+
}
|
|
181
|
+
}
|
|
182
|
+
if (Array.isArray(value) && schema.items !== undefined) {
|
|
183
|
+
value.forEach((item, i) => validateNode(item, schema.items, `${path}[${i}]`, errors));
|
|
184
|
+
}
|
|
185
|
+
if (isPlainObject(value)) {
|
|
186
|
+
if (Array.isArray(schema.required)) {
|
|
187
|
+
for (const key of schema.required) {
|
|
188
|
+
if (!(key in value))
|
|
189
|
+
errors.push(`${joinPath(path, key)}: required (missing)`);
|
|
190
|
+
}
|
|
191
|
+
}
|
|
192
|
+
// Validate only PRESENT, KNOWN properties. The Agentfile schema is
|
|
193
|
+
// `additionalProperties: true` at every level by design (forward-compat,
|
|
194
|
+
// matching the runtime's lenient-on-unknown-keys behavior), so unknown keys
|
|
195
|
+
// are intentionally not constrained.
|
|
196
|
+
const props = isPlainObject(schema.properties) ? schema.properties : undefined;
|
|
197
|
+
if (props) {
|
|
198
|
+
for (const key of Object.keys(value)) {
|
|
199
|
+
if (key in props)
|
|
200
|
+
validateNode(value[key], props[key], joinPath(path, key), errors);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
}
|
|
204
|
+
}
|
|
205
|
+
function resolveRef(ref) {
|
|
206
|
+
const prefix = "#/$defs/";
|
|
207
|
+
if (!ref.startsWith(prefix))
|
|
208
|
+
throw new Error(`Agentfile schema: unsupported $ref "${ref}"`);
|
|
209
|
+
const defs = AGENTFILE_SCHEMA.$defs;
|
|
210
|
+
const node = defs?.[ref.slice(prefix.length)];
|
|
211
|
+
if (node === undefined)
|
|
212
|
+
throw new Error(`Agentfile schema: unknown $ref "${ref}"`);
|
|
213
|
+
return node;
|
|
214
|
+
}
|
|
215
|
+
function typeMatches(value, type) {
|
|
216
|
+
switch (type) {
|
|
217
|
+
case "object":
|
|
218
|
+
return isPlainObject(value);
|
|
219
|
+
case "array":
|
|
220
|
+
return Array.isArray(value);
|
|
221
|
+
case "string":
|
|
222
|
+
return typeof value === "string";
|
|
223
|
+
case "boolean":
|
|
224
|
+
return typeof value === "boolean";
|
|
225
|
+
case "number":
|
|
226
|
+
return typeof value === "number";
|
|
227
|
+
case "integer":
|
|
228
|
+
return Number.isInteger(value);
|
|
229
|
+
default:
|
|
230
|
+
return true; // unknown type keyword — do not constrain
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
function isPlainObject(v) {
|
|
234
|
+
return v !== null && typeof v === "object" && !Array.isArray(v);
|
|
235
|
+
}
|
|
236
|
+
function joinPath(path, key) {
|
|
237
|
+
return path === "" ? key : `${path}.${key}`;
|
|
238
|
+
}
|
package/dist/verify.d.ts
ADDED
|
@@ -0,0 +1,8 @@
|
|
|
1
|
+
import { type AgentImage } from "./image.js";
|
|
2
|
+
import type { RegistryResolver } from "./resolver.js";
|
|
3
|
+
import { type BundleResolver } from "./bundle.js";
|
|
4
|
+
export interface VerifyOptions {
|
|
5
|
+
resolver: RegistryResolver;
|
|
6
|
+
resolveBundle?: BundleResolver;
|
|
7
|
+
}
|
|
8
|
+
export declare function verifyImage(image: AgentImage, opts: VerifyOptions): Promise<void>;
|
package/dist/verify.js
ADDED
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
// Integrity verification (spec §3.6, ADR-0006 §4) — throws LOUDLY on any failure
|
|
2
|
+
// (no silent corruption, [[lrn-no-silent-policy-widening]]). Checks, in order:
|
|
3
|
+
// (1) every embedded content hash matches its bytes; (2) every tool contractDigest
|
|
4
|
+
// is still resolvable + unchanged; (3) the recomputed imageDigest equals the
|
|
5
|
+
// stored one (recompute strips the self-referential field, exactly as build does).
|
|
6
|
+
// Host-side.
|
|
7
|
+
import { contractDigest } from "@irisrun/tools";
|
|
8
|
+
import { sha256Hex, computeImageDigest } from "./image.js";
|
|
9
|
+
import { bundleDigest } from "./bundle.js";
|
|
10
|
+
export async function verifyImage(image, opts) {
|
|
11
|
+
// 1. content hashes match the embedded bytes
|
|
12
|
+
for (const [path, hash] of Object.entries(image.lock.content)) {
|
|
13
|
+
const b64 = image.content[path];
|
|
14
|
+
if (b64 === undefined) {
|
|
15
|
+
throw new Error(`verify: content "${path}" is recorded in the lock but missing from the image`);
|
|
16
|
+
}
|
|
17
|
+
const actual = sha256Hex(Buffer.from(b64, "base64"));
|
|
18
|
+
if (actual !== hash) {
|
|
19
|
+
throw new Error(`verify: content hash mismatch for "${path}" — lock ${hash}, actual ${actual}`);
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
// 2. every tool contract is still resolvable (BY ITS STABLE ref — not location,
|
|
23
|
+
// which floats per ADR-0004) and its digest unchanged
|
|
24
|
+
for (const tool of image.lock.tools) {
|
|
25
|
+
const contract = await opts.resolver.resolve(tool.ref);
|
|
26
|
+
if (contract === null) {
|
|
27
|
+
throw new Error(`verify: dangling tool — "${tool.name}" (${tool.ref}) is no longer resolvable`);
|
|
28
|
+
}
|
|
29
|
+
const digest = contractDigest(contract);
|
|
30
|
+
if (digest !== tool.contractDigest) {
|
|
31
|
+
throw new Error(`verify: contractDigest changed for "${tool.name}" — lock ${tool.contractDigest}, resolved ${digest}`);
|
|
32
|
+
}
|
|
33
|
+
}
|
|
34
|
+
// 3. recomputed imageDigest equals the stored one (computeImageDigest strips the
|
|
35
|
+
// self-referential imageDigest field, exactly as buildImage does)
|
|
36
|
+
const recomputed = computeImageDigest(image);
|
|
37
|
+
if (recomputed !== image.lock.imageDigest) {
|
|
38
|
+
throw new Error(`verify: imageDigest mismatch — stored ${image.lock.imageDigest}, recomputed ${recomputed}`);
|
|
39
|
+
}
|
|
40
|
+
// 4. (M6, NET-NEW) re-resolve the pinned tactic bundle by its STABLE id/ref (NOT
|
|
41
|
+
// a floating location, ADR-0004), recompute bundleDigest, and assert it equals
|
|
42
|
+
// the pinned digest. This catches a CONTENT-tampered bundle whose pinned lock
|
|
43
|
+
// digest is left unchanged — invisible to the imageDigest check above. Skipped
|
|
44
|
+
// entirely when no resolveBundle is injected (M4 back-compat).
|
|
45
|
+
if (opts.resolveBundle !== undefined) {
|
|
46
|
+
const pinned = image.lock.tactics.bundle;
|
|
47
|
+
if (pinned !== undefined) {
|
|
48
|
+
const def = await opts.resolveBundle.resolve(pinned.id);
|
|
49
|
+
if (def === null) {
|
|
50
|
+
throw new Error(`verify: dangling tactic bundle — "${pinned.id}" is no longer resolvable`);
|
|
51
|
+
}
|
|
52
|
+
const digest = bundleDigest(def);
|
|
53
|
+
if (digest !== pinned.digest) {
|
|
54
|
+
throw new Error(`verify: bundle digest changed for "${pinned.id}" — lock ${pinned.digest}, resolved ${digest}`);
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
}
|
package/dist/yaml.d.ts
ADDED
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
import type { Json } from "@irisrun/core";
|
|
2
|
+
import { type AgentfileModel } from "./agentfile.js";
|
|
3
|
+
/** Parse a YAML-subset Agentfile into a validated model (throws loudly on bad/unsupported YAML). */
|
|
4
|
+
export declare function parseAgentfileYaml(text: string): AgentfileModel;
|
|
5
|
+
/** Parse the YAML-subset into a plain JSON value (the raw, pre-validation tree). */
|
|
6
|
+
export declare function parseYamlValue(text: string): Json;
|
package/dist/yaml.js
ADDED
|
@@ -0,0 +1,148 @@
|
|
|
1
|
+
import { validateAgentfile } from "./agentfile.js";
|
|
2
|
+
/** Parse a YAML-subset Agentfile into a validated model (throws loudly on bad/unsupported YAML). */
|
|
3
|
+
export function parseAgentfileYaml(text) {
|
|
4
|
+
return validateAgentfile(parseYamlValue(text));
|
|
5
|
+
}
|
|
6
|
+
/** Parse the YAML-subset into a plain JSON value (the raw, pre-validation tree). */
|
|
7
|
+
export function parseYamlValue(text) {
|
|
8
|
+
const lines = lex(text);
|
|
9
|
+
if (lines.length === 0)
|
|
10
|
+
return {};
|
|
11
|
+
const [value, next] = parseBlock(lines, 0, lines[0].indent);
|
|
12
|
+
if (next !== lines.length) {
|
|
13
|
+
throw new Error(`Agentfile YAML: unexpected indentation at line ${lines[next].lineNo}`);
|
|
14
|
+
}
|
|
15
|
+
return value;
|
|
16
|
+
}
|
|
17
|
+
// Tokenize: strip comments + blank lines, reject unsupported constructs, record indent.
|
|
18
|
+
function lex(text) {
|
|
19
|
+
const out = [];
|
|
20
|
+
const rawLines = text.split("\n");
|
|
21
|
+
for (let i = 0; i < rawLines.length; i++) {
|
|
22
|
+
const raw = rawLines[i];
|
|
23
|
+
const lineNo = i + 1;
|
|
24
|
+
if (raw.trim() === "")
|
|
25
|
+
continue;
|
|
26
|
+
if (raw.trimStart().startsWith("#"))
|
|
27
|
+
continue; // full-line comment
|
|
28
|
+
if (raw.trimStart().startsWith("---") || raw.trimStart().startsWith("...")) {
|
|
29
|
+
throw new Error(`Agentfile YAML: multi-document markers are unsupported (line ${lineNo})`);
|
|
30
|
+
}
|
|
31
|
+
const indentMatch = raw.match(/^[ \t]*/)[0];
|
|
32
|
+
if (indentMatch.includes("\t")) {
|
|
33
|
+
throw new Error(`Agentfile YAML: tab indentation is unsupported — use spaces (line ${lineNo})`);
|
|
34
|
+
}
|
|
35
|
+
const indent = indentMatch.length;
|
|
36
|
+
const content = stripInlineComment(raw.slice(indent)).trimEnd();
|
|
37
|
+
if (content === "")
|
|
38
|
+
continue;
|
|
39
|
+
out.push({ indent, content, lineNo });
|
|
40
|
+
}
|
|
41
|
+
return out;
|
|
42
|
+
}
|
|
43
|
+
// Strip a trailing ` # comment` — QUOTE-AWARE so a quoted value containing ` #`
|
|
44
|
+
// (e.g. `name: 'a #b'`) is never truncated, and `mcp://`/`subprocess://` values
|
|
45
|
+
// (no whitespace before `#`) are never affected. Cuts at the first `#` that is
|
|
46
|
+
// preceded by whitespace AND outside a quoted span.
|
|
47
|
+
function stripInlineComment(s) {
|
|
48
|
+
let inSingle = false;
|
|
49
|
+
let inDouble = false;
|
|
50
|
+
for (let i = 0; i < s.length; i++) {
|
|
51
|
+
const c = s[i];
|
|
52
|
+
if (c === "'" && !inDouble)
|
|
53
|
+
inSingle = !inSingle;
|
|
54
|
+
else if (c === '"' && !inSingle)
|
|
55
|
+
inDouble = !inDouble;
|
|
56
|
+
else if (c === "#" && !inSingle && !inDouble && i > 0 && /\s/.test(s[i - 1])) {
|
|
57
|
+
return s.slice(0, i);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
return s;
|
|
61
|
+
}
|
|
62
|
+
// Parse a block (map or sequence) at `indent`, starting at line `i`.
|
|
63
|
+
function parseBlock(lines, i, indent) {
|
|
64
|
+
if (lines[i].content.startsWith("- "))
|
|
65
|
+
return parseSeq(lines, i, indent);
|
|
66
|
+
return parseMap(lines, i, indent);
|
|
67
|
+
}
|
|
68
|
+
function parseMap(lines, i, indent) {
|
|
69
|
+
const obj = {};
|
|
70
|
+
while (i < lines.length && lines[i].indent === indent) {
|
|
71
|
+
const line = lines[i];
|
|
72
|
+
if (line.content.startsWith("- ")) {
|
|
73
|
+
throw new Error(`Agentfile YAML: unexpected sequence item in a map (line ${line.lineNo})`);
|
|
74
|
+
}
|
|
75
|
+
const colon = line.content.indexOf(":");
|
|
76
|
+
if (colon < 0) {
|
|
77
|
+
throw new Error(`Agentfile YAML: expected "key: value" (line ${line.lineNo})`);
|
|
78
|
+
}
|
|
79
|
+
const key = line.content.slice(0, colon).trim();
|
|
80
|
+
if (Object.prototype.hasOwnProperty.call(obj, key)) {
|
|
81
|
+
throw new Error(`Agentfile YAML: duplicate key "${key}" (line ${line.lineNo})`);
|
|
82
|
+
}
|
|
83
|
+
const rest = line.content.slice(colon + 1).trim();
|
|
84
|
+
if (rest === "") {
|
|
85
|
+
// a nested block on the following deeper-indented lines
|
|
86
|
+
if (i + 1 < lines.length && lines[i + 1].indent > indent) {
|
|
87
|
+
const [child, next] = parseBlock(lines, i + 1, lines[i + 1].indent);
|
|
88
|
+
obj[key] = child;
|
|
89
|
+
i = next;
|
|
90
|
+
}
|
|
91
|
+
else {
|
|
92
|
+
obj[key] = null; // empty block
|
|
93
|
+
i++;
|
|
94
|
+
}
|
|
95
|
+
}
|
|
96
|
+
else {
|
|
97
|
+
obj[key] = parseScalar(rest, line.lineNo);
|
|
98
|
+
i++;
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
return [obj, i];
|
|
102
|
+
}
|
|
103
|
+
function parseSeq(lines, i, indent) {
|
|
104
|
+
const arr = [];
|
|
105
|
+
while (i < lines.length && lines[i].indent === indent && lines[i].content.startsWith("- ")) {
|
|
106
|
+
const line = lines[i];
|
|
107
|
+
const item = line.content.slice(2).trim();
|
|
108
|
+
const colon = item.indexOf(":");
|
|
109
|
+
if (colon > 0 && !looksScalar(item)) {
|
|
110
|
+
// inline single-key map, e.g. "- ref: mcp://..."
|
|
111
|
+
const key = item.slice(0, colon).trim();
|
|
112
|
+
const val = item.slice(colon + 1).trim();
|
|
113
|
+
arr.push({ [key]: parseScalar(val, line.lineNo) });
|
|
114
|
+
}
|
|
115
|
+
else {
|
|
116
|
+
arr.push(parseScalar(item, line.lineNo));
|
|
117
|
+
}
|
|
118
|
+
i++;
|
|
119
|
+
}
|
|
120
|
+
return [arr, i];
|
|
121
|
+
}
|
|
122
|
+
// A bare scalar item in a sequence (no "key:" map shape). True when there is no
|
|
123
|
+
// top-level colon-space that would indicate an inline map.
|
|
124
|
+
function looksScalar(item) {
|
|
125
|
+
return !/^[A-Za-z0-9_.-]+:\s/.test(item) && !/^[A-Za-z0-9_.-]+:$/.test(item);
|
|
126
|
+
}
|
|
127
|
+
function parseScalar(s, lineNo) {
|
|
128
|
+
if (s.startsWith("[") || s.startsWith("{")) {
|
|
129
|
+
throw new Error(`Agentfile YAML: flow collections ([..]/{..}) are unsupported (line ${lineNo})`);
|
|
130
|
+
}
|
|
131
|
+
if (s.startsWith("&") || s.startsWith("*")) {
|
|
132
|
+
throw new Error(`Agentfile YAML: anchors/aliases (&/*) are unsupported (line ${lineNo})`);
|
|
133
|
+
}
|
|
134
|
+
if (s === "true")
|
|
135
|
+
return true;
|
|
136
|
+
if (s === "false")
|
|
137
|
+
return false;
|
|
138
|
+
if (s === "null" || s === "~")
|
|
139
|
+
return null;
|
|
140
|
+
if ((s.startsWith('"') && s.endsWith('"')) || (s.startsWith("'") && s.endsWith("'"))) {
|
|
141
|
+
return s.slice(1, -1);
|
|
142
|
+
}
|
|
143
|
+
if (/^-?\d+$/.test(s))
|
|
144
|
+
return Number.parseInt(s, 10);
|
|
145
|
+
if (/^-?\d*\.\d+$/.test(s))
|
|
146
|
+
return Number.parseFloat(s);
|
|
147
|
+
return s;
|
|
148
|
+
}
|
package/package.json
ADDED
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@irisrun/agent",
|
|
3
|
+
"version": "0.1.0",
|
|
4
|
+
"type": "module",
|
|
5
|
+
"description": "Iris agent image toolchain — Agentfile model + parsers, builder (resolve/embed/pin/validate), content-addressed inspectable image + OCI layout, lockfile, integrity verify, and session pinning + definition migration. Host-side, zero external deps.",
|
|
6
|
+
"exports": {
|
|
7
|
+
".": {
|
|
8
|
+
"iris-src": "./src/index.ts",
|
|
9
|
+
"types": "./dist/index.d.ts",
|
|
10
|
+
"default": "./dist/index.js"
|
|
11
|
+
}
|
|
12
|
+
},
|
|
13
|
+
"dependencies": {
|
|
14
|
+
"@irisrun/core": "^0.1.0",
|
|
15
|
+
"@irisrun/tools": "^0.1.0"
|
|
16
|
+
},
|
|
17
|
+
"license": "MIT",
|
|
18
|
+
"engines": {
|
|
19
|
+
"node": ">=24"
|
|
20
|
+
},
|
|
21
|
+
"publishConfig": {
|
|
22
|
+
"access": "public"
|
|
23
|
+
},
|
|
24
|
+
"repository": {
|
|
25
|
+
"type": "git",
|
|
26
|
+
"url": "git+https://github.com/xoai/iris.git",
|
|
27
|
+
"directory": "packages/agent"
|
|
28
|
+
},
|
|
29
|
+
"homepage": "https://github.com/xoai/iris#readme",
|
|
30
|
+
"files": [
|
|
31
|
+
"dist"
|
|
32
|
+
]
|
|
33
|
+
}
|