@openshain/core 0.2.0 → 0.4.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.
@@ -0,0 +1,400 @@
1
+ import { mkdir, readdir, readFile, stat, writeFile } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { z } from "zod";
4
+ import { parseYamlFile } from "../config/yaml.ts";
5
+ import { OpenshainError } from "../errors.ts";
6
+
7
+ /** Files under authority/ the runtime reads. */
8
+ export const AUTHORITY_DIR_NAME = "authority";
9
+ export const POLICY_FILE_NAME = "policy.yaml";
10
+ export const DELEGATIONS_FILE_NAME = "delegations.yaml";
11
+ export const DECISIONS_DIR_NAME = "decisions";
12
+
13
+ export const DECISION_KINDS = [
14
+ "allow",
15
+ "approval_required",
16
+ "review_required",
17
+ "deny",
18
+ "decision_backed",
19
+ ] as const;
20
+ export type DecisionKind = (typeof DECISION_KINDS)[number];
21
+
22
+ const identifier = z.string().regex(/^[a-z][a-z0-9_-]*$/);
23
+ const isoDate = z.string().regex(/^\d{4}-\d{2}-\d{2}$/);
24
+ const oneOrMany = z.union([z.string().min(1).max(200), z.array(z.string().min(1).max(200)).min(1)]);
25
+
26
+ const MatchSchema = z
27
+ .strictObject({
28
+ tool: oneOrMany.optional(),
29
+ effect: z.enum(["observe", "mutate"]).optional(),
30
+ path: z.string().min(1).max(1000).optional(),
31
+ principal: oneOrMany.optional(),
32
+ work_type: oneOrMany.optional(),
33
+ action: oneOrMany.optional(),
34
+ })
35
+ .refine((m) => Object.values(m).some((v) => v !== undefined), "a rule must match on something");
36
+
37
+ const RuleSchema = z
38
+ .strictObject({
39
+ id: identifier.max(100),
40
+ match: MatchSchema,
41
+ decision: z.enum(DECISION_KINDS),
42
+ reason: z.string().max(2000).optional(),
43
+ approvers: z.array(identifier).min(1).optional(),
44
+ reviewer: z.strictObject({ role: identifier, name: z.string().max(200).optional() }).optional(),
45
+ decision_id: z.string().min(1).max(200).optional(),
46
+ })
47
+ .refine(
48
+ (r) => r.decision !== "decision_backed" || r.decision_id !== undefined,
49
+ "decision_backed needs decision_id",
50
+ );
51
+
52
+ export const PolicyFileSchema = z.strictObject({
53
+ version: z.literal(1),
54
+ default: z.enum(DECISION_KINDS).default("allow"),
55
+ rules: z.array(RuleSchema).default([]),
56
+ });
57
+
58
+ export const DelegationsFileSchema = z.strictObject({
59
+ version: z.literal(1),
60
+ delegations: z
61
+ .array(
62
+ z.strictObject({
63
+ principal: identifier,
64
+ profession: identifier,
65
+ valid_from: isoDate.optional(),
66
+ valid_until: isoDate.nullable().optional(),
67
+ }),
68
+ )
69
+ .default([]),
70
+ });
71
+
72
+ /** What a reviewer decided, written to authority/decisions/ and cited by a decision_backed rule. */
73
+ export const DecisionFileSchema = z.strictObject({
74
+ // One path segment: the id becomes the file name under authority/decisions/.
75
+ id: z
76
+ .string()
77
+ .min(1)
78
+ .max(200)
79
+ .regex(
80
+ /^[A-Za-z0-9][A-Za-z0-9._-]*$/,
81
+ "a decision id is letters, digits, dot, dash and underscore",
82
+ ),
83
+ reviewer: z.strictObject({
84
+ name: z.string().min(1).max(200),
85
+ role: identifier,
86
+ /** As the company states it. openshain does not verify a qualification. */
87
+ qualification: z.string().max(500).optional(),
88
+ }),
89
+ approval_id: z.string().min(1).max(200),
90
+ decided_at: z.iso.datetime(),
91
+ effective_from: isoDate,
92
+ effective_until: isoDate.nullable().default(null),
93
+ interpretation: z.string().min(1).max(100_000),
94
+ applies_to: z
95
+ .strictObject({ action: z.string().max(200).optional(), path: z.string().max(1000).optional() })
96
+ .default({}),
97
+ });
98
+
99
+ export type DecisionRecord = z.output<typeof DecisionFileSchema>;
100
+
101
+ export type PolicyFile = z.output<typeof PolicyFileSchema>;
102
+ export type Rule = PolicyFile["rules"][number];
103
+ export type Delegation = z.output<typeof DelegationsFileSchema>["delegations"][number];
104
+
105
+ /** What the runtime knows about who may do what in a workspace. */
106
+ export interface Authority {
107
+ /** False when the workspace has no authority/ directory: everything is allowed, as before. */
108
+ present: boolean;
109
+ policy: PolicyFile;
110
+ delegations: Delegation[];
111
+ /** The reviewers' decisions, by id. A decision_backed rule cites one. */
112
+ decisions: Map<string, DecisionRecord>;
113
+ }
114
+
115
+ /** One tool call, as the policy sees it. */
116
+ export interface AuthorityRequest {
117
+ tool: string;
118
+ effect: "observe" | "mutate";
119
+ /** The path the call names, normalized and relative to the workspace, if it names one. */
120
+ path?: string;
121
+ principal: string;
122
+ profession: string;
123
+ workType: string;
124
+ /** A name a pack or the policy gives the action. This version uses the tool's name. */
125
+ action?: string;
126
+ /** Today's business date (YYYY-MM-DD), for the delegation's validity. */
127
+ businessDate: string;
128
+ }
129
+
130
+ export type Decision =
131
+ | { kind: "allow"; rule?: Rule; decision?: DecisionRecord }
132
+ | { kind: "deny"; rule?: Rule; reason: string }
133
+ | { kind: "approval_required" | "review_required"; rule: Rule; why?: string };
134
+
135
+ /** An authority that allows everything: what a workspace without authority/ gets. */
136
+ export const OPEN_AUTHORITY: Authority = Object.freeze<Authority>({
137
+ present: false,
138
+ policy: { version: 1, default: "allow", rules: [] },
139
+ delegations: [],
140
+ decisions: new Map(),
141
+ });
142
+
143
+ /** Reads authority/ of a workspace. A workspace without it is open, as every workspace was before. */
144
+ export async function loadAuthority(workspaceRoot: string): Promise<Authority> {
145
+ const dir = join(workspaceRoot, AUTHORITY_DIR_NAME);
146
+ try {
147
+ if (!(await stat(dir)).isDirectory()) return OPEN_AUTHORITY;
148
+ } catch {
149
+ return OPEN_AUTHORITY;
150
+ }
151
+ const policy = await readOptional(join(dir, POLICY_FILE_NAME));
152
+ const delegations = await readOptional(join(dir, DELEGATIONS_FILE_NAME));
153
+ return {
154
+ present: true,
155
+ decisions: await readDecisions(join(dir, DECISIONS_DIR_NAME)),
156
+ policy:
157
+ policy === undefined
158
+ ? { version: 1, default: "allow", rules: [] }
159
+ : parseYamlFile(policy, PolicyFileSchema, `${AUTHORITY_DIR_NAME}/${POLICY_FILE_NAME}`).data,
160
+ delegations:
161
+ delegations === undefined
162
+ ? []
163
+ : parseYamlFile(
164
+ delegations,
165
+ DelegationsFileSchema,
166
+ `${AUTHORITY_DIR_NAME}/${DELEGATIONS_FILE_NAME}`,
167
+ ).data.delegations,
168
+ };
169
+ }
170
+
171
+ /** Every decision under authority/decisions/, by id. A file that cannot be read is a config error. */
172
+ async function readDecisions(dir: string): Promise<Map<string, DecisionRecord>> {
173
+ let names: string[];
174
+ try {
175
+ names = (await readdir(dir)).filter((name) => name.endsWith(".yaml"));
176
+ } catch {
177
+ return new Map();
178
+ }
179
+ const decisions = new Map<string, DecisionRecord>();
180
+ for (const name of names.sort()) {
181
+ const text = await readFile(join(dir, name), "utf8");
182
+ const { data } = parseYamlFile(
183
+ text,
184
+ DecisionFileSchema,
185
+ `${AUTHORITY_DIR_NAME}/${DECISIONS_DIR_NAME}/${name}`,
186
+ );
187
+ decisions.set(data.id, data);
188
+ }
189
+ return decisions;
190
+ }
191
+
192
+ /** Writes one decision under authority/decisions/. The runtime owns that directory. */
193
+ export async function writeDecision(
194
+ workspaceRoot: string,
195
+ decision: DecisionRecord,
196
+ ): Promise<string> {
197
+ // The id is checked again here: this function is public, and the id names a file.
198
+ const checked = DecisionFileSchema.parse(decision);
199
+ try {
200
+ if (!(await stat(join(workspaceRoot, AUTHORITY_DIR_NAME))).isDirectory()) throw new Error();
201
+ } catch {
202
+ throw new OpenshainError(
203
+ "config",
204
+ `this workspace has no ${AUTHORITY_DIR_NAME}/, so it has no policy to decide under`,
205
+ );
206
+ }
207
+ const dir = join(workspaceRoot, AUTHORITY_DIR_NAME, DECISIONS_DIR_NAME);
208
+ await mkdir(dir, { recursive: true });
209
+ const file = join(dir, `${checked.id}.yaml`);
210
+ await writeFile(file, toYaml(checked), { flag: "wx" });
211
+ return file;
212
+ }
213
+
214
+ /**
215
+ * A decision as YAML. Written by hand so that core keeps one YAML dependency, for reading.
216
+ * The interpretation is a block scalar without trailing blank lines, so that what is read back
217
+ * equals what was written.
218
+ */
219
+ function toYaml(decision: DecisionRecord): string {
220
+ const quote = (text: string) => JSON.stringify(text);
221
+ return `${[
222
+ `id: ${quote(decision.id)}`,
223
+ "reviewer:",
224
+ ` name: ${quote(decision.reviewer.name)}`,
225
+ ` role: ${decision.reviewer.role}`,
226
+ ...(decision.reviewer.qualification !== undefined
227
+ ? [` qualification: ${quote(decision.reviewer.qualification)}`]
228
+ : []),
229
+ `approval_id: ${quote(decision.approval_id)}`,
230
+ `decided_at: ${quote(decision.decided_at)}`,
231
+ `effective_from: ${quote(decision.effective_from)}`,
232
+ `effective_until: ${decision.effective_until === null ? "null" : quote(decision.effective_until)}`,
233
+ "interpretation: |-",
234
+ ...decision.interpretation
235
+ .replace(/\n+$/, "")
236
+ .split("\n")
237
+ .map((line) => ` ${line}`),
238
+ // An empty applies_to is written inline: a bare key would read back as null, not as an object.
239
+ ...(decision.applies_to.action === undefined && decision.applies_to.path === undefined
240
+ ? ["applies_to: {}"]
241
+ : [
242
+ "applies_to:",
243
+ ...(decision.applies_to.action !== undefined
244
+ ? [` action: ${quote(decision.applies_to.action)}`]
245
+ : []),
246
+ ...(decision.applies_to.path !== undefined
247
+ ? [` path: ${quote(decision.applies_to.path)}`]
248
+ : []),
249
+ ]),
250
+ ].join("\n")}\n`;
251
+ }
252
+
253
+ async function readOptional(file: string): Promise<string | undefined> {
254
+ try {
255
+ return await readFile(file, "utf8");
256
+ } catch (err) {
257
+ if ((err as NodeJS.ErrnoException).code === "ENOENT") return undefined;
258
+ throw err;
259
+ }
260
+ }
261
+
262
+ /**
263
+ * Judges one call. Ordinary code: the first rule whose every condition holds decides, else the
264
+ * policy's default. Without a delegation for the principal and the profession, everything is
265
+ * denied. A workspace without authority/ allows everything and needs no delegation.
266
+ */
267
+ export function evaluate(authority: Authority, request: AuthorityRequest): Decision {
268
+ if (!authority.present) return { kind: "allow" };
269
+ if (!delegated(authority.delegations, request)) {
270
+ return {
271
+ kind: "deny",
272
+ reason: `no delegation lets a ${request.profession} act for ${request.principal} on ${request.businessDate}`,
273
+ };
274
+ }
275
+ const rule = authority.policy.rules.find((r) => matches(r, request));
276
+ const kind = rule?.decision ?? authority.policy.default;
277
+ switch (kind) {
278
+ case "allow":
279
+ return rule ? { kind, rule } : { kind };
280
+ case "deny":
281
+ return {
282
+ kind,
283
+ ...(rule && { rule }),
284
+ reason:
285
+ rule?.reason ?? (rule ? `denied by rule ${rule.id}` : "denied by the policy's default"),
286
+ };
287
+ case "decision_backed": {
288
+ // The rule cites a reviewer's decision. Without a valid one that covers this call, the
289
+ // reviewer has to look at it again: the rule falls back to a review.
290
+ const named = rule ?? { id: "default", match: {}, decision: kind };
291
+ const decision = named.decision_id ? authority.decisions.get(named.decision_id) : undefined;
292
+ const why = !decision
293
+ ? `rule ${named.id} cites decision ${named.decision_id}, which this workspace does not have`
294
+ : !inEffect(decision, request.businessDate)
295
+ ? `decision ${decision.id} is not in effect on ${request.businessDate}`
296
+ : !covers(decision, request)
297
+ ? `decision ${decision.id} does not cover this call`
298
+ : undefined;
299
+ if (decision && why === undefined) return { kind: "allow", rule: named, decision };
300
+ return { kind: "review_required", rule: named, ...(why !== undefined && { why }) };
301
+ }
302
+ default:
303
+ // approval_required and review_required need a rule to name approvers or a reviewer;
304
+ // a default of that kind is treated as a rule-less request.
305
+ return { kind, rule: rule ?? { id: "default", match: {}, decision: kind } };
306
+ }
307
+ }
308
+
309
+ /** Whether the business date falls in the decision's window. */
310
+ function inEffect(decision: DecisionRecord, businessDate: string): boolean {
311
+ return (
312
+ decision.effective_from <= businessDate &&
313
+ (decision.effective_until === null || businessDate <= decision.effective_until)
314
+ );
315
+ }
316
+
317
+ /** Whether the decision was written for this kind of call. An empty applies_to covers the rule. */
318
+ function covers(decision: DecisionRecord, request: AuthorityRequest): boolean {
319
+ const { action, path } = decision.applies_to;
320
+ if (action !== undefined && action !== (request.action ?? request.tool)) return false;
321
+ if (path !== undefined && (request.path === undefined || !matchGlob(path, request.path))) {
322
+ return false;
323
+ }
324
+ return true;
325
+ }
326
+
327
+ function delegated(delegations: Delegation[], request: AuthorityRequest): boolean {
328
+ return delegations.some(
329
+ (d) =>
330
+ d.principal === request.principal &&
331
+ d.profession === request.profession &&
332
+ (d.valid_from === undefined || d.valid_from <= request.businessDate) &&
333
+ (d.valid_until === undefined ||
334
+ d.valid_until === null ||
335
+ request.businessDate <= d.valid_until),
336
+ );
337
+ }
338
+
339
+ function matches(rule: Rule, request: AuthorityRequest): boolean {
340
+ const m = rule.match;
341
+ if (m.tool !== undefined && !oneOf(m.tool, request.tool)) return false;
342
+ if (m.effect !== undefined && m.effect !== request.effect) return false;
343
+ if (m.principal !== undefined && !oneOf(m.principal, request.principal)) return false;
344
+ if (m.work_type !== undefined && !oneOf(m.work_type, request.workType)) return false;
345
+ if (m.action !== undefined && !oneOf(m.action, request.action ?? request.tool)) return false;
346
+ if (m.path !== undefined) {
347
+ if (request.path === undefined) return false;
348
+ if (!matchGlob(m.path, request.path)) return false;
349
+ }
350
+ return true;
351
+ }
352
+
353
+ function oneOf(expected: string | string[], actual: string): boolean {
354
+ return Array.isArray(expected) ? expected.includes(actual) : expected === actual;
355
+ }
356
+
357
+ /**
358
+ * Matches a workspace-relative path against a glob: `*` stands for part of one segment, `**`
359
+ * for any number of whole segments. No other syntax. `ledger/**` matches everything under
360
+ * ledger/, `*.csv` a CSV at the root, `**\/*.csv` a CSV anywhere.
361
+ */
362
+ export function matchGlob(pattern: string, path: string): boolean {
363
+ // Repeated `**` means the same as one, and collapsing them keeps the match linear.
364
+ const parts = pattern.split("/").filter((part, i, all) => part !== "**" || all[i - 1] !== "**");
365
+ return matchSegments(parts, path.split("/"));
366
+ }
367
+
368
+ function matchSegments(pattern: string[], path: string[]): boolean {
369
+ if (pattern.length === 0) return path.length === 0;
370
+ const [head, ...rest] = pattern as [string, ...string[]];
371
+ if (head === "**") {
372
+ for (let i = 0; i <= path.length; i++) {
373
+ if (matchSegments(rest, path.slice(i))) return true;
374
+ }
375
+ return false;
376
+ }
377
+ if (path.length === 0) return false;
378
+ const [segment, ...remaining] = path as [string, ...string[]];
379
+ return matchSegment(head, segment) && matchSegments(rest, remaining);
380
+ }
381
+
382
+ function matchSegment(pattern: string, segment: string): boolean {
383
+ const parts = pattern.split("*");
384
+ if (parts.length === 1) return pattern === segment;
385
+ let position = 0;
386
+ for (let i = 0; i < parts.length; i++) {
387
+ const part = parts[i] ?? "";
388
+ if (i === 0) {
389
+ if (!segment.startsWith(part)) return false;
390
+ position = part.length;
391
+ } else if (i === parts.length - 1) {
392
+ return segment.slice(position).endsWith(part);
393
+ } else {
394
+ const found = segment.indexOf(part, position);
395
+ if (found === -1) return false;
396
+ position = found + part.length;
397
+ }
398
+ }
399
+ return true;
400
+ }
@@ -1,8 +1,8 @@
1
1
  import { readFile } from "node:fs/promises";
2
2
  import { join } from "node:path";
3
- import { isNode, LineCounter, parseDocument } from "yaml";
4
3
  import { OpenshainError } from "../errors.ts";
5
4
  import { type Config, ConfigFileSchema, toConfig } from "./schema.ts";
5
+ import { parseYamlFile } from "./yaml.ts";
6
6
 
7
7
  export const CONFIG_FILE_NAME = "openshain.yaml";
8
8
 
@@ -31,46 +31,11 @@ export async function loadConfig(
31
31
 
32
32
  export function parseConfig(text: string, options: ParseConfigOptions = {}): Config {
33
33
  const fileName = options.fileName ?? CONFIG_FILE_NAME;
34
- const lineCounter = new LineCounter();
35
- let doc: ReturnType<typeof parseDocument>;
36
- let data: unknown;
37
- try {
38
- doc = parseDocument(text, { lineCounter });
39
- data = doc.errors.length > 0 ? undefined : doc.toJS();
40
- } catch (cause) {
41
- // yaml refuses resource-exhaustion documents (alias bombs) with a plain error
42
- throw new OpenshainError("config", `${fileName}: ${(cause as Error).message}`, { cause });
43
- }
44
-
45
- if (doc.errors.length > 0) {
46
- const lines = doc.errors.map((error) => {
47
- const pos = error.linePos?.[0] ?? { line: 0, col: 0 };
48
- return `${fileName}:${pos.line}:${pos.col} ${firstLine(error.message)}`;
49
- });
50
- throw new OpenshainError("config", lines.join("\n"));
51
- }
52
-
53
- const locate = (path: readonly PropertyKey[]): { line: number; col: number } => {
54
- for (let i = path.length; i >= 0; i--) {
55
- const node = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
56
- if (isNode(node) && node.range) return lineCounter.linePos(node.range[0]);
57
- }
58
- return { line: 1, col: 1 };
59
- };
60
- const problem = (path: readonly PropertyKey[], message: string): string => {
61
- const { line, col } = locate(path);
62
- const where = path.length === 0 ? "<root>" : path.map(String).join(".");
63
- return `${fileName}:${line}:${col} ${where}: ${message}`;
64
- };
65
-
66
- const result = ConfigFileSchema.safeParse(data);
67
- if (!result.success) {
68
- const problems = result.error.issues.map((issue) => problem(issue.path, issue.message));
69
- throw new OpenshainError("config", problems.join("\n"));
70
- }
34
+ const { data, problem } = parseYamlFile(text, ConfigFileSchema, fileName);
35
+ const result = { data };
71
36
 
72
37
  const known = options.modelProviders;
73
- if (known && !known.includes(result.data.model.provider)) {
38
+ if (known && result.data.model && !known.includes(result.data.model.provider)) {
74
39
  throw new OpenshainError(
75
40
  "config",
76
41
  problem(
@@ -82,7 +47,3 @@ export function parseConfig(text: string, options: ParseConfigOptions = {}): Con
82
47
 
83
48
  return toConfig(result.data);
84
49
  }
85
-
86
- function firstLine(message: string): string {
87
- return message.split("\n", 1)[0] ?? message;
88
- }
@@ -46,23 +46,27 @@ export const ConfigFileSchema = z.strictObject({
46
46
  }),
47
47
  principal: z.strictObject({ id: identifier, name: z.string().min(1).max(200) }),
48
48
  profession: z.strictObject({ id: identifier, instructions: z.string().min(1).max(100_000) }),
49
- model: z.strictObject({
50
- provider: identifier,
51
- model: z.string().min(1).max(200),
52
- api_key_env: envVarName,
53
- base_url: z
54
- .url()
55
- .refine((value) => {
56
- const url = new URL(value);
57
- return url.username === "" && url.password === "";
58
- }, "base_url must not carry credentials; use api_key_env")
59
- .refine((value) => {
60
- const url = new URL(value);
61
- return url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname));
62
- }, "base_url must use https unless it points at this machine (localhost, 127.0.0.0/8, ::1)")
63
- .optional(),
64
- options: z.record(z.string(), z.unknown()).optional(),
65
- }),
49
+ model: z
50
+ .strictObject({
51
+ provider: identifier,
52
+ model: z.string().min(1).max(200),
53
+ api_key_env: envVarName,
54
+ base_url: z
55
+ .url()
56
+ .refine((value) => {
57
+ const url = new URL(value);
58
+ return url.username === "" && url.password === "";
59
+ }, "base_url must not carry credentials; use api_key_env")
60
+ .refine((value) => {
61
+ const url = new URL(value);
62
+ return (
63
+ url.protocol === "https:" || (url.protocol === "http:" && isLoopback(url.hostname))
64
+ );
65
+ }, "base_url must use https unless it points at this machine (localhost, 127.0.0.0/8, ::1)")
66
+ .optional(),
67
+ options: z.record(z.string(), z.unknown()).optional(),
68
+ })
69
+ .optional(),
66
70
  tools: z.array(toolProviderRef).default([{ provider: "standard" }]),
67
71
  limits: z
68
72
  .strictObject({
@@ -81,18 +85,22 @@ export type ToolProviderRef =
81
85
  | { module: string; allow: readonly string[] | undefined };
82
86
 
83
87
  /** Configuration as used in code (camelCase). */
88
+ /** The model section of openshain.yaml, as the model providers take it. */
89
+ export interface ModelConfig {
90
+ provider: string;
91
+ model: string;
92
+ apiKeyEnv: string;
93
+ baseUrl: string | undefined;
94
+ options: Record<string, unknown> | undefined;
95
+ }
96
+
84
97
  export interface Config {
85
98
  version: 1;
86
99
  company: { name: string; language: Language };
87
100
  principal: { id: string; name: string };
88
101
  profession: { id: string; instructions: string };
89
- model: {
90
- provider: string;
91
- model: string;
92
- apiKeyEnv: string;
93
- baseUrl: string | undefined;
94
- options: Record<string, unknown> | undefined;
95
- };
102
+ /** The model the interactive CLI runs on. Absent when the workspace is used from other agents only. */
103
+ model?: ModelConfig;
96
104
  tools: ToolProviderRef[];
97
105
  limits: { maxModelCalls: number; maxToolCalls: number; maxOutputTokens: number };
98
106
  debug: { persistRaw: boolean };
@@ -104,13 +112,15 @@ export function toConfig(file: ConfigFile): Config {
104
112
  company: { name: file.company.name, language: file.company.language },
105
113
  principal: { id: file.principal.id, name: file.principal.name },
106
114
  profession: { id: file.profession.id, instructions: file.profession.instructions },
107
- model: {
108
- provider: file.model.provider,
109
- model: file.model.model,
110
- apiKeyEnv: file.model.api_key_env,
111
- baseUrl: file.model.base_url,
112
- options: file.model.options,
113
- },
115
+ ...(file.model && {
116
+ model: {
117
+ provider: file.model.provider,
118
+ model: file.model.model,
119
+ apiKeyEnv: file.model.api_key_env,
120
+ baseUrl: file.model.base_url,
121
+ options: file.model.options,
122
+ },
123
+ }),
114
124
  tools: file.tools.map(toToolProviderRef),
115
125
  limits: {
116
126
  maxModelCalls: file.limits.max_model_calls,
@@ -0,0 +1,60 @@
1
+ import { isNode, LineCounter, parseDocument } from "yaml";
2
+ import type { z } from "zod";
3
+ import { OpenshainError } from "../errors.ts";
4
+
5
+ /** Where a problem in a YAML file is, as `file:line:col path: message`. */
6
+ export type Problem = (path: readonly PropertyKey[], message: string) => string;
7
+
8
+ /**
9
+ * Parses a YAML file against a zod schema. Every problem is reported with its line and column
10
+ * and the path of the field, so that a person can fix the file. Returns the data together with
11
+ * `problem`, for checks the caller adds after parsing.
12
+ */
13
+ export function parseYamlFile<T extends z.ZodType>(
14
+ text: string,
15
+ schema: T,
16
+ fileName: string,
17
+ ): { data: z.output<T>; problem: Problem } {
18
+ const lineCounter = new LineCounter();
19
+ let doc: ReturnType<typeof parseDocument>;
20
+ let data: unknown;
21
+ try {
22
+ doc = parseDocument(text, { lineCounter });
23
+ data = doc.errors.length > 0 ? undefined : doc.toJS();
24
+ } catch (cause) {
25
+ // yaml refuses resource-exhaustion documents (alias bombs) with a plain error
26
+ throw new OpenshainError("config", `${fileName}: ${(cause as Error).message}`, { cause });
27
+ }
28
+
29
+ if (doc.errors.length > 0) {
30
+ const lines = doc.errors.map((error) => {
31
+ const pos = error.linePos?.[0] ?? { line: 0, col: 0 };
32
+ return `${fileName}:${pos.line}:${pos.col} ${firstLine(error.message)}`;
33
+ });
34
+ throw new OpenshainError("config", lines.join("\n"));
35
+ }
36
+
37
+ const locate = (path: readonly PropertyKey[]): { line: number; col: number } => {
38
+ for (let i = path.length; i >= 0; i--) {
39
+ const node = i === 0 ? doc.contents : doc.getIn(path.slice(0, i), true);
40
+ if (isNode(node) && node.range) return lineCounter.linePos(node.range[0]);
41
+ }
42
+ return { line: 1, col: 1 };
43
+ };
44
+ const problem: Problem = (path, message) => {
45
+ const { line, col } = locate(path);
46
+ const where = path.length === 0 ? "<root>" : path.map(String).join(".");
47
+ return `${fileName}:${line}:${col} ${where}: ${message}`;
48
+ };
49
+
50
+ const result = schema.safeParse(data);
51
+ if (!result.success) {
52
+ const problems = result.error.issues.map((issue) => problem(issue.path, issue.message));
53
+ throw new OpenshainError("config", problems.join("\n"));
54
+ }
55
+ return { data: result.data, problem };
56
+ }
57
+
58
+ function firstLine(message: string): string {
59
+ return message.split("\n", 1)[0] ?? message;
60
+ }