@runuai/host 0.9.148 → 0.9.150

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.
Files changed (93) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/codex.ts +9 -1
  12. package/lib/agents/cursor.ts +15 -1
  13. package/lib/agents/grok.ts +8 -0
  14. package/lib/agents/kimi.ts +1 -0
  15. package/lib/agents/machine-durable.ts +2 -0
  16. package/lib/agents/opencode.ts +1 -0
  17. package/lib/agents/proc.ts +6 -1
  18. package/lib/agents/transport.ts +9 -5
  19. package/lib/browser-testing.ts +6 -0
  20. package/lib/codex-auth.ts +2 -0
  21. package/lib/credential-store.ts +141 -6
  22. package/lib/engine-accounts.ts +1 -0
  23. package/lib/env-file.ts +7 -0
  24. package/lib/env-schema.ts +841 -51
  25. package/lib/git-diff.ts +1 -0
  26. package/lib/git-identity.ts +2 -0
  27. package/lib/github-tokens.ts +1 -0
  28. package/lib/host-env.ts +98 -18
  29. package/lib/lease.ts +11 -3
  30. package/lib/machine-gateway-tunnel.ts +195 -17
  31. package/lib/machine-project-env-loader.ts +152 -0
  32. package/lib/managed-runtime.ts +270 -51
  33. package/lib/mcp-gateway.ts +777 -26
  34. package/lib/orchestrator.ts +408 -81
  35. package/lib/project-env-authority.ts +1319 -0
  36. package/lib/project-env-feature.ts +74 -0
  37. package/lib/project-env-loader.ts +237 -0
  38. package/lib/project-env-replica.ts +2094 -0
  39. package/lib/project-env-schema-capture.ts +342 -0
  40. package/lib/project-env-staging.ts +148 -0
  41. package/lib/project-env-task-policy.ts +176 -0
  42. package/lib/runtime-archive.ts +79 -16
  43. package/lib/runtime-authority.ts +44 -0
  44. package/lib/runtime-state.ts +175 -10
  45. package/lib/secrets-proxy/address.ts +72 -0
  46. package/lib/secrets-proxy/audit.ts +30 -0
  47. package/lib/secrets-proxy/ca-store.ts +200 -0
  48. package/lib/secrets-proxy/ca.ts +470 -0
  49. package/lib/secrets-proxy/container-steps.ts +138 -0
  50. package/lib/secrets-proxy/der.ts +273 -0
  51. package/lib/secrets-proxy/engine.ts +1633 -0
  52. package/lib/secrets-proxy/host.ts +93 -0
  53. package/lib/secrets-proxy/hostname.ts +60 -0
  54. package/lib/secrets-proxy/placeholders.ts +196 -0
  55. package/lib/secrets-proxy/policy.ts +141 -0
  56. package/lib/secrets-proxy/readiness.ts +148 -0
  57. package/lib/secrets-proxy/runtime.ts +582 -0
  58. package/lib/secrets-proxy/scrub.ts +559 -0
  59. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  60. package/lib/secrets-proxy/task-scripts.ts +262 -0
  61. package/lib/secrets-proxy/task-trust.ts +574 -0
  62. package/lib/secrets-proxy/trust-watch.ts +80 -0
  63. package/lib/shared-files-s3.ts +3 -0
  64. package/lib/sidecar-guard.ts +58 -0
  65. package/lib/sidecar-resume.ts +51 -3
  66. package/lib/standard-image.ts +50 -12
  67. package/lib/task-env-forwarder.ts +228 -0
  68. package/lib/task-env-source.ts +193 -0
  69. package/lib/task-environment/apple-container.ts +45 -11
  70. package/lib/task-environment/docker.ts +51 -12
  71. package/lib/task-environment/index.ts +6 -2
  72. package/lib/task-environment/machine-task-up.ts +258 -25
  73. package/lib/task-environment/machine.ts +80 -22
  74. package/lib/task-environment/registry.ts +3 -2
  75. package/lib/task-environment/self-identity.ts +20 -0
  76. package/lib/task-environment/self.ts +92 -16
  77. package/lib/task-environment/types.ts +63 -0
  78. package/lib/task-gateway-port.ts +22 -0
  79. package/lib/task-gateway-protocol.ts +190 -0
  80. package/lib/task-gateway-recovery.ts +44 -0
  81. package/lib/task-gateway-state.ts +497 -0
  82. package/lib/transcript.ts +1 -0
  83. package/package.json +13 -1
  84. package/scripts/agent/env-hook.sh +626 -0
  85. package/scripts/agent/task-up.sh +476 -27
  86. package/scripts/agent/uai-env-exec.sh +26 -0
  87. package/src/cli.ts +26 -3
  88. package/src/index.ts +273 -19
  89. package/src/main.ts +939 -48
  90. package/src/project-env-latest.ts +130 -0
  91. package/src/project-env-policy.ts +281 -0
  92. package/src/project-env-wording.ts +47 -0
  93. package/src/protocol.ts +2219 -15
package/lib/env-schema.ts CHANGED
@@ -1,71 +1,861 @@
1
1
  /**
2
- * ADR-122 phase 1: a minimal, OWNED parser for the env-spec subset Uai
3
- * reads — key names and the @required / @sensitive / @type decorators from
4
- * comment lines above each declaration in a committed `.env.schema`.
2
+ * Uai's own parser for the env-spec subset it reads from a repository's
3
+ * committed `.env.schema` (ADR-122 phase 1, ADR-153 §4): key names, the
4
+ * @required / @sensitive / @type decorators, and @proxy / @placeholder.
5
5
  *
6
- * Deliberately dependency-free: this phase must stand alone even if the
7
- * upstream tooling vanishes (the graduated-adoption ladder in ADR-122).
8
- * Unknown decorators are preserved verbatim but unused; values in the
9
- * schema file are IGNORED — a schema declares, it never supplies.
6
+ * Deliberately dependency-free: it must stand alone even if the upstream
7
+ * tooling vanishes (the graduated-adoption ladder in ADR-122). Values in the
8
+ * schema file are never read: a schema declares, it never supplies.
10
9
  *
11
- * First consumer: the post-task-up validation note. A project that
12
- * declares `@required DATABASE_URL` with no value stored on this host gets
13
- * one visible system note instead of a blank 500 hours later (the
14
- * 2026-08-25 incident this phase exists to retire).
10
+ * The env-spec rules it follows (varlock.dev/env-spec/reference):
11
+ * - A comment line carries decorators only if its content starts with `@`;
12
+ * any `@` in another comment is prose. A post-value comment may carry them
13
+ * too (`KEY= # @sensitive`).
14
+ * - Comment lines directly above an item attach to it. A blank line or a
15
+ * divider (`# ---`, `# ===`) ends a block; a block that ends before the
16
+ * first item belongs to the document header.
17
+ * - `@name` is `@name=true`. Value decorators are single-use; function calls
18
+ * (`@name(...)`) accumulate.
19
+ *
20
+ * Fail closed (ADR-153): an @proxy Uai cannot fully interpret makes its key
21
+ * `unsupported`, so the key is withheld; it never reads as no directive, which
22
+ * would deliver the real value. A detached @proxy (outside any item) that
23
+ * names keys withholds them; one that cannot be read at all, like a value
24
+ * that never ends or text that is not text, makes the whole schema
25
+ * unreadable, which withholds every key without an approved binding.
15
26
  */
16
27
 
17
- export interface EnvSchemaEntry {
28
+ import { isIP } from "node:net";
29
+
30
+ import {
31
+ MAX_PROJECT_ENV_KEY_CHARS,
32
+ MAX_PROJECT_ENV_PLACEHOLDER_CHARS,
33
+ MAX_PROJECT_ENV_PROXY_DOMAINS,
34
+ MAX_PROJECT_ENV_SCHEMA_DECLARATIONS,
35
+ MAX_PROJECT_ENV_TYPE_CHARS,
36
+ type KnownProjectEnvSchemaIssueCode,
37
+ type KnownProjectEnvSchemaUnavailableReason,
38
+ type KnownProjectEnvUnsupportedReason,
39
+ type ProjectEnvDirective,
40
+ type ProjectEnvSchemaDeclaration,
41
+ type ProjectEnvSchemaIssue,
42
+ } from "../src/protocol";
43
+ import { canonicalHostname, isProxiableDomain } from "./secrets-proxy/hostname";
44
+
45
+ /**
46
+ * The epoch of what this parser reads, stamped on every schema report: the
47
+ * cloud prefers a newer epoch's reading of a commit to any older one's
48
+ * (ADR-153 §4). Bump it in the contract with any change to what
49
+ * `parseEnvSchemaDeclarations` returns for some input. The fingerprint test
50
+ * beside this file pins its results on a fixed corpus under the current
51
+ * epoch, so most such changes fail it until the epoch moves.
52
+ */
53
+ export { CURRENT_PROJECT_ENV_PARSER_EPOCH } from "../src/protocol";
54
+
55
+ export type EnvSchemaParse =
56
+ | { ok: true; declarations: ProjectEnvSchemaDeclaration[]; issues: ProjectEnvSchemaIssue[] }
57
+ | { ok: false; reason: KnownProjectEnvSchemaUnavailableReason };
58
+
59
+ type Reason = KnownProjectEnvUnsupportedReason;
60
+ type Failure = KnownProjectEnvSchemaUnavailableReason;
61
+
62
+ /** A schema file larger than this is not read: no repository needs one. */
63
+ export const MAX_ENV_SCHEMA_BYTES = 256 * 1024;
64
+ /** A minted placeholder fills at least this many characters at random. */
65
+ export const PLACEHOLDER_RANDOM_CHARACTERS = 32;
66
+
67
+ // --- Decorator syntax -----------------------------------------------------------
68
+
69
+ type Value =
70
+ | { kind: "string"; value: string }
71
+ | { kind: "bare"; value: string }
72
+ | { kind: "array"; items: Value[] }
73
+ | { kind: "object"; entries: Array<[string, Value]> }
74
+ | { kind: "call"; name: string; args: Arg[] };
75
+
76
+ interface Arg {
77
+ name: string | null;
78
+ value: Value;
79
+ }
80
+
81
+ interface Decorator {
18
82
  name: string;
19
- required: boolean;
20
- sensitive: boolean;
21
- type?: string;
22
- /** Decorators Uai does not interpret, kept for diagnostics. */
23
- otherDecorators: string[];
83
+ form: "bare" | "value" | "call";
84
+ value?: Value;
85
+ args?: Arg[];
86
+ /** As written after the `@`, for the legacy diagnostics list. */
87
+ source: string;
24
88
  }
25
89
 
26
- const KEY_LINE = /^([A-Za-z_][A-Za-z0-9_]*)\s*=/;
27
- const DECORATOR = /@([A-Za-z][A-Za-z0-9]*)(?:=((?:[^\s(]+)?(?:\([^)]*\))?))?/g;
90
+ const NAME = /[A-Za-z][A-Za-z0-9_]*/y;
91
+ const NAMED_ARG = /([A-Za-z][A-Za-z0-9_]*)[ \t]*=/y;
92
+ /** In a decorator's call, upstream takes `name=value` with nothing around
93
+ * the `=`. */
94
+ const TIGHT_NAMED_ARG = /([A-Za-z][A-Za-z0-9_]*)=/y;
95
+ const QUOTES = new Set(["\"", "'", "`"]);
96
+ /** Upstream's only whitespace: any other character (a vertical tab, a
97
+ * no-break space) is text. */
98
+ const isBlankChar = (char: string): boolean => char === " " || char === "\t";
99
+ /** How deep lists, objects and calls may nest in one decorator or value:
100
+ * enough for any real schema, and never a stack overflow. */
101
+ const MAX_NESTING = 16;
28
102
 
29
- export function parseEnvSchema(content: string): EnvSchemaEntry[] {
30
- const entries: EnvSchemaEntry[] = [];
31
- let pending: {
32
- required: boolean;
33
- sensitive: boolean;
34
- type?: string;
35
- other: string[];
36
- } = { required: false, sensitive: false, other: [] };
37
- for (const rawLine of content.split(/\r?\n/)) {
38
- const line = rawLine.trim();
39
- if (line.startsWith("#")) {
40
- for (const match of line.matchAll(DECORATOR)) {
41
- const name = match[1]!;
42
- const value = match[2];
43
- if (name === "required") pending.required = true;
44
- else if (name === "sensitive") pending.sensitive = true;
45
- else if (name === "type" && value) pending.type = value;
46
- else pending.other.push(value ? `${name}=${value}` : name);
103
+ class SyntaxFailure extends Error {}
104
+
105
+ /** One line's decorators, or a value's call, list or object. Throws
106
+ * SyntaxFailure on anything it cannot read. */
107
+ class DecoratorReader {
108
+ private pos: number;
109
+ private depth = 0;
110
+ /** A value's structure: inside it, line breaks and `#` comments are blanks. */
111
+ private readonly multiline: boolean;
112
+
113
+ constructor(
114
+ private readonly text: string,
115
+ options: { start?: number; value?: boolean } = {},
116
+ ) {
117
+ this.pos = options.start ?? 0;
118
+ this.multiline = options.value === true;
119
+ }
120
+
121
+ /** Decorators up to a comment. */
122
+ readAll(into: Decorator[]): void {
123
+ for (;;) {
124
+ this.skipSpaces();
125
+ if (this.done() || this.peek() === "#") return;
126
+ if (this.peek() !== "@") {
127
+ // Stray text, as upstream reads it: skipped, and the decorators after
128
+ // it still count. It may not hold an `@` (upstream refuses that).
129
+ while (!this.done() && !isBlankChar(this.peek())) {
130
+ if (this.peek() === "@") throw new SyntaxFailure();
131
+ this.pos += 1;
132
+ }
133
+ continue;
134
+ }
135
+ const start = this.pos + 1;
136
+ this.pos += 1;
137
+ const name = this.name();
138
+ let decorator: Decorator;
139
+ if (this.peek() === "=") {
140
+ this.pos += 1;
141
+ decorator = { name, form: "value", value: this.value(true), source: "" };
142
+ } else if (this.peek() === "(") {
143
+ this.pos += 1;
144
+ decorator = { name, form: "call", args: this.nest(() => this.args(")")), source: "" };
145
+ } else {
146
+ decorator = { name, form: "bare", source: "" };
147
+ }
148
+ decorator.source = this.text.slice(start, this.pos);
149
+ into.push(decorator);
150
+ // Decorators end at whitespace or a comment; anything glued on is junk.
151
+ if (!this.done() && !isBlankChar(this.peek()) && this.peek() !== "#") throw new SyntaxFailure();
152
+ }
153
+ }
154
+
155
+ /** A function call, list or object at the start of a value: where it ends. */
156
+ structureEnd(): number {
157
+ if (this.peek() === "[" || this.peek() === "{") {
158
+ this.value(false);
159
+ return this.pos;
160
+ }
161
+ this.name();
162
+ if (this.peek() !== "(") throw new SyntaxFailure();
163
+ this.pos += 1;
164
+ this.nest(() => this.args(")"));
165
+ return this.pos;
166
+ }
167
+
168
+ private done(): boolean {
169
+ return this.pos >= this.text.length;
170
+ }
171
+
172
+ private peek(): string {
173
+ return this.text[this.pos] ?? "";
174
+ }
175
+
176
+ private skipSpaces(): void {
177
+ for (;;) {
178
+ const char = this.peek();
179
+ if (isBlankChar(char)) {
180
+ this.pos += 1;
181
+ } else if (this.multiline && this.depth > 0 && (char === "\n" || this.atCrLf())) {
182
+ this.pos += char === "\n" ? 1 : 2;
183
+ } else if (this.multiline && this.depth > 0 && char === "#") {
184
+ while (!this.done() && this.peek() !== "\n" && !this.atCrLf()) this.pos += 1;
185
+ } else {
186
+ return;
187
+ }
188
+ }
189
+ }
190
+
191
+ /** Spaces and tabs only: what upstream allows around an `=`. */
192
+ private skipBlanks(): void {
193
+ while (isBlankChar(this.peek())) this.pos += 1;
194
+ }
195
+
196
+ private atCrLf(): boolean {
197
+ return this.peek() === "\r" && this.text[this.pos + 1] === "\n";
198
+ }
199
+
200
+ private name(): string {
201
+ NAME.lastIndex = this.pos;
202
+ const match = NAME.exec(this.text);
203
+ if (match === null) throw new SyntaxFailure();
204
+ this.pos += match[0].length;
205
+ return match[0];
206
+ }
207
+
208
+ private nest<T>(read: () => T): T {
209
+ this.depth += 1;
210
+ if (this.depth > MAX_NESTING) throw new SyntaxFailure();
211
+ try {
212
+ return read();
213
+ } finally {
214
+ this.depth -= 1;
215
+ }
216
+ }
217
+
218
+ private value(topLevel: boolean): Value {
219
+ const char = this.peek();
220
+ if (QUOTES.has(char)) return { kind: "string", value: this.quoted() };
221
+ if (char === "[") {
222
+ this.pos += 1;
223
+ return { kind: "array", items: this.nest(() => this.list("]")) };
224
+ }
225
+ if (char === "{") {
226
+ this.pos += 1;
227
+ return { kind: "object", entries: this.nest(() => this.entries()) };
228
+ }
229
+ const start = this.pos;
230
+ while (!this.done()) {
231
+ const next = this.peek();
232
+ if (isBlankChar(next) || next === "(" || next === "\n" || next === "\r") break;
233
+ // A decorator's own value ends at a comment, as upstream reads it.
234
+ if (topLevel && next === "#") break;
235
+ if (!topLevel && (next === "," || next === ")" || next === "]" || next === "}")) break;
236
+ if (QUOTES.has(next) || next === "[" || next === "{") throw new SyntaxFailure();
237
+ this.pos += 1;
238
+ }
239
+ const word = this.text.slice(start, this.pos);
240
+ if (word.length === 0) throw new SyntaxFailure();
241
+ if (this.peek() === "(") {
242
+ if (!/^[A-Za-z][A-Za-z0-9_]*$/.test(word)) throw new SyntaxFailure();
243
+ this.pos += 1;
244
+ return { kind: "call", name: word, args: this.nest(() => this.args(")")) };
245
+ }
246
+ return { kind: "bare", value: word };
247
+ }
248
+
249
+ private quoted(): string {
250
+ const quote = this.peek();
251
+ this.pos += 1;
252
+ let out = "";
253
+ for (;;) {
254
+ if (this.done()) throw new SyntaxFailure();
255
+ const char = this.peek();
256
+ if (char === "\\" && this.pos + 1 < this.text.length) {
257
+ const escaped = this.text[this.pos + 1]!;
258
+ // Not even escaped: a string never spans lines. (A lone CR is text;
259
+ // the LF of an escaped CRLF fails below, as any line break does.)
260
+ if (escaped === "\n") throw new SyntaxFailure();
261
+ out += escaped === quote || escaped === "\\" ? escaped : `\\${escaped}`;
262
+ this.pos += 2;
263
+ continue;
264
+ }
265
+ // A string never holds a line break, even in a structure that does.
266
+ if (char === "\n") throw new SyntaxFailure();
267
+ this.pos += 1;
268
+ if (char === quote) return out;
269
+ out += char;
270
+ }
271
+ }
272
+
273
+ /** Comma-separated values up to `close`; a trailing comma is allowed. */
274
+ private list(close: string): Value[] {
275
+ const items: Value[] = [];
276
+ for (;;) {
277
+ this.skipSpaces();
278
+ if (this.peek() === close) {
279
+ this.pos += 1;
280
+ return items;
281
+ }
282
+ items.push(this.value(false));
283
+ this.endOfItem(close);
284
+ if (this.text[this.pos - 1] === close) return items;
285
+ }
286
+ }
287
+
288
+ private entries(): Array<[string, Value]> {
289
+ const entries: Array<[string, Value]> = [];
290
+ for (;;) {
291
+ this.skipSpaces();
292
+ if (this.peek() === "}") {
293
+ this.pos += 1;
294
+ return entries;
295
+ }
296
+ const key = this.name();
297
+ this.skipBlanks();
298
+ if (this.peek() !== "=") throw new SyntaxFailure();
299
+ this.pos += 1;
300
+ this.skipBlanks();
301
+ entries.push([key, this.value(false)]);
302
+ this.endOfItem("}");
303
+ if (this.text[this.pos - 1] === "}") return entries;
304
+ }
305
+ }
306
+
307
+ private args(close: string): Arg[] {
308
+ const args: Arg[] = [];
309
+ for (;;) {
310
+ this.skipSpaces();
311
+ if (this.peek() === close) {
312
+ this.pos += 1;
313
+ return args;
314
+ }
315
+ const pattern = this.multiline ? NAMED_ARG : TIGHT_NAMED_ARG;
316
+ pattern.lastIndex = this.pos;
317
+ const named = pattern.exec(this.text);
318
+ let name: string | null = null;
319
+ if (named !== null) {
320
+ name = named[1]!;
321
+ this.pos += named[0].length;
322
+ if (this.multiline) this.skipBlanks();
323
+ }
324
+ args.push({ name, value: this.value(false) });
325
+ this.endOfItem(close);
326
+ if (this.text[this.pos - 1] === close) return args;
327
+ }
328
+ }
329
+
330
+ /** After an item: a comma (then more, or the close), or the close. */
331
+ private endOfItem(close: string): void {
332
+ this.skipSpaces();
333
+ if (this.peek() === ",") {
334
+ this.pos += 1;
335
+ this.skipSpaces();
336
+ if (this.peek() === close) this.pos += 1;
337
+ return;
338
+ }
339
+ if (this.peek() === close) {
340
+ this.pos += 1;
341
+ return;
342
+ }
343
+ throw new SyntaxFailure();
344
+ }
345
+ }
346
+
347
+ /** A decorator comment's decorators, or null if any of it is unreadable. */
348
+ function readDecorators(content: string): Decorator[] | null {
349
+ const decorators: Decorator[] = [];
350
+ try {
351
+ new DecoratorReader(content).readAll(decorators);
352
+ return decorators;
353
+ } catch (error) {
354
+ if (error instanceof SyntaxFailure) return null;
355
+ throw error;
356
+ }
357
+ }
358
+
359
+ // --- Lines and values -------------------------------------------------------------
360
+
361
+ interface Block {
362
+ decorators: Decorator[];
363
+ }
364
+
365
+ const emptyBlock = (): Block => ({ decorators: [] });
366
+
367
+ /** `export ` and any blanks: once read, a key must follow (upstream never
368
+ * takes it back to read `export` itself as the key). */
369
+ const EXPORT_PREFIX = /^export [ \t]*/;
370
+ /** A key as upstream reads one: `.` and `-` may follow its first character. */
371
+ const ITEM_KEY = /^([A-Za-z_][A-Za-z0-9_.-]*)[ \t]*=/;
372
+ /** A key the contract, the settings API and a shell can all hold. */
373
+ const CONTRACT_KEY = /^[A-Za-z_][A-Za-z0-9_]*$/;
374
+ /** A value upstream reads as a structure: a call, a list or an object. */
375
+ const STRUCTURE = /^(?:[A-Za-z][A-Za-z0-9_]*\(|\[|\{)/;
376
+ /** Before a value's first `#`: a quote or an expansion it may sit inside. */
377
+ const AMBIGUOUS_COMMENT = /["'`]|\$[({]/;
378
+
379
+ /**
380
+ * A config item's value, from `from` on line `index`: where it ends, and its
381
+ * post-value comment if it has one, as upstream (@env-spec/parser) reads it:
382
+ * - `"` may span lines; `"""` and a ``` fence must, closing on a later line;
383
+ * `'` and a single backtick may not.
384
+ * - A call, list or object is read whole, across lines if it spans them, so
385
+ * a `#` in it is not the value's comment. Any other unquoted value ends at
386
+ * its first `#`.
387
+ * Anything upstream refuses (a value that never ends, text after a closed
388
+ * one) is unreadable here too. So is a `#` that may sit inside quotes or an
389
+ * expansion, and a structure that does not read whole (too deep, or broken):
390
+ * where the value ends is a guess there, and a guess could lose a real
391
+ * @proxy or invent one.
392
+ */
393
+ function endOfValue(
394
+ source: Source,
395
+ index: number,
396
+ firstLine: string,
397
+ from: number,
398
+ afterBlank: boolean,
399
+ ): { lastLine: number; comment: string | null } | Failure {
400
+ const { lines } = source;
401
+ const value = firstLine.slice(from);
402
+ const after = (lastLine: number, rest: string): { lastLine: number; comment: string | null } | Failure => {
403
+ const match = /^[ \t]*(?:#(.*))?$/.exec(rest);
404
+ if (match === null) return "schema_malformed";
405
+ return { lastLine, comment: match[1] ?? null };
406
+ };
407
+ const fence = value.startsWith('"""')
408
+ ? '"""'
409
+ : value.startsWith("```")
410
+ ? "```"
411
+ : QUOTES.has(value[0] ?? "")
412
+ ? value[0]!
413
+ : null;
414
+ if (afterBlank && fence !== null) {
415
+ // Upstream refuses a quoted value after a blank.
416
+ return "schema_malformed";
417
+ }
418
+ if (fence === null) {
419
+ // After a blank, upstream reads even a call, list or object as plain
420
+ // text, up to its first #.
421
+ if (!afterBlank && STRUCTURE.test(value)) {
422
+ // Read whole, across lines if it spans them (a line break or a `#`
423
+ // comment inside it is a blank there, as upstream reads it).
424
+ const start = source.lineStarts[index]! + (lines[index]!.length - firstLine.length) + from;
425
+ let end: number;
426
+ try {
427
+ end = new DecoratorReader(source.text, { start, value: true }).structureEnd();
428
+ } catch (error) {
429
+ if (!(error instanceof SyntaxFailure)) throw error;
430
+ // Not readable whole. Upstream reads some of these as plain text and
431
+ // refuses others; any reading here would be a guess.
432
+ return "schema_malformed";
47
433
  }
434
+ let lastLine = index;
435
+ while (lastLine + 1 < lines.length && source.lineStarts[lastLine + 1]! < end) lastLine += 1;
436
+ return after(lastLine, lines[lastLine]!.slice(end - source.lineStarts[lastLine]!));
437
+ }
438
+ const hash = value.indexOf("#");
439
+ if (hash === -1) return { lastLine: index, comment: null };
440
+ if (AMBIGUOUS_COMMENT.test(value.slice(0, hash))) return "schema_malformed";
441
+ return { lastLine: index, comment: value.slice(hash + 1) };
442
+ }
443
+ let text = value.slice(fence.length);
444
+ if (fence.length === 3) {
445
+ // Upstream refuses a triple-quoted or fenced value closed on its own line.
446
+ if (text.replaceAll(`\\${fence}`, "").includes(fence)) return "schema_malformed";
447
+ }
448
+ const spansLines = fence !== "'" && fence !== "`";
449
+ let lineIndex = index;
450
+ for (;;) {
451
+ let i = 0;
452
+ while (i < text.length) {
453
+ // An escaped delimiter (\" in "...", \""" in """...""") does not close.
454
+ if (text[i] === "\\" && (fence.length === 1 || text.startsWith(fence, i + 1))) {
455
+ i += 1 + (fence.length === 1 ? 1 : fence.length);
456
+ continue;
457
+ }
458
+ if (lineIndex > index || fence.length === 1) {
459
+ if (text.startsWith(fence, i)) return after(lineIndex, text.slice(i + fence.length));
460
+ }
461
+ i += 1;
462
+ }
463
+ lineIndex += 1;
464
+ if (!spansLines || lineIndex >= lines.length) return "schema_unterminated_value";
465
+ text = lines[lineIndex]!;
466
+ }
467
+ }
468
+
469
+ // --- The schema -------------------------------------------------------------------
470
+
471
+ interface Item {
472
+ key: string;
473
+ block: Block;
474
+ }
475
+
476
+ /** The schema's text, its lines (without their line breaks), and the offset
477
+ * each line starts at. */
478
+ interface Source {
479
+ text: string;
480
+ lines: string[];
481
+ lineStarts: number[];
482
+ }
483
+
484
+ function sourceOf(text: string): Source {
485
+ const lines = text.split(/\r?\n/);
486
+ const lineStarts: number[] = [];
487
+ let offset = 0;
488
+ for (const line of lines) {
489
+ lineStarts.push(offset);
490
+ offset += line.length + (text[offset + line.length] === "\r" ? 2 : 1);
491
+ }
492
+ return { text, lines, lineStarts };
493
+ }
494
+
495
+ interface Reading {
496
+ items: Item[];
497
+ detached: Block[];
498
+ }
499
+
500
+ function read(text: string): Reading | Failure {
501
+ const source = sourceOf(text);
502
+ const { lines } = source;
503
+ const items: Item[] = [];
504
+ const detached: Block[] = [];
505
+ let block = emptyBlock();
506
+ const endBlock = (): void => {
507
+ if (block.decorators.length > 0) detached.push(block);
508
+ block = emptyBlock();
509
+ };
510
+ /** False when the decorators cannot be read: upstream refuses the file,
511
+ * and what they say about routing is unknown. */
512
+ const addDecorators = (into: Block, content: string): boolean => {
513
+ const decorators = readDecorators(content);
514
+ if (decorators === null) return false;
515
+ into.decorators.push(...decorators);
516
+ return true;
517
+ };
518
+
519
+ for (let index = 0; index < lines.length; index += 1) {
520
+ const raw = lines[index]!;
521
+ if (/^[ \t]*$/.test(raw)) {
522
+ endBlock();
48
523
  continue;
49
524
  }
50
- if (line === "") {
51
- // A blank line ends a decorator block that never reached a key —
52
- // env-spec decorators attach to the declaration directly below them.
53
- pending = { required: false, sensitive: false, other: [] };
525
+ if (raw.startsWith("#")) {
526
+ const content = raw.slice(1).replace(/^[ \t]+/, "");
527
+ // A divider, as upstream has it: three of - = * #, in any mix.
528
+ if (/^[-=*#]{3}/.test(content)) endBlock();
529
+ else if (content.startsWith("@") && !addDecorators(block, content)) return "schema_malformed";
54
530
  continue;
55
531
  }
56
- const key = KEY_LINE.exec(line);
57
- if (key) {
58
- entries.push({
59
- name: key[1]!,
60
- required: pending.required,
61
- sensitive: pending.sensitive,
62
- ...(pending.type !== undefined ? { type: pending.type } : {}),
63
- otherDecorators: pending.other,
532
+ // Only an item may be indented, and only with spaces and tabs: upstream
533
+ // refuses an indented comment, or a line led by any other character.
534
+ const line = raw.replace(/^[ \t]+/, "");
535
+ const keyStart = EXPORT_PREFIX.exec(line)?.[0].length ?? 0;
536
+ const item = ITEM_KEY.exec(line.slice(keyStart));
537
+ // Neither blank, a comment nor KEY=...: upstream refuses the file, and a
538
+ // line we cannot read may be a key whose decorators we would lose.
539
+ if (item === null || item[1]!.length > MAX_PROJECT_ENV_KEY_CHARS) return "schema_malformed";
540
+ const attached = block;
541
+ block = emptyBlock();
542
+ const itemEnd = keyStart + item[0].length;
543
+ const afterEquals = line.slice(itemEnd);
544
+ const blanks = afterEquals.length - afterEquals.replace(/^[ \t]+/, "").length;
545
+ const end = endOfValue(source, index, line, itemEnd + blanks, blanks > 0);
546
+ if (typeof end === "string") return end;
547
+ index = end.lastLine;
548
+ if (end.comment !== null) {
549
+ const comment = end.comment.replace(/^[ \t]+/, "");
550
+ // Upstream refuses a divider after a value.
551
+ if (/^[-=*#]{3}/.test(comment)) return "schema_malformed";
552
+ if (comment.startsWith("@") && !addDecorators(attached, comment)) return "schema_malformed";
553
+ }
554
+ items.push({ key: item[1]!, block: attached });
555
+ }
556
+ endBlock();
557
+ return { items, detached };
558
+ }
559
+
560
+ function booleanDecorator(decorators: readonly Decorator[], name: string): boolean | null {
561
+ let result: boolean | null = null;
562
+ for (const decorator of decorators) {
563
+ if (decorator.name !== name) continue;
564
+ const explicitFalse =
565
+ decorator.form === "value" && decorator.value?.kind === "bare" && decorator.value.value === "false";
566
+ result = (result ?? false) || !explicitFalse;
567
+ }
568
+ return result;
569
+ }
570
+
571
+ /** Withheld, for one of the reasons the contract knows. */
572
+ const unsupported = (reason: Reason): ProjectEnvDirective => ({ kind: "unsupported", reason });
573
+
574
+ const OPTION_REASONS: Readonly<Record<string, Reason>> = {
575
+ path: "option_path",
576
+ method: "option_method",
577
+ block: "option_block",
578
+ keys: "option_keys",
579
+ substituteIn: "option_substitute_in",
580
+ transform: "option_transform",
581
+ approval: "option_approval",
582
+ rules: "option_rules",
583
+ };
584
+
585
+ /** A declared domain in canonical form, or why it cannot be proxied. */
586
+ function proxyDomain(raw: string): string | { reason: Reason } {
587
+ if (raw.includes("*")) return { reason: "wildcard_domain" };
588
+ const canonical = canonicalHostname(raw);
589
+ if (canonical === null) {
590
+ const bare = raw.replace(/^\[(.*)\]$/, "$1");
591
+ const lastLabel = raw.replace(/\.$/, "").split(".").pop() ?? "";
592
+ const looksNumeric = /^[0-9]+$/.test(lastLabel) || /^0x[0-9a-f]*$/i.test(lastLabel);
593
+ return { reason: isIP(bare) !== 0 || looksNumeric ? "ip_literal" : "invalid_domain" };
594
+ }
595
+ if (canonical === "localhost" || canonical.endsWith(".localhost")) return { reason: "localhost" };
596
+ if (!canonical.includes(".")) return { reason: "invalid_domain" };
597
+ if (!isProxiableDomain(canonical)) return { reason: "reserved_domain" };
598
+ return canonical;
599
+ }
600
+
601
+ function stringsOf(value: Value): string[] | null {
602
+ if (value.kind === "string" || value.kind === "bare") return [value.value];
603
+ if (value.kind !== "array") return null;
604
+ const out: string[] = [];
605
+ for (const item of value.items) {
606
+ if (item.kind !== "string" && item.kind !== "bare") return null;
607
+ out.push(item.value);
608
+ }
609
+ return out;
610
+ }
611
+
612
+ /** What an item's @proxy decorators ask for, failing closed on anything Uai
613
+ * cannot honour in full. */
614
+ function directiveOf(block: Block): ProjectEnvDirective {
615
+ const proxies = block.decorators.filter((decorator) => decorator.name === "proxy");
616
+ if (proxies.length === 0) return { kind: "none" };
617
+ const calls = proxies.filter((decorator) => decorator.form === "call");
618
+ const values = proxies.filter((decorator) => decorator.form !== "call");
619
+ if (values.length > 0) {
620
+ if (calls.length > 0 || values.length > 1) return unsupported("conflicting_forms");
621
+ // Quoted or not, a string is the same string (env-spec).
622
+ const value = values[0]!.value;
623
+ const word = value?.kind === "bare" || value?.kind === "string" ? value.value : null;
624
+ if (word === "passthrough") return { kind: "passthrough" };
625
+ if (word === "omit") return { kind: "omit" };
626
+ return unsupported("invalid_value");
627
+ }
628
+ const domains = new Set<string>();
629
+ for (const call of calls) {
630
+ let declared: string[] | null = null;
631
+ for (const arg of call.args ?? []) {
632
+ if (arg.name === null) return unsupported("malformed");
633
+ if (arg.name !== "domain") {
634
+ return unsupported(OPTION_REASONS[arg.name] ?? "option_unknown");
635
+ }
636
+ if (declared !== null) return unsupported("malformed");
637
+ declared = stringsOf(arg.value);
638
+ if (declared === null) return unsupported("malformed");
639
+ }
640
+ if (declared === null || declared.length === 0) return unsupported("missing_domain");
641
+ for (const raw of declared) {
642
+ const domain = proxyDomain(raw);
643
+ if (typeof domain !== "string") return unsupported(domain.reason);
644
+ domains.add(domain);
645
+ }
646
+ }
647
+ if (domains.size > MAX_PROJECT_ENV_PROXY_DOMAINS) return unsupported("too_many_domains");
648
+ return { kind: "proxy", domains: [...domains].sort() };
649
+ }
650
+
651
+ /** The declared placeholder's shape: the prefix up to and including its last
652
+ * `_` or `-`, and a length that leaves room for the random part. */
653
+ function placeholderOf(block: Block): { prefix: string; totalLength: number } | "unusable" | null {
654
+ const declared = block.decorators.filter((decorator) => decorator.name === "placeholder");
655
+ if (declared.length === 0) return null;
656
+ if (declared.length > 1) return "unusable";
657
+ const value = declared[0]!.value;
658
+ if (declared[0]!.form !== "value" || (value?.kind !== "string" && value?.kind !== "bare")) return "unusable";
659
+ const literal = value.value;
660
+ if (literal.length === 0 || literal.length > MAX_PROJECT_ENV_PLACEHOLDER_CHARS) return "unusable";
661
+ const cut = Math.max(literal.lastIndexOf("_"), literal.lastIndexOf("-"));
662
+ const prefix = literal.slice(0, cut + 1);
663
+ if (!/^[A-Za-z0-9._~-]*$/.test(prefix)) return "unusable";
664
+ const totalLength = Math.max(literal.length, prefix.length + PLACEHOLDER_RANDOM_CHARACTERS);
665
+ if (totalLength > MAX_PROJECT_ENV_PLACEHOLDER_CHARS) return "unusable";
666
+ return { prefix, totalLength };
667
+ }
668
+
669
+ function typeOf(block: Block): string | undefined {
670
+ const declared = block.decorators.find((decorator) => decorator.name === "type" && decorator.form === "value");
671
+ if (declared === undefined) return undefined;
672
+ const text = declared.source.slice("type=".length);
673
+ if (text.length === 0 || text.length > MAX_PROJECT_ENV_TYPE_CHARS || /[\u0000-\u001f\u007f]/.test(text)) return undefined;
674
+ return text;
675
+ }
676
+
677
+ const routes = (block: Block): boolean =>
678
+ block.decorators.some((decorator) => decorator.name === "proxy" || decorator.name === "placeholder");
679
+
680
+ interface Parsed {
681
+ declarations: ProjectEnvSchemaDeclaration[];
682
+ issues: ProjectEnvSchemaIssue[];
683
+ /** Decorators Uai does not read, per key, for the legacy diagnostics. */
684
+ other: Map<string, string[]>;
685
+ }
686
+
687
+ function parse(input: string | Uint8Array): Parsed | Failure {
688
+ let text: string;
689
+ if (typeof input === "string") {
690
+ if (Buffer.byteLength(input, "utf8") > MAX_ENV_SCHEMA_BYTES) return "schema_too_large";
691
+ text = input;
692
+ } else {
693
+ if (input.byteLength > MAX_ENV_SCHEMA_BYTES) return "schema_too_large";
694
+ try {
695
+ text = new TextDecoder("utf-8", { fatal: true, ignoreBOM: true }).decode(input);
696
+ } catch {
697
+ return "schema_not_text";
698
+ }
699
+ }
700
+ if (text.includes("\u0000")) return "schema_not_text";
701
+ // Upstream refuses a byte-order mark: so does this, rather than read past it.
702
+ if (text.startsWith("\uFEFF")) return "schema_malformed";
703
+
704
+ const reading = read(text);
705
+ if (typeof reading === "string") return reading;
706
+
707
+ // Each issue once per key (or once for the project), as an observation
708
+ // requires; this also keeps their number bounded by the keys.
709
+ const issues: ProjectEnvSchemaIssue[] = [];
710
+ const reported = new Set<string>();
711
+ const issue = (code: KnownProjectEnvSchemaIssueCode, key?: string): void => {
712
+ const id = `${code}\u0000${key ?? ""}`;
713
+ if (reported.has(id)) return;
714
+ reported.add(id);
715
+ issues.push(key === undefined ? { code } : { code, key });
716
+ };
717
+ /** The keys a `keys=[...]` argument names, or null if it cannot be read. */
718
+ const keysNamed = (decorator: Decorator): string[] | null => {
719
+ const named: string[] = [];
720
+ for (const arg of decorator.args ?? []) {
721
+ if (arg.name !== "keys") continue;
722
+ const keys = stringsOf(arg.value);
723
+ if (keys === null || keys.some((key) => !/^[A-Za-z_][A-Za-z0-9_.-]*$/.test(key))) return null;
724
+ named.push(...keys);
725
+ }
726
+ return named;
727
+ };
728
+
729
+ const headerKeys = new Set<string>();
730
+ for (const block of reading.detached) {
731
+ for (const decorator of block.decorators) {
732
+ if (decorator.name === "proxyConfig") issue("proxy_config");
733
+ if (decorator.name !== "proxy") continue;
734
+ issue("header_proxy_rule");
735
+ const keys = keysNamed(decorator);
736
+ if (keys === null) return "schema_malformed";
737
+ for (const key of keys) headerKeys.add(key);
738
+ }
739
+ }
740
+ // An item's own @proxy can name other keys to inject too (`keys=[...]`).
741
+ // Uai cannot, so they are withheld like the item.
742
+ const optionKeys = new Set<string>();
743
+ for (const item of reading.items) {
744
+ for (const decorator of item.block.decorators) {
745
+ // @proxyConfig belongs in the header; Uai honours it nowhere.
746
+ if (decorator.name === "proxyConfig") issue("proxy_config");
747
+ if (decorator.name !== "proxy" || decorator.form !== "call") continue;
748
+ const keys = keysNamed(decorator);
749
+ if (keys === null) return "schema_malformed";
750
+ for (const key of keys) optionKeys.add(key);
751
+ }
752
+ }
753
+
754
+ const byKey = new Map<string, ProjectEnvSchemaDeclaration>();
755
+ const other = new Map<string, string[]>();
756
+ const routed = new Set<string>();
757
+ for (const item of reading.items) {
758
+ // A key with `.` or `-` in it is one upstream reads, but no value can be
759
+ // stored under it (the settings API and a shell refuse the name): not a
760
+ // declaration. Any keys its @proxy names were taken above.
761
+ if (!CONTRACT_KEY.test(item.key)) continue;
762
+ const { block } = item;
763
+ const directive = directiveOf(block);
764
+ const proxied = block.decorators.some((decorator) => decorator.name === "proxy");
765
+ const declaration: ProjectEnvSchemaDeclaration = {
766
+ key: item.key,
767
+ required: booleanDecorator(block.decorators, "required") ?? false,
768
+ sensitive: proxied || (booleanDecorator(block.decorators, "sensitive") ?? false),
769
+ directive,
770
+ };
771
+ const type = typeOf(block);
772
+ if (type !== undefined) declaration.type = type;
773
+ if (directive.kind === "proxy") {
774
+ const placeholder = placeholderOf(block);
775
+ if (placeholder === "unusable") issue("placeholder_ignored", item.key);
776
+ else if (placeholder !== null) declaration.placeholder = placeholder;
777
+ }
778
+ const unread = block.decorators
779
+ .filter((decorator) => !["required", "sensitive", "type", "proxy", "placeholder"].includes(decorator.name))
780
+ .map((decorator) => decorator.source);
781
+
782
+ const earlier = byKey.get(item.key);
783
+ if (earlier === undefined) {
784
+ byKey.set(item.key, declaration);
785
+ other.set(item.key, unread);
786
+ if (routes(block)) routed.add(item.key);
787
+ continue;
788
+ }
789
+ // Declared again: plain repeats merge; any routing on either withholds it.
790
+ if (routes(block) || routed.has(item.key)) {
791
+ routed.add(item.key);
792
+ byKey.set(item.key, {
793
+ key: item.key,
794
+ required: earlier.required || declaration.required,
795
+ sensitive: true,
796
+ ...(earlier.type !== undefined ? { type: earlier.type } : {}),
797
+ directive: unsupported("duplicate_key"),
798
+ });
799
+ } else {
800
+ byKey.set(item.key, {
801
+ ...earlier,
802
+ required: earlier.required || declaration.required,
803
+ sensitive: earlier.sensitive || declaration.sensitive,
64
804
  });
65
805
  }
66
- pending = { required: false, sensitive: false, other: [] };
806
+ other.get(item.key)!.push(...unread);
807
+ }
808
+
809
+ // Keys a rule names: withheld, and declared here if the file did not.
810
+ const withhold = (key: string, reason: Reason): void => {
811
+ const earlier = byKey.get(key);
812
+ byKey.set(key, {
813
+ key,
814
+ required: earlier?.required ?? false,
815
+ sensitive: true,
816
+ ...(earlier?.type !== undefined ? { type: earlier.type } : {}),
817
+ directive: unsupported(reason),
818
+ });
819
+ if (!other.has(key)) other.set(key, []);
820
+ };
821
+ for (const key of [...headerKeys].sort()) if (CONTRACT_KEY.test(key)) withhold(key, "header_rule");
822
+ for (const key of [...optionKeys].sort()) {
823
+ if (CONTRACT_KEY.test(key) && !headerKeys.has(key)) withhold(key, "option_keys");
67
824
  }
68
- return entries;
825
+ if (byKey.size > MAX_PROJECT_ENV_SCHEMA_DECLARATIONS) return "schema_too_many_declarations";
826
+ return { declarations: [...byKey.values()], issues, other };
827
+ }
828
+
829
+ /** A repository's `.env.schema`, as Uai reads it (ADR-153 §4). */
830
+ export function parseEnvSchemaDeclarations(input: string | Uint8Array): EnvSchemaParse {
831
+ const parsed = parse(input);
832
+ if (typeof parsed === "string") return { ok: false, reason: parsed };
833
+ return { ok: true, declarations: parsed.declarations, issues: parsed.issues };
834
+ }
835
+
836
+ // --- ADR-122 phase 1: the required-keys note ---------------------------------------
837
+
838
+ export interface EnvSchemaEntry {
839
+ name: string;
840
+ required: boolean;
841
+ sensitive: boolean;
842
+ type?: string;
843
+ /** Decorators Uai does not interpret, kept for diagnostics. */
844
+ otherDecorators: string[];
845
+ }
846
+
847
+ /** The ADR-122 shape, for the required-keys note. A schema that cannot be
848
+ * read declares nothing here; the note is advisory. */
849
+ export function parseEnvSchema(content: string): EnvSchemaEntry[] {
850
+ const parsed = parse(content);
851
+ if (typeof parsed === "string") return [];
852
+ return parsed.declarations.map((declaration) => ({
853
+ name: declaration.key,
854
+ required: declaration.required,
855
+ sensitive: declaration.sensitive,
856
+ ...(declaration.type !== undefined ? { type: declaration.type } : {}),
857
+ otherDecorators: parsed.other.get(declaration.key) ?? [],
858
+ }));
69
859
  }
70
860
 
71
861
  /** Required keys the provided value set does not cover. Presence is a