@runuai/host 0.9.148 → 0.9.149
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/db/migrations/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +312 -2
- package/images/standard/Dockerfile +44 -6
- package/images/standard/container/uai-init +85 -2
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/lib/agent-cli.ts +18 -1
- package/lib/agent.ts +99 -34
- package/lib/agents/codex.ts +9 -1
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +2 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/transport.ts +9 -5
- package/lib/browser-testing.ts +6 -0
- package/lib/codex-auth.ts +2 -0
- package/lib/credential-store.ts +141 -6
- package/lib/engine-accounts.ts +1 -0
- package/lib/env-file.ts +7 -0
- package/lib/env-schema.ts +841 -51
- package/lib/git-diff.ts +1 -0
- package/lib/git-identity.ts +2 -0
- package/lib/github-tokens.ts +1 -0
- package/lib/host-env.ts +98 -18
- package/lib/lease.ts +11 -3
- package/lib/machine-gateway-tunnel.ts +195 -17
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/managed-runtime.ts +270 -51
- package/lib/mcp-gateway.ts +777 -26
- package/lib/orchestrator.ts +391 -69
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-loader.ts +237 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/runtime-archive.ts +79 -16
- package/lib/runtime-authority.ts +44 -0
- package/lib/runtime-state.ts +175 -10
- package/lib/secrets-proxy/address.ts +72 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +60 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +141 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +574 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/shared-files-s3.ts +3 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +51 -3
- package/lib/standard-image.ts +50 -12
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +193 -0
- package/lib/task-environment/apple-container.ts +45 -11
- package/lib/task-environment/docker.ts +51 -12
- package/lib/task-environment/index.ts +6 -2
- package/lib/task-environment/machine-task-up.ts +258 -25
- package/lib/task-environment/machine.ts +80 -22
- package/lib/task-environment/registry.ts +3 -2
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +92 -16
- package/lib/task-environment/types.ts +63 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +44 -0
- package/lib/task-gateway-state.ts +497 -0
- package/lib/transcript.ts +1 -0
- package/package.json +13 -1
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/task-up.sh +476 -27
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/src/cli.ts +26 -3
- package/src/index.ts +273 -19
- package/src/main.ts +939 -48
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +281 -0
- package/src/project-env-wording.ts +47 -0
- package/src/protocol.ts +2219 -15
package/lib/env-schema.ts
CHANGED
|
@@ -1,71 +1,861 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
4
|
-
*
|
|
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:
|
|
7
|
-
*
|
|
8
|
-
*
|
|
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
|
-
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
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
|
-
|
|
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
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
/**
|
|
23
|
-
|
|
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
|
|
27
|
-
const
|
|
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
|
-
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
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 (
|
|
51
|
-
|
|
52
|
-
//
|
|
53
|
-
|
|
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
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
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
|
-
|
|
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
|
|
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
|