@runuai/host 0.9.149 → 0.9.151

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 (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1876 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1847 -236
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +623 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1062 -140
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,277 @@
1
+ /**
2
+ * A one-off container on the Apple runtime (ADR-106), and nothing of it left
3
+ * behind (#p2jdax).
4
+ *
5
+ * `container run --rm` removes only a container that started. A run the
6
+ * runtime refuses leaves a created-never-started container, and those wedge
7
+ * the runtime's apiserver: `run` and `delete` hang while `list` and
8
+ * `inspect` still answer, until the runtime's services are recycled, which
9
+ * restarts every Apple task (apple-runtime-recycle.ts). A client killed at
10
+ * its bound (no answer in time, output past its bound) can leave one too, or
11
+ * a container that goes on running what it was abandoned for. So every
12
+ * one-off container is named in the closed grammar the recycle's sweep
13
+ * deletes, with a random part no other run's name has, labelled as Uai's,
14
+ * and labelled with a token only its own run carries; and its run is
15
+ * settled after it ends:
16
+ *
17
+ * - absent (the runtime says it is not found): done;
18
+ * - its own (Uai's label and its run's token, on exactly that name):
19
+ * deleted, then proven gone; deleted again if it is not; still there, it
20
+ * is what wedges the runtime, so the runtime's remedy is asked for, and it
21
+ * is proven gone after. Only the runtime's own activation restarts its
22
+ * services (it recovers every task after); all other work, and every
23
+ * watch, asks the runtime's owner for that activation instead
24
+ * (askRuntimeOwnerOnce);
25
+ * - of that name but not its own: never touched (a name alone is no proof);
26
+ * - unobservable (the runtime does not answer): nothing is deleted or
27
+ * recycled on a guess.
28
+ *
29
+ * Whatever is left unproven is watched a while longer, and so is the name of
30
+ * a run whose client never reported its end (its container can still be
31
+ * created after): each look that finds it its own settles it again, and the
32
+ * watch ends only once it is proven gone or removed, or its time is up.
33
+ */
34
+
35
+ import { randomBytes } from "node:crypto";
36
+
37
+ import { MAINTENANCE_CONTAINER_LABEL, type AppleRecycleOutcome } from "./apple-runtime-recycle";
38
+
39
+ /** The label only one run's container carries: its token. */
40
+ export const MAINTENANCE_TOKEN_LABEL = "com.uai.maintenance.token";
41
+
42
+ /** One run of the Apple `container` CLI. */
43
+ export interface AppleCliResult {
44
+ code: number | null;
45
+ stdout: string;
46
+ stderr: string;
47
+ }
48
+
49
+ export type AppleCli = (args: string[], timeoutMs: number) => Promise<AppleCliResult>;
50
+
51
+ export interface AppleOneOffDeps {
52
+ cli: AppleCli;
53
+ /** The runtime's remedy for a container of a run's own that will not go
54
+ * (see the module comment). */
55
+ recycle: () => Promise<AppleRecycleOutcome>;
56
+ /** Tests only: how the watch waits. */
57
+ delay?: (ms: number) => Promise<void>;
58
+ }
59
+
60
+ /** One one-off container: its name, and the token only its run carries. */
61
+ export interface AppleOneOff {
62
+ readonly name: string;
63
+ readonly token: string;
64
+ }
65
+
66
+ /** What a one-off container's name may name, within the recycle sweep's
67
+ * grammar (MAINTENANCE_CONTAINER_NAME_RE). */
68
+ const PURPOSE = /^[a-z]+(?:-[a-z]+)*$/;
69
+ const INSPECT_TIMEOUT_MS = 15_000;
70
+ const DELETE_TIMEOUT_MS = 30_000;
71
+ const DELETE_ATTEMPTS = 2;
72
+ /** A watched name is looked for again after each of these waits. */
73
+ const WATCH_MS: readonly number[] = [5_000, 15_000, 60_000, 180_000, 600_000];
74
+
75
+ let sequence = 0;
76
+
77
+ /** A new one-off container: a name no other run has had, and its token. */
78
+ export function newAppleOneOff(purpose: string): AppleOneOff {
79
+ if (!PURPOSE.test(purpose)) throw new Error(`a malformed one-off container purpose: ${purpose}`);
80
+ sequence += 1;
81
+ return {
82
+ name: `uai-maint-${purpose}-${process.pid}-${sequence}-${randomBytes(8).toString("hex")}`,
83
+ token: randomBytes(16).toString("hex"),
84
+ };
85
+ }
86
+
87
+ /** The `container run` arguments that name a one-off container, mark it
88
+ * Uai's (what the recycle's sweep goes by) and give it its run's token
89
+ * (what alone authorizes deleting it here). */
90
+ export function appleOneOffRunArgs(oneOff: AppleOneOff): string[] {
91
+ return [
92
+ "--name",
93
+ oneOff.name,
94
+ "-l",
95
+ `${MAINTENANCE_CONTAINER_LABEL}=1`,
96
+ "-l",
97
+ `${MAINTENANCE_TOKEN_LABEL}=${oneOff.token}`,
98
+ ];
99
+ }
100
+
101
+ /** What one inspection of a one-off container's name finds. */
102
+ export type AppleOneOffPresence = "ours" | "absent" | "foreign" | "unknown";
103
+
104
+ /**
105
+ * One observation of `oneOff`'s name: absent only on the runtime's own "not
106
+ * found"; its own only with Uai's label and its run's token on exactly that
107
+ * container; of that name without them, foreign; anything else (no answer,
108
+ * an answer that does not parse), unknown.
109
+ */
110
+ export async function appleOneOffPresence(cli: AppleCli, oneOff: AppleOneOff): Promise<AppleOneOffPresence> {
111
+ const { name } = oneOff;
112
+ const inspected = await cli(["inspect", name], INSPECT_TIMEOUT_MS);
113
+ if (inspected.code === 0) {
114
+ try {
115
+ const parsed = JSON.parse(inspected.stdout) as Array<{
116
+ id?: unknown;
117
+ configuration?: { labels?: Record<string, unknown> };
118
+ }>;
119
+ if (!Array.isArray(parsed) || parsed.length !== 1 || parsed[0]?.id !== name) return "unknown";
120
+ const labels = parsed[0]?.configuration?.labels;
121
+ return labels?.[MAINTENANCE_CONTAINER_LABEL] === "1" && labels?.[MAINTENANCE_TOKEN_LABEL] === oneOff.token
122
+ ? "ours"
123
+ : "foreign";
124
+ } catch {
125
+ return "unknown";
126
+ }
127
+ }
128
+ if (
129
+ inspected.code !== null &&
130
+ inspected.stdout.trim() === "" &&
131
+ inspected.stderr.split("\n").some((line) => line.trim() === `Error: container not found: ${name}`)
132
+ ) {
133
+ return "absent";
134
+ }
135
+ return "unknown";
136
+ }
137
+
138
+ /** How a one-off container's run was settled. */
139
+ export type AppleOneOffSettlement =
140
+ /** Nothing of that name is there. */
141
+ | "absent"
142
+ /** Its own was there: deleted, and proven gone. */
143
+ | "removed"
144
+ /** Its own would not go: the runtime's remedy took it, and it is proven
145
+ * gone. */
146
+ | "recycled"
147
+ /** Of that name but not its own: left untouched. */
148
+ | "foreign"
149
+ /** Neither proven gone nor removed. */
150
+ | "unproven";
151
+
152
+ /**
153
+ * Settle `oneOff`'s container once (see the module comment). Never
154
+ * rejects.
155
+ */
156
+ export async function settleAppleOneOff(deps: AppleOneOffDeps, oneOff: AppleOneOff): Promise<AppleOneOffSettlement> {
157
+ const { name } = oneOff;
158
+ try {
159
+ let presence = await appleOneOffPresence(deps.cli, oneOff);
160
+ if (presence === "absent") return "absent";
161
+ // Only its own is ever deleted.
162
+ for (let attempt = 0; attempt < DELETE_ATTEMPTS && presence === "ours"; attempt += 1) {
163
+ await deps.cli(["delete", "--force", name], DELETE_TIMEOUT_MS);
164
+ presence = await appleOneOffPresence(deps.cli, oneOff);
165
+ }
166
+ if (presence === "absent") return "removed";
167
+ if (presence !== "ours") return leftAlone(name, presence);
168
+ // Its own, and it will not go: what wedges the runtime.
169
+ console.warn(`[host-agent] one-off container ${name} could not be deleted; asking for the Apple runtime's remedy`);
170
+ const outcome = await deps.recycle();
171
+ presence = await appleOneOffPresence(deps.cli, oneOff);
172
+ if (presence === "absent") return "recycled";
173
+ console.warn(
174
+ `[host-agent] one-off container ${name} is still there (${
175
+ outcome.recycled ? "the runtime's services were recycled" : outcome.reason
176
+ }); it is watched, and removed once it can be`,
177
+ );
178
+ return "unproven";
179
+ } catch (error) {
180
+ console.warn(
181
+ `[host-agent] one-off container ${name} could not be settled: ${error instanceof Error ? error.message : String(error)}`,
182
+ );
183
+ return "unproven";
184
+ }
185
+ }
186
+
187
+ function leftAlone(name: string, presence: "foreign" | "unknown"): AppleOneOffSettlement {
188
+ if (presence === "foreign") {
189
+ console.warn(`[host-agent] a container named ${name} is not this run's own; leaving it untouched`);
190
+ return "foreign";
191
+ }
192
+ console.warn(`[host-agent] could not observe one-off container ${name}; nothing is deleted or recycled on a guess`);
193
+ return "unproven";
194
+ }
195
+
196
+ /**
197
+ * The runtime's remedy for work outside its activation: ask the runtime's
198
+ * owner, at most once for one run, to recheck the runtime
199
+ * (container-runtime.ts requestContainerRuntimeRecheck). Its activation
200
+ * restarts the services of a runtime it finds wedged, then recovers every
201
+ * task; this never restarts them itself.
202
+ */
203
+ export function askRuntimeOwnerOnce(
204
+ recheck: (reason: string) => boolean,
205
+ reason: string,
206
+ ): () => Promise<Extract<AppleRecycleOutcome, { recycled: false }>> {
207
+ let asked: boolean | null = null;
208
+ return async () => {
209
+ asked ??= recheck(reason);
210
+ return {
211
+ recycled: false,
212
+ reason: asked ? "its owner was asked to recheck the runtime" : "no owner of the runtime was there to ask",
213
+ };
214
+ };
215
+ }
216
+
217
+ /**
218
+ * Whether a run's settlement leaves its name to watch: anything unproven,
219
+ * and, when its client never reported its end (`lateCreation`: its
220
+ * container can still be created after), anything short of removed.
221
+ */
222
+ export function appleOneOffNeedsWatch(settled: AppleOneOffSettlement, lateCreation: boolean): boolean {
223
+ if (settled === "removed" || settled === "recycled") return false;
224
+ return settled === "unproven" || lateCreation;
225
+ }
226
+
227
+ /**
228
+ * Watch `oneOff`'s name: look for it again after each wait, and settle it
229
+ * whenever it is found its own; one that cannot be observed, or is not its
230
+ * own (when `lateCreation`, the name may be its own's later), is looked for
231
+ * again. Resolves once it is removed or proven gone (absent ends the watch
232
+ * only where nothing of the run can be created after), or the watch's time
233
+ * is up; never rejects.
234
+ */
235
+ export async function watchAppleOneOff(
236
+ deps: AppleOneOffDeps,
237
+ oneOff: AppleOneOff,
238
+ options: { lateCreation: boolean },
239
+ ): Promise<AppleOneOffSettlement> {
240
+ const delay = deps.delay ?? defaultDelay;
241
+ let last: AppleOneOffSettlement = "unproven";
242
+ for (const wait of WATCH_MS) {
243
+ await delay(wait);
244
+ let presence: AppleOneOffPresence;
245
+ try {
246
+ presence = await appleOneOffPresence(deps.cli, oneOff);
247
+ } catch {
248
+ presence = "unknown";
249
+ }
250
+ if (presence === "absent" || presence === "foreign") {
251
+ if (!options.lateCreation) return presence;
252
+ last = presence;
253
+ continue;
254
+ }
255
+ if (presence === "unknown") {
256
+ last = "unproven";
257
+ continue;
258
+ }
259
+ const settled = await settleAppleOneOff(deps, oneOff);
260
+ if (settled === "removed" || settled === "recycled") return settled;
261
+ if (settled !== "unproven" && !options.lateCreation) return settled;
262
+ last = settled;
263
+ }
264
+ return last;
265
+ }
266
+
267
+ function defaultDelay(ms: number): Promise<void> {
268
+ return new Promise((resolve) => {
269
+ const timer = setTimeout(resolve, ms);
270
+ timer.unref?.();
271
+ });
272
+ }
273
+
274
+ /** Tests only: the name sequence, from the start. */
275
+ export function resetAppleOneOffNamesForTests(): void {
276
+ sequence = 0;
277
+ }
@@ -103,9 +103,10 @@ export function parseAppleContainerServiceLabels(listing: string): string[] {
103
103
  * requires the label AND the closed generated grammar together. */
104
104
  export const MAINTENANCE_CONTAINER_LABEL = "com.uai.maintenance";
105
105
 
106
- /** The closed grammar `runMaintenanceContainer` generates:
107
- * uai-maint-<purpose>-<pid>-<seq>. */
108
- export const MAINTENANCE_CONTAINER_NAME_RE = /^uai-maint-[a-z-]+-\d+-\d+$/;
106
+ /** The closed grammar one-off containers are named in (apple-one-off.ts):
107
+ * uai-maint-<purpose>-<pid>-<seq>-<16 random hex>; the random part is
108
+ * absent from the names of a runner that predates it. */
109
+ export const MAINTENANCE_CONTAINER_NAME_RE = /^uai-maint-[a-z-]+-\d+-\d+(?:-[0-9a-f]{16})?$/;
109
110
 
110
111
  /**
111
112
  * The sweep deletes ONLY containers Uai provably owns: the exact generated
@@ -6,10 +6,11 @@
6
6
  * never get committed. The cloud relays bytes in/out; it never persists them.
7
7
  */
8
8
 
9
- import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
9
+ import { mkdir } from "node:fs/promises";
10
10
  import { basename, resolve } from "node:path";
11
11
 
12
12
  import { taskWorkspaceDir } from "./env";
13
+ import { makeContainedDirectories, readBoundedFile, writeContainedFile } from "./task-environment/workspace-files";
13
14
 
14
15
  /** Host directory holding a task's attachments. */
15
16
  export function attachmentsDir(taskId: string): string {
@@ -21,26 +22,27 @@ export function containerAttachmentPath(filename: string): string {
21
22
  return `/workspace/.uai/attachments/${basename(filename)}`;
22
23
  }
23
24
 
24
- /** Resolve + harden a filename to a path inside the task's attachments dir. */
25
- function safePath(taskId: string, filename: string): string {
26
- const dir = attachmentsDir(taskId);
27
- const full = resolve(dir, basename(filename));
28
- if (full !== dir && !full.startsWith(dir + "/")) {
25
+ /** An attachment's file name, as it lands in the attachments directory. */
26
+ function attachmentName(filename: string): string {
27
+ const name = basename(filename);
28
+ if (name === "" || name === "." || name === ".." || name.includes("\0")) {
29
29
  throw new Error("invalid attachment filename");
30
30
  }
31
- return full;
31
+ return name;
32
32
  }
33
33
 
34
- export function writeAttachment(
35
- taskId: string,
36
- filename: string,
37
- bytes: Buffer,
38
- ): void {
39
- mkdirSync(attachmentsDir(taskId), { recursive: true });
40
- writeFileSync(safePath(taskId, filename), bytes);
34
+ /** Written inside the task's workspace, never through a link a task put
35
+ * there (#cb3j0z). */
36
+ export async function writeAttachment(taskId: string, filename: string, bytes: Buffer): Promise<void> {
37
+ const name = attachmentName(filename);
38
+ // The workspace root is the runner's own path; below it, the task's.
39
+ await mkdir(taskWorkspaceDir(taskId), { recursive: true });
40
+ await makeContainedDirectories(taskWorkspaceDir(taskId), [".uai", "attachments"]);
41
+ await writeContainedFile(taskWorkspaceDir(taskId), [".uai", "attachments", name], bytes);
41
42
  }
42
43
 
43
- export function readAttachment(taskId: string, filename: string): Buffer | null {
44
- const full = safePath(taskId, filename);
45
- return existsSync(full) ? readFileSync(full) : null;
44
+ /** Read the same way: a regular file inside the workspace, or null. */
45
+ export async function readAttachment(taskId: string, filename: string): Promise<Buffer | null> {
46
+ const bytes = await readBoundedFile(taskWorkspaceDir(taskId), [".uai", "attachments", attachmentName(filename)]);
47
+ return bytes === null ? null : Buffer.from(bytes);
46
48
  }
@@ -0,0 +1,319 @@
1
+ /**
2
+ * ADR-154 §3 on the sign-in box: write one capture as one immutable revision,
3
+ * exactly where the cloud's flow says, and never read it back.
4
+ *
5
+ * The cloud begins every capture (lib/credential-metadata.ts) and hands the
6
+ * box the revision's parameter names. The box checks them, builds the two
7
+ * parts from what the sign-in produced, and creates them, broker-only part
8
+ * first:
9
+ *
10
+ * - lease-readable: what a task may receive: a static key as entered, or an
11
+ * OAuth login's brokered copy (access token in, refresh token replaced);
12
+ * - broker-only (OAuth logins only): the full material, which only the
13
+ * broker reads and only the broker ever overwrites.
14
+ *
15
+ * Both are created, never overwritten (`putImmutable`), with tags naming the
16
+ * workspace and person the role policy checks. The answer carries names and
17
+ * versions, never material.
18
+ */
19
+ import {
20
+ ENGINE_CREDENTIAL_FILES,
21
+ ENGINE_ENV_CREDENTIAL_KEYS,
22
+ MAX_CREDENTIAL_VALUE_BYTES,
23
+ parseCaptureFlowWire,
24
+ serializeLeasedValue,
25
+ type CaptureFlowWire,
26
+ type CaptureResultWire,
27
+ type CredentialLocation,
28
+ } from "../src/cloud-credentials";
29
+ import { ImmutableParameterAbortedError, storeFailureKind, type CredentialStore } from "./credential-store";
30
+ import { laneFor } from "./token-lanes/registry";
31
+ import {
32
+ emptyRefreshMeta,
33
+ refreshMetaFrom,
34
+ type CredentialFiles,
35
+ type RefreshMeta,
36
+ } from "./token-lanes/types";
37
+
38
+ /** What only the broker reads: the full material of an OAuth login. */
39
+ export interface BrokerEngineValue {
40
+ v: 1;
41
+ part: "broker";
42
+ location: CredentialLocation;
43
+ class: "engine";
44
+ lane: string;
45
+ files: CredentialFiles;
46
+ refresh: RefreshMeta;
47
+ }
48
+
49
+ export type EngineCaptureMaterial =
50
+ | { authKind: "env"; env: Record<string, string> }
51
+ | { authKind: "config-dir"; files: CredentialFiles };
52
+
53
+ export interface BuiltCapture {
54
+ lease: string;
55
+ broker: string | null;
56
+ expiresAt: number | null;
57
+ /** Metadata the lease part also carries (never material): an SSH key's
58
+ * public half, so a retry can tell which key the revision holds. The
59
+ * identity tags always win over these. */
60
+ leaseTags?: Record<string, string>;
61
+ }
62
+
63
+ export type ParsedCaptureFlow = CaptureFlowWire & { location: CredentialLocation };
64
+
65
+ export function parseCaptureFlow(value: unknown): ParsedCaptureFlow | null {
66
+ return parseCaptureFlowWire(value);
67
+ }
68
+
69
+ function onlyKeys(record: Record<string, string>, allowed: readonly string[]): boolean {
70
+ const keys = Object.keys(record);
71
+ return keys.length > 0 && keys.every((key) => allowed.includes(key));
72
+ }
73
+
74
+ /**
75
+ * The two parts of an AI account's capture, or an error the sign-in shows.
76
+ * The flow's broker part must exist exactly when the material rotates: a
77
+ * rotating login without one would leave its refresh token nowhere the
78
+ * broker can reach (and a task holding it would refresh it itself), and a
79
+ * static key needs none.
80
+ */
81
+ export function buildEngineCapture(
82
+ flow: ParsedCaptureFlow,
83
+ material: EngineCaptureMaterial,
84
+ ): BuiltCapture | { error: string } {
85
+ const { location } = flow;
86
+ if (location.class !== "engine") {
87
+ return { error: "This capture is not an AI account." };
88
+ }
89
+ const kind = location.engineKind!;
90
+ if (material.authKind === "env") {
91
+ if (!onlyKeys(material.env, ENGINE_ENV_CREDENTIAL_KEYS[kind] ?? [])) {
92
+ return { error: "This key is not one the engine reads." };
93
+ }
94
+ if (flow.brokerParameter !== null) {
95
+ return { error: "A pasted key does not rotate; it has no broker part." };
96
+ }
97
+ return {
98
+ lease: serializeLeasedValue({
99
+ v: 1,
100
+ part: "lease",
101
+ location,
102
+ class: "engine",
103
+ authKind: "env",
104
+ env: { ...material.env },
105
+ }),
106
+ broker: null,
107
+ expiresAt: null,
108
+ };
109
+ }
110
+ if (!onlyKeys(material.files, ENGINE_CREDENTIAL_FILES[kind] ?? [])) {
111
+ return { error: "The sign-in produced files the engine does not use." };
112
+ }
113
+ const lane = laneFor(
114
+ { kind, authKind: "config-dir", id: location.credentialId!, scope: location.scope },
115
+ material.files,
116
+ );
117
+ if (lane === null) {
118
+ if (flow.brokerParameter !== null) {
119
+ return { error: "This credential does not rotate; it has no broker part." };
120
+ }
121
+ return {
122
+ lease: serializeLeasedValue({
123
+ v: 1,
124
+ part: "lease",
125
+ location,
126
+ class: "engine",
127
+ authKind: "config-dir",
128
+ files: { ...material.files },
129
+ expiresAt: null,
130
+ }),
131
+ broker: null,
132
+ expiresAt: null,
133
+ };
134
+ }
135
+ if (flow.brokerParameter === null) {
136
+ return { error: "This sign-in rotates, and the cloud gave it nowhere to keep its refresh token." };
137
+ }
138
+ const expiresAt = lane.expiresAt(material.files);
139
+ const broker: BrokerEngineValue = {
140
+ v: 1,
141
+ part: "broker",
142
+ location,
143
+ class: "engine",
144
+ lane: lane.id,
145
+ files: { ...material.files },
146
+ refresh: { ...emptyRefreshMeta(lane.id), expiresAt },
147
+ };
148
+ return {
149
+ lease: serializeLeasedValue({
150
+ v: 1,
151
+ part: "lease",
152
+ location,
153
+ class: "engine",
154
+ authKind: "config-dir",
155
+ files: lane.brokeredCopy(material.files),
156
+ expiresAt,
157
+ }),
158
+ broker: JSON.stringify(broker),
159
+ expiresAt,
160
+ };
161
+ }
162
+
163
+ /** The tags every part carries: the identity a retry is checked against,
164
+ * and the workspace and person the role policy compares. */
165
+ export function captureTags(
166
+ flow: ParsedCaptureFlow,
167
+ part: "lease" | "broker",
168
+ ): Record<string, string> {
169
+ return {
170
+ ...(flow.orgId !== null ? { org: flow.orgId } : {}),
171
+ ...(flow.userId !== null ? { user: flow.userId } : {}),
172
+ uai_credential: flow.credentialId,
173
+ uai_revision: flow.revision,
174
+ uai_part: part,
175
+ };
176
+ }
177
+
178
+ /**
179
+ * Create both parts, broker-only first: a crash after it leaves a revision
180
+ * the cloud never promotes, which the sweep removes; a lease-readable part
181
+ * never exists without the refresh token it was brokered from. A retry after
182
+ * a lost answer finds its own create by its tags and carries on.
183
+ */
184
+ export async function writeCapture(
185
+ store: CredentialStore,
186
+ flow: ParsedCaptureFlow,
187
+ built: BuiltCapture,
188
+ options: { signal?: AbortSignal } = {},
189
+ ): Promise<CaptureResultWire> {
190
+ if (!store.putImmutable) {
191
+ throw new Error("This credential store cannot create immutable parameters.");
192
+ }
193
+ // A caller's deadline covers the writes too: past it nothing more is sent,
194
+ // and a part already sent is the cloud's to sweep with the revision.
195
+ const { signal } = options;
196
+ if (built.broker !== null) {
197
+ if (flow.brokerParameter === null) throw new Error("no broker part to write");
198
+ if (signal?.aborted) throw new ImmutableParameterAbortedError();
199
+ await store.putImmutable(flow.brokerParameter, built.broker, captureTags(flow, "broker"), { signal });
200
+ }
201
+ if (signal?.aborted) throw new ImmutableParameterAbortedError();
202
+ await store.putImmutable(
203
+ flow.leaseParameter,
204
+ built.lease,
205
+ { ...built.leaseTags, ...captureTags(flow, "lease") },
206
+ { signal },
207
+ );
208
+ return {
209
+ credentialId: flow.credentialId,
210
+ revision: flow.revision,
211
+ // Created, never overwritten by the box: version 1. Only the broker
212
+ // writes later versions.
213
+ leaseVersion: 1,
214
+ expiresAt: built.expiresAt,
215
+ };
216
+ }
217
+
218
+ export interface CaptureOutcome {
219
+ ok: boolean;
220
+ message: string;
221
+ /** Present exactly when `ok`. */
222
+ capture?: CaptureResultWire;
223
+ }
224
+
225
+ /**
226
+ * The sign-in box's sink for an AI account's capture: check the flow, build
227
+ * both parts, create them. Every failure is a message the sign-in shows, and
228
+ * none of them carries material.
229
+ */
230
+ export async function captureEngineAccount(
231
+ store: CredentialStore | null,
232
+ wire: CaptureFlowWire,
233
+ engine: string,
234
+ material: EngineCaptureMaterial,
235
+ engineLabel: string,
236
+ ): Promise<CaptureOutcome> {
237
+ const flow = parseCaptureFlow(wire);
238
+ if (!flow || flow.location.class !== "engine" || flow.location.engineKind !== engine) {
239
+ return { ok: false, message: "The sign-in's destination is invalid; nothing was kept." };
240
+ }
241
+ if (!store) {
242
+ return { ok: false, message: "This runner has no credential store to hold the account." };
243
+ }
244
+ const built = buildEngineCapture(flow, material);
245
+ if ("error" in built) return { ok: false, message: built.error };
246
+ try {
247
+ const capture = await writeCapture(store, flow, built);
248
+ return { ok: true, message: `${engineLabel} signed in.`, capture };
249
+ } catch (err) {
250
+ // A category, never the error's words: a store failure can echo input.
251
+ console.warn(`[capture-flow] ${engine} ${flow.credentialId}/${flow.revision}: write failed (${storeFailureKind(err)})`);
252
+ return {
253
+ ok: false,
254
+ message: "The credential store refused the write; nothing was kept on this runner.",
255
+ };
256
+ }
257
+ }
258
+
259
+
260
+ /**
261
+ * A stored broker-only part, exactly, for the revision at `expected`; null
262
+ * for anything else. Only the broker reads it (token-broker-revision.ts).
263
+ */
264
+ export function parseBrokerEngineValue(
265
+ raw: string,
266
+ expected: CredentialLocation,
267
+ ): BrokerEngineValue | null {
268
+ if (typeof raw !== "string" || new TextEncoder().encode(raw).length > MAX_CREDENTIAL_VALUE_BYTES) {
269
+ return null;
270
+ }
271
+ let parsed: unknown;
272
+ try {
273
+ parsed = JSON.parse(raw);
274
+ } catch {
275
+ return null;
276
+ }
277
+ if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) return null;
278
+ const o = parsed as Record<string, unknown>;
279
+ const location = o.location as Record<string, unknown> | null;
280
+ if (
281
+ o.v !== 1 ||
282
+ o.part !== "broker" ||
283
+ o.class !== "engine" ||
284
+ expected.class !== "engine" ||
285
+ typeof o.lane !== "string" ||
286
+ !location ||
287
+ typeof location !== "object" ||
288
+ location.class !== expected.class ||
289
+ location.scope !== expected.scope ||
290
+ location.ownerId !== expected.ownerId ||
291
+ location.engineKind !== expected.engineKind ||
292
+ location.credentialId !== expected.credentialId ||
293
+ location.revision !== expected.revision
294
+ ) {
295
+ return null;
296
+ }
297
+ const allowed = ENGINE_CREDENTIAL_FILES[expected.engineKind ?? ""] ?? [];
298
+ const files = o.files as Record<string, unknown> | null;
299
+ if (
300
+ !files ||
301
+ typeof files !== "object" ||
302
+ Array.isArray(files) ||
303
+ Object.keys(files).length === 0 ||
304
+ !Object.entries(files).every(([name, text]) => allowed.includes(name) && typeof text === "string")
305
+ ) {
306
+ return null;
307
+ }
308
+ const refresh = refreshMetaFrom(o.refresh);
309
+ if (!refresh || refresh.lane !== o.lane) return null;
310
+ return {
311
+ v: 1,
312
+ part: "broker",
313
+ location: expected,
314
+ class: "engine",
315
+ lane: o.lane,
316
+ files: files as CredentialFiles,
317
+ refresh,
318
+ };
319
+ }