@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
@@ -8,14 +8,23 @@
8
8
  */
9
9
 
10
10
  import {
11
- DeleteParameterCommand,
12
11
  GetParameterCommand,
12
+ GetParameterHistoryCommand,
13
13
  GetParametersByPathCommand,
14
+ ListTagsForResourceCommand,
14
15
  PutParameterCommand,
15
16
  SSMClient,
16
17
  } from "@aws-sdk/client-ssm";
17
18
 
19
+ import {
20
+ BROKER_PARAMETER_ROOT,
21
+ idPathSegment,
22
+ legacyAdoptedRoot,
23
+ legacyEngineParameterRoot,
24
+ } from "../src/cloud-credentials";
18
25
  import type { BrokerCredential, BrokerStorage } from "./token-broker";
26
+ import type { AdoptionStore } from "./token-broker-adopt";
27
+ import type { RevisionStore, StoredParameter } from "./token-broker-revision";
19
28
  import { refreshMetaFrom, type CredentialFiles, type RefreshMeta } from "./token-lanes/types";
20
29
 
21
30
  export interface SsmBrokerStorageOptions {
@@ -30,7 +39,7 @@ interface StoredValue {
30
39
  version: number | undefined;
31
40
  }
32
41
 
33
- /** `/uai/orgs/<o>/engines/<kind>/<id>` and `/uai/hosts/<h>/engines/<kind>/<id>`. */
42
+ /** `<prefix>/orgs/<o>/engines/<kind>/<id>` and the corresponding host path. */
34
43
  export function engineParameterHeader(
35
44
  name: string,
36
45
  prefix: string,
@@ -53,6 +62,10 @@ export class SsmBrokerStorage implements BrokerStorage {
53
62
  }
54
63
 
55
64
  async list(): Promise<BrokerCredential[]> {
65
+ // ADR-154, owner answer 3: an adopted account has one refresher, the
66
+ // cloud's scheduler, never this scan. Markers that cannot be listed stop
67
+ // the run rather than risk a second refresher.
68
+ const adopted = await adoptedLegacyNames(this.client, this.opts.prefix);
56
69
  const out: BrokerCredential[] = [];
57
70
  for (const path of [`${this.opts.prefix}/orgs`, `${this.opts.prefix}/hosts`]) {
58
71
  let nextToken: string | undefined;
@@ -67,6 +80,7 @@ export class SsmBrokerStorage implements BrokerStorage {
67
80
  );
68
81
  for (const p of page.Parameters ?? []) {
69
82
  if (!p.Name || typeof p.Value !== "string") continue;
83
+ if (adopted.has(p.Name)) continue;
70
84
  const header = engineParameterHeader(p.Name, this.opts.prefix);
71
85
  if (!header) continue;
72
86
  let raw: unknown;
@@ -123,12 +137,132 @@ export class SsmBrokerStorage implements BrokerStorage {
123
137
  // ---------------------------------------------------------------------------
124
138
  // Mutual exclusion between runs. Reserved concurrency 1 is the first line
125
139
  // (deploy-aws.sh sets it when the account's limit allows); this lock is the
126
- // one that always holds: a `PutParameter` WITHOUT `Overwrite` is atomic, so
127
- // two runs can never both create `<prefix>/locks/token-broker`. A holder
128
- // that died keeps the lock at most LOCK_STALE_MS; a later run takes it over.
140
+ // one that always holds. A lock is one parameter that is never deleted: AWS
141
+ // asks for 30 seconds between deleting a parameter and creating one of the
142
+ // same name (DeleteParameter), and adoptions take the scan lock one after
143
+ // another. Free, it holds RELEASED_LOCK; held, its holder's
144
+ // `<time>:<nonce>`. A holder that died keeps it at most LOCK_STALE_MS; a
145
+ // later run takes it over.
129
146
  // ---------------------------------------------------------------------------
130
147
 
131
148
  export const LOCK_STALE_MS = 5 * 60_000;
149
+ /** A holder frees the lock only this far inside the stale window: past it,
150
+ * a run that found the lock stale may hold it now. The broker's 120-second
151
+ * Lambda timeout (deploy-aws.sh) keeps every holder well inside. */
152
+ const LOCK_RELEASE_MARGIN_MS = 60_000;
153
+ /** A free lock: time zero, which every reader takes for stale (the build
154
+ * before this one too, which deleted the parameter instead). */
155
+ export const RELEASED_LOCK = "0:released";
156
+
157
+ interface LockToken {
158
+ heldAt: number;
159
+ /** The SSM version this claimant had to create. Null for the previous
160
+ * build's `<time>:<nonce>` tokens and recovery fences, which stay
161
+ * conservatively held until released or stale. */
162
+ expectedVersion: number | null;
163
+ }
164
+
165
+ function lockToken(value: string | undefined): LockToken | null {
166
+ if (typeof value !== "string") return null;
167
+ const parts = value.split(":");
168
+ const [heldAtText, expectedText] = parts;
169
+ const heldAt = Number(heldAtText);
170
+ if (!Number.isSafeInteger(heldAt) || heldAt <= 0) return null;
171
+ const expectedVersion = Number(expectedText);
172
+ return {
173
+ heldAt,
174
+ expectedVersion:
175
+ parts.length === 3 && Number.isSafeInteger(expectedVersion) && expectedVersion > 0
176
+ ? expectedVersion
177
+ : null,
178
+ };
179
+ }
180
+
181
+ /** A contender can land after the elected holder has already released. Its
182
+ * token is not a lock (its returned SSM version missed the version it named),
183
+ * but it may also have overwritten a newer holder that is still working.
184
+ * Replay every intervening version: an elected token opens a hold, RELEASED
185
+ * closes it, and a losing token changes neither. Only a complete chain ending
186
+ * with no open hold proves that the current false token is free. */
187
+ async function holderBeforeFalseToken(
188
+ client: SSMClient,
189
+ name: string,
190
+ expectedVersion: number,
191
+ currentVersion: number,
192
+ ): Promise<LockToken | null | undefined> {
193
+ let nextToken: string | undefined;
194
+ const history = new Map<number, string>();
195
+ try {
196
+ do {
197
+ const page = await client.send(
198
+ new GetParameterHistoryCommand({
199
+ Name: name,
200
+ WithDecryption: false,
201
+ MaxResults: 50,
202
+ NextToken: nextToken,
203
+ }),
204
+ );
205
+ for (const item of page.Parameters ?? []) {
206
+ if (
207
+ item.Version !== undefined &&
208
+ item.Version >= expectedVersion &&
209
+ item.Version < currentVersion &&
210
+ item.Value !== undefined
211
+ ) {
212
+ history.set(item.Version, item.Value);
213
+ }
214
+ }
215
+ nextToken = page.NextToken;
216
+ } while (nextToken);
217
+ } catch {
218
+ // Missing IAM or a transient history read fails closed. The false token
219
+ // becomes stale normally; it must never be mistaken for a free live lock.
220
+ }
221
+ let holder: LockToken | null = null;
222
+ for (let version = expectedVersion; version < currentVersion; version += 1) {
223
+ const value = history.get(version);
224
+ // A missing/expired history entry cannot prove the holder is gone.
225
+ if (value === undefined) return undefined;
226
+ if (value === RELEASED_LOCK) {
227
+ holder = null;
228
+ continue;
229
+ }
230
+ const claim = lockToken(value);
231
+ // The previous build's two-field token won its own election; for a new
232
+ // token, only the exact SSM version it named did. Losing writes do not
233
+ // erase an already-open hold.
234
+ if (claim && (claim.expectedVersion === null || claim.expectedVersion === version)) {
235
+ holder = claim;
236
+ }
237
+ }
238
+ return holder;
239
+ }
240
+
241
+ function lockWriteConflict(error: unknown): boolean {
242
+ const name = (error as { name?: string }).name;
243
+ return name === "ParameterAlreadyExists" || name === "TooManyUpdates";
244
+ }
245
+
246
+ /** History can age out (SSM retains 100 versions) or be temporarily
247
+ * unavailable. Replace an unprovable false token with a fresh fence and admit
248
+ * nobody for one stale window. Any hidden holder predates this write and is
249
+ * therefore stale when the fence is; a normal holder release may free it
250
+ * sooner. This is deliberately not an elected holder and never returns a
251
+ * release callback. */
252
+ async function installRecoveryFence(
253
+ client: SSMClient,
254
+ name: string,
255
+ now: () => number,
256
+ ): Promise<void> {
257
+ const token = `${now()}:recovery:${Math.random().toString(36).slice(2)}`;
258
+ try {
259
+ await client.send(
260
+ new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: true }),
261
+ );
262
+ } catch (error) {
263
+ if (!lockWriteConflict(error)) throw error;
264
+ }
265
+ }
132
266
 
133
267
  export function brokerLockName(prefix: string): string {
134
268
  return `${prefix}/locks/token-broker`;
@@ -144,42 +278,248 @@ export interface BrokerLockOptions {
144
278
  /** Try to take the lock. Returns a release function, or null when another
145
279
  * run holds a fresh one. */
146
280
  export async function acquireBrokerLock(opts: BrokerLockOptions): Promise<(() => Promise<void>) | null> {
147
- const name = brokerLockName(opts.prefix);
281
+ return acquireParameterLock({ ...opts, name: brokerLockName(opts.prefix) });
282
+ }
283
+
284
+ /** The same lock, at any parameter name. */
285
+ export async function acquireParameterLock(opts: {
286
+ client: SSMClient;
287
+ name: string;
288
+ now?: () => number;
289
+ staleMs?: number;
290
+ }): Promise<(() => Promise<void>) | null> {
291
+ const name = opts.name;
148
292
  const now = opts.now ?? Date.now;
149
293
  const staleMs = opts.staleMs ?? LOCK_STALE_MS;
150
- const token = `${now()}:${Math.random().toString(36).slice(2)}`;
151
- const tryCreate = async (overwrite: boolean): Promise<boolean> => {
294
+ let current: { Parameter?: { Value?: string; Version?: number } } | null;
295
+ try {
296
+ current = await opts.client.send(new GetParameterCommand({ Name: name }));
297
+ } catch (err) {
298
+ if ((err as { name?: string }).name !== "ParameterNotFound") throw err;
299
+ current = null;
300
+ }
301
+ if (current !== null) {
302
+ const claim = lockToken(current.Parameter?.Value);
303
+ const currentVersion = current.Parameter?.Version;
304
+ if (claim) {
305
+ const isElected =
306
+ claim.expectedVersion === null ||
307
+ currentVersion === undefined ||
308
+ claim.expectedVersion === currentVersion;
309
+ if (isElected) {
310
+ if (now() - claim.heldAt < staleMs) return null;
311
+ } else {
312
+ // Versions only rise. A token naming a future version is malformed,
313
+ // not evidence that a previous holder released. Fence recovery rather
314
+ // than leaving the malformed lineage wedged forever.
315
+ if (claim.expectedVersion! > currentVersion) {
316
+ await installRecoveryFence(opts.client, name, now);
317
+ return null;
318
+ }
319
+ // Replay a mismatched token even when that token's own timestamp is
320
+ // stale: it may have landed late over a much newer, live holder.
321
+ const holder = await holderBeforeFalseToken(
322
+ opts.client,
323
+ name,
324
+ claim.expectedVersion!,
325
+ currentVersion,
326
+ );
327
+ if (holder === undefined) {
328
+ await installRecoveryFence(opts.client, name, now);
329
+ return null;
330
+ }
331
+ if (holder !== null && now() - holder.heldAt < staleMs) {
332
+ return null;
333
+ }
334
+ }
335
+ }
336
+ }
337
+ // Timed at the write, so a run that lost the election below leaves a token
338
+ // no older than ours.
339
+ const takenAt = now();
340
+ const read = current?.Parameter?.Version;
341
+ const expectedVersion = current === null ? 1 : read === undefined ? null : read + 1;
342
+ if (expectedVersion === null) return null;
343
+ const token = `${takenAt}:${expectedVersion}:${Math.random().toString(36).slice(2)}`;
344
+ if (current === null) {
345
+ // Never taken (or deleted by the build before this one): created once.
152
346
  try {
153
- await opts.client.send(
154
- new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: overwrite }),
155
- );
156
- return true;
347
+ await opts.client.send(new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: false }));
157
348
  } catch (err) {
158
- if (!overwrite && (err as { name?: string }).name === "ParameterAlreadyExists") return false;
349
+ if (lockWriteConflict(err)) return null;
159
350
  throw err;
160
351
  }
161
- };
162
- if (!(await tryCreate(false))) {
163
- // Someone holds it. Stale?
164
- let heldAt = 0;
352
+ } else {
353
+ /**
354
+ * SSM has no conditional overwrite, but it numbers every write: ours is
355
+ * the lock only if it is the very next version after the one we read
356
+ * free or stale. Of runs that read the same version, exactly one gets
357
+ * the next; the others may leave their tokens in the value after it, but
358
+ * a token is only written over a lock read free or stale, so none of
359
+ * them holds it meanwhile.
360
+ */
361
+ let written: { Version?: number };
165
362
  try {
166
- const current = await opts.client.send(new GetParameterCommand({ Name: name }));
167
- heldAt = Number(current.Parameter?.Value?.split(":")[0] ?? 0) || 0;
168
- } catch {
169
- heldAt = 0;
363
+ written = await opts.client.send(
364
+ new PutParameterCommand({ Name: name, Value: token, Type: "String", Overwrite: true }),
365
+ );
366
+ } catch (error) {
367
+ if (lockWriteConflict(error)) return null;
368
+ throw error;
170
369
  }
171
- if (heldAt && now() - heldAt < staleMs) return null;
172
- await tryCreate(true);
370
+ if (written.Version !== expectedVersion) return null;
173
371
  }
174
372
  return async () => {
373
+ // Inside the hold no other run can hold the lock, whatever token a run
374
+ // that lost the election left in the value: free it. Late in it, a run
375
+ // that found it stale may hold it already; leave it, stale anyway.
376
+ if (now() - takenAt >= Math.max(0, staleMs - LOCK_RELEASE_MARGIN_MS)) return;
175
377
  try {
176
- // Only release our own token: a takeover after a stall must not be
177
- // undone by the stalled run finishing late.
178
- const current = await opts.client.send(new GetParameterCommand({ Name: name }));
179
- if (current.Parameter?.Value !== token) return;
180
- await opts.client.send(new DeleteParameterCommand({ Name: name }));
378
+ await opts.client.send(
379
+ new PutParameterCommand({ Name: name, Value: RELEASED_LOCK, Type: "String", Overwrite: true }),
380
+ );
181
381
  } catch {
182
382
  /* the stale window covers a failed release */
183
383
  }
184
384
  };
185
385
  }
386
+
387
+ // ---------------------------------------------------------------------------
388
+ // ADR-154 §7: one named revision, for the exact-name entry point
389
+ // ---------------------------------------------------------------------------
390
+
391
+ /** One credential's lock: one invoke at a time per credential, whichever
392
+ * revision it names, since an invoke the cloud gave up on may still run. */
393
+ export function revisionLockName(credentialId: string): string {
394
+ return `${BROKER_PARAMETER_ROOT}/locks/${idPathSegment(credentialId)}`;
395
+ }
396
+
397
+ export class SsmRevisionStore implements RevisionStore {
398
+ constructor(
399
+ private readonly opts: { client: SSMClient; kmsKeyId: string; now?: () => number },
400
+ ) {}
401
+
402
+ async get(name: string): Promise<StoredParameter | null> {
403
+ try {
404
+ const out = await this.opts.client.send(new GetParameterCommand({ Name: name, WithDecryption: true }));
405
+ const parameter = out.Parameter;
406
+ if (parameter?.Value === undefined || parameter.Version === undefined) return null;
407
+ return { value: parameter.Value, version: parameter.Version };
408
+ } catch (err) {
409
+ if ((err as { name?: string }).name === "ParameterNotFound") return null;
410
+ throw err;
411
+ }
412
+ }
413
+
414
+ async overwrite(name: string, value: string): Promise<number> {
415
+ const out = await this.opts.client.send(
416
+ new PutParameterCommand({
417
+ Name: name,
418
+ Value: value,
419
+ Type: "SecureString",
420
+ KeyId: this.opts.kmsKeyId,
421
+ Overwrite: true,
422
+ }),
423
+ );
424
+ if (out.Version === undefined) throw new Error("PutParameter returned no version");
425
+ return out.Version;
426
+ }
427
+
428
+ lock(credentialId: string): Promise<(() => Promise<void>) | null> {
429
+ return acquireParameterLock({
430
+ client: this.opts.client,
431
+ name: revisionLockName(credentialId),
432
+ now: this.opts.now,
433
+ });
434
+ }
435
+ }
436
+
437
+ // ---------------------------------------------------------------------------
438
+ // ADR-154, owner answer 3: adoption of today's workspace AI accounts
439
+ // ---------------------------------------------------------------------------
440
+
441
+ async function parametersUnder(
442
+ client: SSMClient,
443
+ path: string,
444
+ decrypt: boolean,
445
+ ): Promise<Array<{ name: string; value: string }>> {
446
+ const out: Array<{ name: string; value: string }> = [];
447
+ let nextToken: string | undefined;
448
+ do {
449
+ const page = await client.send(
450
+ new GetParametersByPathCommand({ Path: path, Recursive: true, WithDecryption: decrypt, NextToken: nextToken }),
451
+ );
452
+ for (const p of page.Parameters ?? []) {
453
+ if (p.Name && typeof p.Value === "string") out.push({ name: p.Name, value: p.Value });
454
+ }
455
+ nextToken = page.NextToken;
456
+ } while (nextToken);
457
+ return out;
458
+ }
459
+
460
+ /** The legacy parameter names (under `prefix`) an adoption marker names. */
461
+ export async function adoptedLegacyNames(client: SSMClient, prefix: string): Promise<Set<string>> {
462
+ const names = new Set<string>();
463
+ const adoptedRoot = legacyAdoptedRoot(prefix);
464
+ for (const marker of await parametersUnder(client, `${adoptedRoot}/orgs`, false)) {
465
+ names.add(`${prefix}${marker.name.slice(adoptedRoot.length)}`);
466
+ }
467
+ return names;
468
+ }
469
+
470
+ export class SsmAdoptionStore extends SsmRevisionStore implements AdoptionStore {
471
+ constructor(
472
+ private readonly adoption: { client: SSMClient; kmsKeyId: string; prefix: string; now?: () => number },
473
+ ) {
474
+ super(adoption);
475
+ }
476
+
477
+ get legacyPrefix(): string {
478
+ return this.adoption.prefix;
479
+ }
480
+
481
+ async create(name: string, value: string, tags: Record<string, string>): Promise<"created" | "matched"> {
482
+ const entries = Object.entries(tags);
483
+ const marker = name.startsWith(`${legacyAdoptedRoot(this.adoption.prefix)}/`);
484
+ try {
485
+ await this.adoption.client.send(
486
+ new PutParameterCommand({
487
+ Name: name,
488
+ Value: value,
489
+ // A marker names ids only; every part is a secret.
490
+ Type: marker ? "String" : "SecureString",
491
+ ...(marker ? {} : { KeyId: this.adoption.kmsKeyId }),
492
+ Tier: marker ? "Standard" : "Advanced",
493
+ Overwrite: false,
494
+ Tags: entries.map(([Key, Value]) => ({ Key, Value })),
495
+ }),
496
+ );
497
+ return "created";
498
+ } catch (error) {
499
+ if ((error as { name?: string }).name !== "ParameterAlreadyExists") throw error;
500
+ }
501
+ // Exists: this create's retry only when its identity tags all match.
502
+ const listed = await this.adoption.client.send(
503
+ new ListTagsForResourceCommand({ ResourceType: "Parameter", ResourceId: name }),
504
+ );
505
+ const observed = new Map((listed.TagList ?? []).map((tag) => [tag.Key, tag.Value]));
506
+ for (const [key, expected] of entries) {
507
+ const actual = observed.get(key);
508
+ if (actual === undefined) throw new Error(`${name} exists and its tags are not visible yet`);
509
+ if (actual !== expected) throw new Error(`${name} exists as another credential's`);
510
+ }
511
+ return "matched";
512
+ }
513
+
514
+ listLegacy(): Promise<Array<{ name: string; value: string }>> {
515
+ return parametersUnder(this.adoption.client, legacyEngineParameterRoot(this.adoption.prefix), true);
516
+ }
517
+
518
+ listMarkers(): Promise<Array<{ name: string; value: string }>> {
519
+ return parametersUnder(this.adoption.client, `${legacyAdoptedRoot(this.adoption.prefix)}/orgs`, false);
520
+ }
521
+
522
+ scanLock(): Promise<(() => Promise<void>) | null> {
523
+ return acquireBrokerLock({ client: this.adoption.client, prefix: this.adoption.prefix, now: this.adoption.now });
524
+ }
525
+ }
@@ -19,6 +19,7 @@ import {
19
19
  type RefreshOutcome,
20
20
  type TokenLane,
21
21
  } from "./types";
22
+ import { brokerErrorKind, CREDENTIAL_LANE_SCHEDULES } from "../../src/cloud-credentials";
22
23
 
23
24
  export const OPENAI_TOKEN_ENDPOINT = "https://auth.openai.com/oauth/token";
24
25
  export const CODEX_CLIENT_ID = "app_EMoamEEZ73f0CkXaXp7hrann";
@@ -38,7 +39,8 @@ function tokensOf(files: CredentialFiles): {
38
39
  export const openaiOAuthLane: TokenLane = {
39
40
  id: "openai-oauth",
40
41
  endpoints: [OPENAI_TOKEN_ENDPOINT],
41
- schedule: { leadMs: 2 * DAY_MS },
42
+ // ADR-154 §7: the cloud names a revision on the same schedule.
43
+ schedule: CREDENTIAL_LANE_SCHEDULES["openai-oauth"]!,
42
44
  reactiveSignals: [
43
45
  /refresh token was revoked/i,
44
46
  /refresh_token_reused/i,
@@ -81,11 +83,13 @@ export const openaiOAuthLane: TokenLane = {
81
83
  refresh_token: current,
82
84
  scope: "openid profile email",
83
85
  });
84
- } catch (err) {
86
+ } catch (error) {
85
87
  return {
86
88
  ok: false,
87
89
  terminal: false,
88
- reason: `request failed: ${err instanceof Error ? err.message : String(err)}`,
90
+ // A transport error can quote the request (and therefore the refresh
91
+ // token) in its message. Only its bounded kind may cross the lane.
92
+ reason: brokerErrorKind(error),
89
93
  };
90
94
  }
91
95
  if (res.status < 200 || res.status >= 300) {
@@ -25,6 +25,7 @@ import {
25
25
  type RefreshOutcome,
26
26
  type TokenLane,
27
27
  } from "./types";
28
+ import { brokerErrorKind, CREDENTIAL_LANE_SCHEDULES } from "../../src/cloud-credentials";
28
29
 
29
30
  export const XAI_ISSUER = "https://auth.x.ai";
30
31
  export const XAI_TOKEN_ENDPOINT = `${XAI_ISSUER}/oauth2/token`;
@@ -60,7 +61,8 @@ export const xaiOAuthLane: TokenLane = {
60
61
  // Observed 2026-09-09: the token lives SIX hours (create_time → expires_at).
61
62
  // Refresh with three hours left — the hourly cycle then renews it at about
62
63
  // four hours of age — and every four hours when expiry cannot be read.
63
- schedule: { leadMs: 3 * HOUR_MS, everyMs: 4 * HOUR_MS },
64
+ // ADR-154 §7: the cloud names a revision on the same schedule.
65
+ schedule: CREDENTIAL_LANE_SCHEDULES["xai-oauth"]!,
64
66
  reactiveSignals: [/invalid_grant/i, /token (?:has )?expired/i, /log ?in again/i, /unauthorized/i],
65
67
 
66
68
  matches(header: CredentialHeader, files: CredentialFiles): boolean {
@@ -117,11 +119,13 @@ export const xaiOAuthLane: TokenLane = {
117
119
  refresh_token: current,
118
120
  client_id: clientId,
119
121
  });
120
- } catch (err) {
122
+ } catch (error) {
121
123
  return {
122
124
  ok: false,
123
125
  terminal: false,
124
- reason: `request failed: ${err instanceof Error ? err.message : String(err)}`,
126
+ // A transport error can quote the request (and therefore the refresh
127
+ // token) in its message. Only its bounded kind may cross the lane.
128
+ reason: brokerErrorKind(error),
125
129
  };
126
130
  }
127
131
  if (res.status < 200 || res.status >= 300) {
package/lib/transcript.ts CHANGED
@@ -9,12 +9,12 @@
9
9
  * instructions) only — no tool-call execution traces.
10
10
  */
11
11
 
12
- import { appendFileSync, mkdirSync } from "node:fs";
13
- import { resolve } from "node:path";
12
+ import { mkdirSync } from "node:fs";
14
13
 
15
14
  import { taskWorkspaceDir } from "./env";
16
15
  import { rewriteAttachmentRefs } from "./orchestrator";
17
16
  import type { TaskEnvironmentHandle } from "./task-environment/types";
17
+ import { appendContainedFileSync, makeContainedDirectoriesSync } from "./task-environment/workspace-files";
18
18
  import type { TranscriptTarget } from "../src/protocol";
19
19
 
20
20
  /** Container path agents are pointed at. */
@@ -43,10 +43,14 @@ export function appendTranscript(
43
43
  ): void {
44
44
  const entry = transcriptEntry(author, text);
45
45
  if (entry === null) return;
46
- const dir = resolve(taskWorkspaceDir(taskId), ".uai");
47
- mkdirSync(dir, { recursive: true });
46
+ // Into the task's own .uai, never through a link the task put there
47
+ // (#cb3j0z). The workspace root is the runner's own path; .uai, directly
48
+ // below it, is made if gone without following a link.
49
+ const workspace = taskWorkspaceDir(taskId);
50
+ mkdirSync(workspace, { recursive: true });
51
+ makeContainedDirectoriesSync(workspace, [".uai"]);
48
52
  for (const target of new Set(targets)) {
49
- appendFileSync(resolve(dir, TARGET_FILENAME[target]), entry);
53
+ appendContainedFileSync(workspace, [".uai", TARGET_FILENAME[target]], Buffer.from(entry));
50
54
  }
51
55
  }
52
56
 
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.149",
3
+ "version": "0.9.151",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
@@ -57,6 +57,10 @@
57
57
  "types": "./src/protocol.ts",
58
58
  "default": "./src/protocol.ts"
59
59
  },
60
+ "./cloud-credentials": {
61
+ "types": "./src/cloud-credentials.ts",
62
+ "default": "./src/cloud-credentials.ts"
63
+ },
60
64
  "./project-env-policy": {
61
65
  "types": "./src/project-env-policy.ts",
62
66
  "default": "./src/project-env-policy.ts"
@@ -78,6 +82,7 @@
78
82
  "uai-host": "tsx src/cli.ts"
79
83
  },
80
84
  "dependencies": {
85
+ "@aws-sdk/client-ssm": "3.899.0",
81
86
  "@sentry/node": "^10.66.0",
82
87
  "better-sqlite3": "12.11.1",
83
88
  "dotenv": "^16.4.5",
@@ -88,7 +93,6 @@
88
93
  "zod": "^3.23.8"
89
94
  },
90
95
  "devDependencies": {
91
- "@aws-sdk/client-ssm": "3.899.0",
92
96
  "@types/node": "^22.9.0",
93
97
  "@types/ws": "^8.18.1",
94
98
  "@typescript-eslint/eslint-plugin": "^8.59.4",