@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.
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 +391 -69
  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
@@ -0,0 +1,342 @@
1
+ /**
2
+ * ADR-153 §4, "One observation per task and project", on Docker and Apple.
3
+ *
4
+ * task-up records the commit each project's worktree branched from in the
5
+ * task's host control tree, which the container never sees, before anything
6
+ * in the task starts. From that commit alone, read in the host-only git cache
7
+ * (never in the worktree or its container-mounted repository), the runner
8
+ * reads `.env.schema`, parses it, and keeps the observation beside the
9
+ * record, written once. A resume, a retry or a restart reuses it, so editing
10
+ * `.env.schema` inside the task changes nothing for that task.
11
+ */
12
+
13
+ import { spawnSync } from "node:child_process";
14
+ import { randomBytes } from "node:crypto";
15
+ import {
16
+ closeSync,
17
+ fsyncSync,
18
+ linkSync,
19
+ mkdirSync,
20
+ openSync,
21
+ readFileSync,
22
+ unlinkSync,
23
+ writeSync,
24
+ } from "node:fs";
25
+ import { join } from "node:path";
26
+
27
+ import {
28
+ CURRENT_PROJECT_ENV_PARSER_EPOCH,
29
+ isProjectEnvSchemaObservation,
30
+ type ProjectEnvSchemaObservation,
31
+ } from "../src/protocol";
32
+ import { MAX_ENV_SCHEMA_BYTES, parseEnvSchemaDeclarations } from "./env-schema";
33
+ import { projectDir, taskDir } from "./env";
34
+
35
+ /** Where task-up records each project's base, and the runner its observation. */
36
+ export function taskEnvSchemaDir(taskId: string): string {
37
+ return join(taskDir(taskId), ".uai", "env-schemas");
38
+ }
39
+
40
+ /** The host-only cache task-up fetches each project into. */
41
+ function projectHostCache(projectId: string): string {
42
+ return join(projectDir(projectId), "host-cache.git");
43
+ }
44
+
45
+ // Ids name files here: anything else is never looked up.
46
+ const SAFE_ID = /^[A-Za-z0-9_-]{1,128}$/;
47
+ const OID = /^(?:[0-9a-f]{40}|[0-9a-f]{64})$/;
48
+
49
+ /** The base task-up recorded: a commit, a proven-empty repository, or none
50
+ * (a task started before this was recorded, whose base cannot be told). */
51
+ export type RecordedEnvSchemaBase = { kind: "commit"; oid: string } | { kind: "empty" } | null;
52
+
53
+ function recordedBase(taskId: string, projectId: string): RecordedEnvSchemaBase {
54
+ let text: string;
55
+ try {
56
+ text = readFileSync(join(taskEnvSchemaDir(taskId), `${projectId}.base`), "utf8");
57
+ } catch {
58
+ return null;
59
+ }
60
+ const line = text.trim();
61
+ if (line === "empty") return { kind: "empty" };
62
+ const match = /^oid ([0-9a-f]+)$/.exec(line);
63
+ return match !== null && OID.test(match[1]!) ? { kind: "commit", oid: match[1]! } : null;
64
+ }
65
+
66
+ const UNSET_GIT_ENV = [
67
+ "GIT_ASKPASS",
68
+ "SSH_ASKPASS",
69
+ "SSH_AUTH_SOCK",
70
+ "GIT_CONFIG",
71
+ "GIT_CONFIG_PARAMETERS",
72
+ "GIT_TRACE",
73
+ "GIT_TRACE2",
74
+ "GIT_TRACE_CURL",
75
+ "GIT_TRACE_PACKET",
76
+ "GIT_TRACE_REDACT",
77
+ "GIT_DIR",
78
+ "GIT_WORK_TREE",
79
+ "GIT_OBJECT_DIRECTORY",
80
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES",
81
+ ];
82
+
83
+ /** Read-only plumbing in the host cache, with task-up's `git_host_control`
84
+ * hardening: no system or global config, no hooks, no credentials. */
85
+ function git(cache: string, args: string[]): Buffer | null {
86
+ const environment: NodeJS.ProcessEnv = { ...process.env };
87
+ for (const name of UNSET_GIT_ENV) delete environment[name];
88
+ Object.assign(environment, {
89
+ LC_ALL: "C",
90
+ GIT_CONFIG_GLOBAL: "/dev/null",
91
+ GIT_CONFIG_NOSYSTEM: "1",
92
+ GIT_CONFIG_COUNT: "2",
93
+ GIT_CONFIG_KEY_0: "core.hooksPath",
94
+ GIT_CONFIG_VALUE_0: "/dev/null",
95
+ GIT_CONFIG_KEY_1: "core.fsmonitor",
96
+ GIT_CONFIG_VALUE_1: "false",
97
+ GIT_SSH_COMMAND: "/bin/false",
98
+ GIT_TERMINAL_PROMPT: "0",
99
+ });
100
+ // `--no-replace-objects`: a replace ref in the cache must not stand in for
101
+ // the commit or blob that was recorded.
102
+ const argv = ["--no-replace-objects", "--git-dir", cache, "-c", "credential.helper=", ...args];
103
+ // A process that could not run to its end (a timeout under load, a signal)
104
+ // says nothing about the repository: tried again before it counts.
105
+ for (let attempt = 1; ; attempt += 1) {
106
+ const result = spawnSync("git", argv, {
107
+ env: environment,
108
+ maxBuffer: MAX_ENV_SCHEMA_BYTES + 64 * 1024,
109
+ timeout: 15_000,
110
+ });
111
+ if (result.error === undefined && result.signal === null) {
112
+ return result.status === 0 && Buffer.isBuffer(result.stdout) ? result.stdout : null;
113
+ }
114
+ if (attempt >= GIT_ATTEMPTS) return null;
115
+ }
116
+ }
117
+
118
+ const GIT_ATTEMPTS = 3;
119
+
120
+ /** A commit's committer time in seconds, from `rev-list --format=%ct`; null
121
+ * for anything else, or a time milliseconds cannot hold exactly. */
122
+ function committerTime(listed: Buffer | null): number | null {
123
+ if (listed === null) return null;
124
+ // `rev-list --format=%ct` prints the time on its own line, after a
125
+ // "commit <oid>" header on a git that still prints one.
126
+ const line = listed.toString("utf8").trim().split("\n").pop() ?? "";
127
+ if (!/^\d{1,15}$/.test(line)) return null;
128
+ const seconds = Number(line);
129
+ return Number.isSafeInteger(seconds * 1000) ? seconds : null;
130
+ }
131
+ const unreached = (projectId: string, reason: "base_commit_missing" | "schema_read_failed"): ProjectEnvSchemaObservation => ({
132
+ projectId,
133
+ status: "unavailable",
134
+ parserEpoch: CURRENT_PROJECT_ENV_PARSER_EPOCH,
135
+ baseOid: null,
136
+ baseCommitTimeMs: null,
137
+ reason,
138
+ });
139
+
140
+ /**
141
+ * The reading of one project's schema at a base, as the git commands it
142
+ * needs: each yield is a command's arguments, answered with its output (null
143
+ * when it failed). Written once, run where the repository is: in the host
144
+ * cache on an owned runner, in the fresh clone on a task machine.
145
+ */
146
+ function* observationSteps(
147
+ projectId: string,
148
+ base: RecordedEnvSchemaBase,
149
+ ): Generator<string[], ProjectEnvSchemaObservation, Buffer | null> {
150
+ if (base === null) return unreached(projectId, "base_commit_missing");
151
+ if (base.kind === "empty") {
152
+ return {
153
+ projectId,
154
+ status: "resolved",
155
+ parserEpoch: CURRENT_PROJECT_ENV_PARSER_EPOCH,
156
+ baseOid: null,
157
+ baseCommitTimeMs: null,
158
+ declarations: [],
159
+ issues: [],
160
+ };
161
+ }
162
+ // Plumbing only: `rev-list` prints the committer time alone, so no
163
+ // repository setting (a signature check, a pager) runs anything, and a
164
+ // commit message of any size is never read.
165
+ const time = committerTime(yield ["rev-list", "--no-walk", "--format=%ct", base.oid]);
166
+ if (time === null) return unreached(projectId, "schema_read_failed");
167
+ const committed = {
168
+ projectId,
169
+ parserEpoch: CURRENT_PROJECT_ENV_PARSER_EPOCH,
170
+ baseOid: base.oid,
171
+ baseCommitTimeMs: time * 1000,
172
+ };
173
+ const unreadable = (reason: string): ProjectEnvSchemaObservation => ({ ...committed, status: "unavailable", reason });
174
+ const entry = yield ["ls-tree", "-z", base.oid, "--", ".env.schema"];
175
+ if (entry === null) return unreached(projectId, "schema_read_failed");
176
+ // A commit without the file declares nothing, which is a trusted answer.
177
+ if (entry.length === 0) return { ...committed, status: "resolved", declarations: [], issues: [] };
178
+ const match = /^(\d{6}) blob ([0-9a-f]+)\t\.env\.schema\0$/.exec(entry.toString("utf8"));
179
+ if (match === null || (match[1] !== "100644" && match[1] !== "100755")) return unreadable("schema_not_regular_file");
180
+ const blob = match[2]!;
181
+ const size = (yield ["cat-file", "-s", blob])?.toString("utf8").trim();
182
+ if (size === undefined || !/^\d+$/.test(size)) return unreached(projectId, "schema_read_failed");
183
+ if (Number(size) > MAX_ENV_SCHEMA_BYTES) return unreadable("schema_too_large");
184
+ const content = yield ["cat-file", "blob", blob];
185
+ if (content === null) return unreached(projectId, "schema_read_failed");
186
+ const parsed = parseEnvSchemaDeclarations(new Uint8Array(content));
187
+ if (!parsed.ok) return unreadable(parsed.reason);
188
+ return { ...committed, status: "resolved", declarations: parsed.declarations, issues: parsed.issues };
189
+ }
190
+
191
+ /** The observation of one project's schema at its recorded base, read in
192
+ * the host cache. */
193
+ export function observeEnvSchemaAt(
194
+ projectId: string,
195
+ base: RecordedEnvSchemaBase,
196
+ cache = projectHostCache(projectId),
197
+ ): ProjectEnvSchemaObservation {
198
+ const steps = observationSteps(projectId, base);
199
+ let next = steps.next();
200
+ while (!next.done) next = steps.next(git(cache, next.value));
201
+ return next.value;
202
+ }
203
+
204
+ /** Git run in a repository somewhere else: its output, or null if it failed. */
205
+ export type RemoteGit = (args: string[]) => Promise<Buffer | null>;
206
+
207
+ async function observeWith(projectId: string, base: RecordedEnvSchemaBase, run: RemoteGit): Promise<ProjectEnvSchemaObservation> {
208
+ const steps = observationSteps(projectId, base);
209
+ let next = steps.next();
210
+ while (!next.done) next = steps.next(await run(next.value));
211
+ return next.value;
212
+ }
213
+
214
+ function readObservation(taskId: string, projectId: string): ProjectEnvSchemaObservation | null {
215
+ let parsed: unknown;
216
+ try {
217
+ parsed = JSON.parse(readFileSync(join(taskEnvSchemaDir(taskId), `${projectId}.json`), "utf8"));
218
+ } catch {
219
+ return null;
220
+ }
221
+ return isProjectEnvSchemaObservation(parsed) && parsed.projectId === projectId ? parsed : null;
222
+ }
223
+
224
+ /** Writes `body` at `path`, durably, unless something is there already,
225
+ * which then stays. */
226
+ function writeOnce(dir: string, path: string, body: string): void {
227
+ const temporary = join(dir, `.${randomBytes(8).toString("hex")}.tmp`);
228
+ const fd = openSync(temporary, "wx", 0o600);
229
+ try {
230
+ writeSync(fd, body);
231
+ fsyncSync(fd);
232
+ } finally {
233
+ closeSync(fd);
234
+ }
235
+ try {
236
+ linkSync(temporary, path);
237
+ } catch (error) {
238
+ if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error;
239
+ } finally {
240
+ unlinkSync(temporary);
241
+ }
242
+ const directory = openSync(dir, "r");
243
+ try {
244
+ fsyncSync(directory);
245
+ } finally {
246
+ closeSync(directory);
247
+ }
248
+ }
249
+
250
+ const cached = new Map<string, ProjectEnvSchemaObservation>();
251
+ const MAX_CACHED = 512;
252
+
253
+ /**
254
+ * Each project's observation for the task, in the order given: the one
255
+ * stored, or one read from its recorded base now and stored before it is
256
+ * returned.
257
+ *
258
+ * The first /env request of the task captures it this way, before any
259
+ * command in the task gets a value: task-up records the base before the
260
+ * container starts. With no base recorded, an observation is stored only
261
+ * once task-up has finished (`final`), since until then task-up may still be
262
+ * about to record it. A project id that cannot name a file, or a store that
263
+ * cannot be written, gets an unavailable observation that is not kept, so a
264
+ * later capture can still record one.
265
+ */
266
+ export function captureTaskEnvSchemas(
267
+ taskId: string,
268
+ projectIds: readonly string[],
269
+ options: { final: boolean } = { final: true },
270
+ ): ProjectEnvSchemaObservation[] {
271
+ return projectIds.map((projectId) => {
272
+ if (!SAFE_ID.test(taskId) || !SAFE_ID.test(projectId)) return unreached(projectId, "schema_read_failed");
273
+ const stored = taskEnvSchemaObservation(taskId, projectId);
274
+ if (stored !== null) return stored;
275
+ const base = recordedBase(taskId, projectId);
276
+ const observation = observeEnvSchemaAt(projectId, base);
277
+ if (base === null && !options.final) return observation;
278
+ return storeObservation(taskId, projectId, observation);
279
+ });
280
+ }
281
+
282
+ /** Stores the task's observation of a project unless one is stored, and
283
+ * answers whichever is stored then; the given one if none can be. */
284
+ function storeObservation(taskId: string, projectId: string, observation: ProjectEnvSchemaObservation): ProjectEnvSchemaObservation {
285
+ try {
286
+ const dir = taskEnvSchemaDir(taskId);
287
+ mkdirSync(dir, { recursive: true, mode: 0o700 });
288
+ writeOnce(dir, join(dir, `${projectId}.json`), `${JSON.stringify(observation)}\n`);
289
+ } catch {
290
+ return observation;
291
+ }
292
+ // Whatever was there first is the task's: a concurrent capture may have won.
293
+ return taskEnvSchemaObservation(taskId, projectId) ?? observation;
294
+ }
295
+
296
+ /**
297
+ * A task machine's capture (ADR-153 §4, "Machine and sidecar"): right after
298
+ * a project's clone, before anything in the task starts, the clone's HEAD
299
+ * and the `.env.schema` at it, read by `git` run in the fresh clone, and
300
+ * stored once in the runner's control tree. A stored observation is reused.
301
+ * A clone with no commit at all is a proven-empty repository.
302
+ */
303
+ export async function captureTaskEnvSchemaFromClone(
304
+ taskId: string,
305
+ projectId: string,
306
+ run: RemoteGit,
307
+ ): Promise<ProjectEnvSchemaObservation> {
308
+ if (!SAFE_ID.test(taskId) || !SAFE_ID.test(projectId)) return unreached(projectId, "schema_read_failed");
309
+ const stored = taskEnvSchemaObservation(taskId, projectId);
310
+ if (stored !== null) return stored;
311
+ const head = (await run(["rev-parse", "--verify", "--quiet", "HEAD^{commit}"]))?.toString("utf8").trim();
312
+ let base: RecordedEnvSchemaBase;
313
+ if (head !== undefined && OID.test(head)) {
314
+ base = { kind: "commit", oid: head };
315
+ } else {
316
+ const refs = await run(["for-each-ref", "--count=1", "--format=%(refname)"]);
317
+ if (refs === null || refs.length > 0) return storeObservation(taskId, projectId, unreached(projectId, "schema_read_failed"));
318
+ base = { kind: "empty" };
319
+ }
320
+ return storeObservation(taskId, projectId, await observeWith(projectId, base, run));
321
+ }
322
+
323
+
324
+ /** Forget what was read of a task's observations, when the task ends. */
325
+ export function forgetTaskEnvSchemaObservations(taskId: string): void {
326
+ for (const key of [...cached.keys()]) if (key.startsWith(`${taskId}\u0000`)) cached.delete(key);
327
+ }
328
+
329
+ /** The task's stored observation of a project, or null when none is stored
330
+ * yet (or what is stored is not one). Stored ones never change, so they are
331
+ * kept in memory once read. */
332
+ export function taskEnvSchemaObservation(taskId: string, projectId: string): ProjectEnvSchemaObservation | null {
333
+ if (!SAFE_ID.test(taskId) || !SAFE_ID.test(projectId)) return null;
334
+ const cacheKey = `${taskId}\u0000${projectId}`;
335
+ const hit = cached.get(cacheKey);
336
+ if (hit !== undefined) return hit;
337
+ const observation = readObservation(taskId, projectId);
338
+ if (observation === null) return null;
339
+ if (cached.size >= MAX_CACHED) cached.delete(cached.keys().next().value!);
340
+ cached.set(cacheKey, observation);
341
+ return observation;
342
+ }
@@ -0,0 +1,148 @@
1
+ /**
2
+ * Immutable project-env staging writer (ADR-153).
3
+ *
4
+ * The sign-in box may create and tag the SecureString but may never read it.
5
+ * A deterministic envelope and a master-key HMAC in the create-time tags make
6
+ * an ambiguous retry recognizable without granting GetParameter or allowing
7
+ * an overwrite of somebody else's value.
8
+ */
9
+
10
+ import { createHash, createHmac } from "node:crypto";
11
+
12
+ import {
13
+ MAX_PROJECT_ENV_STAGED_ENVELOPE_BYTES,
14
+ isProjectEnvStagingPutFrame,
15
+ type ProjectEnvStagingPutFrame,
16
+ } from "../src/protocol";
17
+ import {
18
+ configuredCredentialStore,
19
+ credentialStorePrefix,
20
+ parameterName,
21
+ type CredentialStore,
22
+ type ImmutableParameterPutResult,
23
+ } from "./credential-store";
24
+ import { masterKey } from "./secrets";
25
+
26
+ const STAGING_HMAC_DOMAIN = Buffer.from(
27
+ "uai.project_env.staging.v1\0",
28
+ "utf8",
29
+ );
30
+
31
+ export const PROJECT_ENV_STAGING_TAGS = {
32
+ org: "org",
33
+ project: "project",
34
+ mac: "uai_staging_mac",
35
+ } as const;
36
+
37
+ export class ProjectEnvStagingInvalidError extends Error {
38
+ constructor(message: string) {
39
+ super(message);
40
+ this.name = "ProjectEnvStagingInvalidError";
41
+ }
42
+ }
43
+
44
+ export interface StoredProjectEnvStagingEnvelope {
45
+ v: 2;
46
+ kind: "project-env";
47
+ orgId: string;
48
+ projectId: string;
49
+ key: string;
50
+ stagingId: string;
51
+ value: string;
52
+ }
53
+
54
+ export interface ProjectEnvStagingWriteOptions {
55
+ /** Tests/custom stores may inject the write-only implementation. */
56
+ store?: CredentialStore | null;
57
+ /** Tests may pin the process-local authentication key. */
58
+ hmacKey?: Buffer;
59
+ prefix?: string;
60
+ }
61
+
62
+ /** Exact JSON shared with the cloud-side reservation helper. */
63
+ export function projectEnvStagingEnvelope(
64
+ frame: ProjectEnvStagingPutFrame,
65
+ ): string {
66
+ if (!isProjectEnvStagingPutFrame(frame)) {
67
+ throw new ProjectEnvStagingInvalidError(
68
+ "invalid immutable environment staging request",
69
+ );
70
+ }
71
+ const envelope: StoredProjectEnvStagingEnvelope = {
72
+ v: 2,
73
+ kind: "project-env",
74
+ orgId: frame.orgId,
75
+ projectId: frame.projectId,
76
+ key: frame.key,
77
+ stagingId: frame.stagingId,
78
+ value: frame.value,
79
+ };
80
+ const encoded = JSON.stringify(envelope);
81
+ if (
82
+ Buffer.byteLength(encoded, "utf8") >
83
+ MAX_PROJECT_ENV_STAGED_ENVELOPE_BYTES
84
+ ) {
85
+ throw new ProjectEnvStagingInvalidError(
86
+ "environment value exceeds the immutable staging limit",
87
+ );
88
+ }
89
+ return encoded;
90
+ }
91
+
92
+ export function projectEnvStagingParameterName(
93
+ frame: Pick<ProjectEnvStagingPutFrame, "projectId" | "key" | "stagingId">,
94
+ prefix = credentialStorePrefix(),
95
+ ): string {
96
+ const digest = createHash("sha256").update(frame.key, "utf8").digest("hex");
97
+ return parameterName(
98
+ prefix,
99
+ "projects",
100
+ frame.projectId,
101
+ "env-values",
102
+ digest,
103
+ frame.stagingId,
104
+ );
105
+ }
106
+
107
+ export function projectEnvStagingTags(
108
+ frame: Pick<ProjectEnvStagingPutFrame, "orgId" | "projectId">,
109
+ encodedEnvelope: string,
110
+ hmacKey: Buffer = masterKey(),
111
+ ): Record<string, string> {
112
+ const mac = createHmac("sha256", hmacKey)
113
+ .update(STAGING_HMAC_DOMAIN)
114
+ .update(encodedEnvelope, "utf8")
115
+ .digest("hex");
116
+ return {
117
+ [PROJECT_ENV_STAGING_TAGS.org]: frame.orgId,
118
+ [PROJECT_ENV_STAGING_TAGS.project]: frame.projectId,
119
+ [PROJECT_ENV_STAGING_TAGS.mac]: mac,
120
+ };
121
+ }
122
+
123
+ export async function writeProjectEnvStaging(
124
+ frame: ProjectEnvStagingPutFrame,
125
+ options: ProjectEnvStagingWriteOptions = {},
126
+ ): Promise<ImmutableParameterPutResult> {
127
+ const encoded = projectEnvStagingEnvelope(frame);
128
+ const store =
129
+ Object.prototype.hasOwnProperty.call(options, "store")
130
+ ? options.store
131
+ : configuredCredentialStore();
132
+ if (!store) {
133
+ throw new Error("the sign-in box has no credential store for environment staging");
134
+ }
135
+ if (!store.putImmutable) {
136
+ throw new Error("the credential store does not support immutable environment staging");
137
+ }
138
+ const name = projectEnvStagingParameterName(
139
+ frame,
140
+ options.prefix ?? credentialStorePrefix(),
141
+ );
142
+ const tags = projectEnvStagingTags(
143
+ frame,
144
+ encoded,
145
+ options.hmacKey ?? masterKey(),
146
+ );
147
+ return store.putImmutable(name, encoded, tags);
148
+ }
@@ -0,0 +1,176 @@
1
+ /**
2
+ * ADR-153 §4 at runtime: what each stored key of a task's projects gives the
3
+ * task (its real value, a placeholder proxied to its domains, or nothing),
4
+ * and why a key is withheld. Pure: the caller reads the task's own,
5
+ * immutable schema observations and the runner's binding records; this
6
+ * decides, with the derivation the settings page shows (`projectEnvKeyPolicyAt`
7
+ * under each binding's own policy epoch).
8
+ *
9
+ * One authority unit per key: the first project, in task order, with a
10
+ * stored row for a key owns it, and only that project's observation, binding
11
+ * and placeholder apply. A later project's declaration never meets an
12
+ * earlier project's value.
13
+ */
14
+
15
+ import { projectEnvKeyPolicyAt, type ProjectEnvSchemaEvidence } from "../src/project-env-policy";
16
+ import { projectEnvWithheldReason } from "../src/project-env-wording";
17
+ import {
18
+ isProjectEnvHead,
19
+ isProjectEnvSchemaObservation,
20
+ sameProjectEnvHead,
21
+ type ProjectEnvHead,
22
+ type ProjectEnvSchemaDeclaration,
23
+ } from "../src/protocol";
24
+
25
+ /** Whether two heads are the same, field by field (the contract's rule). */
26
+ export const sameTaskEnvHead: (left: ProjectEnvHead, right: ProjectEnvHead) => boolean = sameProjectEnvHead;
27
+
28
+ /** Which generation of a key's value a decision is for: the record's head,
29
+ * as the contract defines it (ADR-153 §4, "Where bindings live"). */
30
+ export type TaskEnvHead = ProjectEnvHead;
31
+
32
+ /** A live head: one with a value. */
33
+ export type TaskEnvLiveHead = Extract<ProjectEnvHead, { tombstone: false }>;
34
+
35
+ /** A stored key and its binding record, as the runner holds them. */
36
+ export interface TaskEnvRecord {
37
+ key: string;
38
+ /** The record's head. A tombstone has no value, so it owns nothing; a head
39
+ * that is not one withholds the key, since its value cannot be told. */
40
+ head: unknown;
41
+ /** The binding, or null for an unbound key. Checked by the policy: one the
42
+ * contract rejects withholds the key. */
43
+ binding: unknown;
44
+ /** The policy epoch the binding was written under. */
45
+ policyEpoch: unknown;
46
+ }
47
+
48
+ /** One of the task's projects, lead first. */
49
+ export interface TaskEnvProject {
50
+ projectId: string;
51
+ /** The task's own observation of this project's schema. One the contract
52
+ * rejects, or another project's, counts as a schema it could not read. */
53
+ observation: unknown;
54
+ /** Every live value of the project on this runner, with its record:
55
+ * value, head, binding and epoch read from one snapshot of the store, so
56
+ * a decision is never paired with another generation's value. */
57
+ records: readonly TaskEnvRecord[];
58
+ }
59
+
60
+ export type TaskEnvPlaceholderShape = NonNullable<ProjectEnvSchemaDeclaration["placeholder"]>;
61
+
62
+ /** What a key gives the task. A delivered or proxied key names the head its
63
+ * value must have: the value is used only at that head. */
64
+ export type TaskEnvKeyPolicy =
65
+ | { projectId: string; key: string; kind: "deliver"; head: TaskEnvLiveHead }
66
+ | {
67
+ projectId: string;
68
+ key: string;
69
+ kind: "proxy";
70
+ head: TaskEnvLiveHead;
71
+ domains: string[];
72
+ placeholder: TaskEnvPlaceholderShape | null;
73
+ }
74
+ | { projectId: string; key: string; kind: "withhold"; reason: string };
75
+
76
+ const headOf = (value: unknown): ProjectEnvHead | null => (isProjectEnvHead(value) ? value : null);
77
+
78
+ /** What each stored key gives the task, in task order. */
79
+ export function taskEnvKeyPolicies(projects: readonly TaskEnvProject[]): TaskEnvKeyPolicy[] {
80
+ const claimed = new Set<string>();
81
+ const policies: TaskEnvKeyPolicy[] = [];
82
+ for (const project of projects) {
83
+ const observation =
84
+ isProjectEnvSchemaObservation(project.observation) && project.observation.projectId === project.projectId
85
+ ? project.observation
86
+ : null;
87
+ for (const record of project.records) {
88
+ const head = headOf(record.head);
89
+ // A deleted value's record keeps its binding, but owns no key.
90
+ if (claimed.has(record.key) || head?.tombstone === true) continue;
91
+ claimed.add(record.key);
92
+ const ref = { projectId: project.projectId, key: record.key };
93
+ if (head === null) {
94
+ policies.push({ ...ref, kind: "withhold", reason: "binding_invalid" });
95
+ continue;
96
+ }
97
+ const declaration =
98
+ observation?.status === "resolved"
99
+ ? (observation.declarations.find((candidate) => candidate.key === record.key) ?? null)
100
+ : null;
101
+ const evidence: ProjectEnvSchemaEvidence =
102
+ observation?.status === "resolved" ? { status: "resolved", declaration } : { status: "unavailable" };
103
+ const { decision } = projectEnvKeyPolicyAt(record.policyEpoch, evidence, record.binding);
104
+ if (decision.kind === "deliver") {
105
+ policies.push({ ...ref, kind: "deliver", head });
106
+ } else if (decision.kind === "proxy") {
107
+ policies.push({ ...ref, kind: "proxy", head, domains: decision.domains, placeholder: declaration?.placeholder ?? null });
108
+ } else {
109
+ policies.push({ ...ref, kind: "withhold", reason: decision.reason });
110
+ }
111
+ }
112
+ }
113
+ return policies;
114
+ }
115
+
116
+ /** The task's one note about the keys it does not get, or null when it gets
117
+ * every one. Names and reasons only, never a value. */
118
+ export function taskEnvWithheldNote(policies: readonly TaskEnvKeyPolicy[]): string | null {
119
+ const withheld = policies.filter(
120
+ (policy): policy is Extract<TaskEnvKeyPolicy, { kind: "withhold" }> => policy.kind === "withhold",
121
+ );
122
+ if (withheld.length === 0) return null;
123
+ const lines = withheld.map((policy) => `${policy.key} (${projectEnvWithheldReason(policy.reason)})`);
124
+ return (
125
+ `Uai is withholding ${withheld.length === 1 ? "an env var" : `${withheld.length} env vars`} from this task: ` +
126
+ `${lines.join(", ")}. The repository's Settings → Environment says more, and is where a waiting key is approved.`
127
+ );
128
+ }
129
+
130
+ /**
131
+ * One consistent read of a task's projects from the runner's store: the
132
+ * records the decisions come from, and the values they name. The runner
133
+ * renders /env and the proxy reads a value only through one snapshot, so a
134
+ * decision is never paired with another generation's value or binding.
135
+ */
136
+ export interface TaskEnvSnapshot {
137
+ projects: readonly TaskEnvProject[];
138
+ /** The value of a key's live row in this snapshot, only if that row is at
139
+ * exactly this head; null otherwise. */
140
+ valueAt(projectId: string, key: string, head: TaskEnvHead): string | null;
141
+ }
142
+
143
+ /** What a task's /env and its proxy get from one snapshot. */
144
+ export interface TaskEnvView {
145
+ /** Delivered keys, each with its value at the decision's head. */
146
+ values: Readonly<Record<string, string>>;
147
+ /** Every key the task does not get as its value: proxied and withheld. */
148
+ notDelivered: readonly string[];
149
+ /** Proxied keys, each with the value the proxy injects. */
150
+ proxied: ReadonlyArray<Extract<TaskEnvKeyPolicy, { kind: "proxy" }> & { value: string }>;
151
+ /** The decisions themselves, for the task's note. */
152
+ policies: readonly TaskEnvKeyPolicy[];
153
+ }
154
+
155
+ /** The task's view of one snapshot. A delivered or proxied key whose value is
156
+ * not at its decision's head in that same snapshot is not delivered. */
157
+ export function taskEnvView(snapshot: TaskEnvSnapshot): TaskEnvView {
158
+ const policies = taskEnvKeyPolicies(snapshot.projects);
159
+ const values: Record<string, string> = Object.create(null) as Record<string, string>;
160
+ const notDelivered: string[] = [];
161
+ const proxied: Array<Extract<TaskEnvKeyPolicy, { kind: "proxy" }> & { value: string }> = [];
162
+ for (const policy of policies) {
163
+ if (policy.kind === "withhold") {
164
+ notDelivered.push(policy.key);
165
+ continue;
166
+ }
167
+ const value = snapshot.valueAt(policy.projectId, policy.key, policy.head);
168
+ if (policy.kind === "deliver" && typeof value === "string") {
169
+ values[policy.key] = value;
170
+ continue;
171
+ }
172
+ notDelivered.push(policy.key);
173
+ if (policy.kind === "proxy" && typeof value === "string") proxied.push({ ...policy, value });
174
+ }
175
+ return { values, notDelivered, proxied, policies };
176
+ }