@runuai/host 0.9.138 → 0.9.140

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.
@@ -23,10 +23,19 @@ import { addEngineAccount } from "./engine-accounts";
23
23
  import { connectEngine, type EngineKind } from "./engines";
24
24
 
25
25
  const CONNECT_TIMEOUT_MS = 2 * 60_000;
26
- const MAX_CONCURRENT_CONNECTS = 1;
26
+ // Connects are one store write each; labeled (workspace) connects never touch
27
+ // a shared slot, so they run side by side — a user is never told to wait for
28
+ // someone else's connect (ADR-136 sign-in box). Bounded, not serialised.
29
+ const MAX_CONCURRENT_CONNECTS = 16;
27
30
 
28
31
  export type EmitEngineConnectEvent = (frame: EngineConnectEventFrame) => void;
29
32
 
33
+ export interface AddAccountOutcome {
34
+ ok: boolean;
35
+ message: string;
36
+ id?: string;
37
+ }
38
+
30
39
  export interface EngineConnectManagerOptions {
31
40
  /** Re-advertise capabilities after a successful mutation (main.ts). */
32
41
  readvertise(): void;
@@ -34,6 +43,17 @@ export interface EngineConnectManagerOptions {
34
43
  refreshEngineAccountAgents(kind: string): Promise<void>;
35
44
  /** Best-effort post-connect image maintenance (optional engines bake in). */
36
45
  ensureStandardImage(): Promise<unknown>;
46
+ /** ADR-136 sign-in box: only labeled, workspace-scoped connects are
47
+ * admitted — nothing here may write the host's default slot. */
48
+ workspaceOnly?: boolean;
49
+ /** The labeled-account sink. Defaults to the local `addEngineAccount`; the
50
+ * sign-in box supplies the store-first one and awaits its write. */
51
+ addLabeledAccount?: (
52
+ kind: EngineConnectKind,
53
+ label: string,
54
+ secret: string,
55
+ scope: string,
56
+ ) => AddAccountOutcome | Promise<AddAccountOutcome>;
37
57
  timers?: {
38
58
  setTimeout(handler: () => void, ms: number): NodeJS.Timeout;
39
59
  clearTimeout(timer: NodeJS.Timeout): void;
@@ -77,7 +97,7 @@ export class EngineConnectManager {
77
97
  engine,
78
98
  phase: "done",
79
99
  ok: false,
80
- message: "Another engine connect is already in progress.",
100
+ message: "Too many engine connects are running on this runner right now; try again in a minute.",
81
101
  });
82
102
  return true;
83
103
  }
@@ -156,15 +176,26 @@ export class EngineConnectManager {
156
176
  return;
157
177
  }
158
178
 
179
+ // ADR-136 sign-in box: a workspace account or nothing.
180
+ if (
181
+ this.options.workspaceOnly &&
182
+ (label === undefined || scope === undefined || scope === HOST_ACCOUNT_SCOPE)
183
+ ) {
184
+ this.finish(
185
+ operation,
186
+ false,
187
+ "The Uai Cloud sign-in box connects workspace accounts only.",
188
+ );
189
+ return;
190
+ }
159
191
  // ADR-116 labeled extra account — the sink validates kind support.
160
192
  if (label !== undefined && secret !== undefined) {
161
- const added = addEngineAccount(
162
- engine,
163
- label,
164
- { token: secret },
165
- {},
166
- scope ?? HOST_ACCOUNT_SCOPE,
167
- );
193
+ const sink =
194
+ this.options.addLabeledAccount ??
195
+ ((kind, accountLabel, value, accountScope) =>
196
+ addEngineAccount(kind, accountLabel, { token: value }, {}, accountScope));
197
+ const added = await sink(engine, label, secret, scope ?? HOST_ACCOUNT_SCOPE);
198
+ if (operation.done) return;
168
199
  if (added.ok) this.afterMutation(engine, false);
169
200
  this.finish(operation, added.ok, added.message, added.id);
170
201
  return;
@@ -64,7 +64,13 @@ export const ENGINE_LOGIN_TIMEOUT_MS = 10 * 60_000;
64
64
  export const SUBMIT_KEYSTROKE_DELAY_MS = 300;
65
65
  // Global serialization keeps a failed per-operation stale-resource sweep from
66
66
  // ever deleting another live login's labeled container or marked temp dir.
67
- export const MAX_CONCURRENT_ENGINE_LOGINS = 1;
67
+ /**
68
+ * Concurrent login containers per runner. Workspace sign-ins run side by
69
+ * side (ADR-136 sign-in box: a user is never told to wait for someone else's
70
+ * login); only the host's DEFAULT slot is exclusive, per engine. Bounded so
71
+ * a burst cannot fork containers without limit.
72
+ */
73
+ export const MAX_CONCURRENT_ENGINE_LOGINS = 16;
68
74
 
69
75
  const LOGIN_MOUNT = "/run/uai-engine-login";
70
76
  const LOGIN_HOME = `${LOGIN_MOUNT}/home`;
@@ -136,6 +142,12 @@ export interface EngineLoginTimers {
136
142
  clearTimeout(timer: unknown): void;
137
143
  }
138
144
 
145
+ export interface AccountAddOutcome {
146
+ ok: boolean;
147
+ message: string;
148
+ id?: string;
149
+ }
150
+
139
151
  export interface EngineLoginSeams {
140
152
  createTempDir(): Promise<string>;
141
153
  removeTempDir(path: string): Promise<void>;
@@ -158,22 +170,24 @@ export interface EngineLoginSeams {
158
170
  readTranscript(tempDir: string): Promise<string | null>;
159
171
  persistClaudeToken(token: string): Promise<void>;
160
172
  persistCodexAuth(sourceFile: string): Promise<void>;
161
- /** ADR-116: labeled extra-account captures (never the default slots). */
173
+ /** ADR-116: labeled extra-account captures (never the default slots).
174
+ * Sync on a host that seals the capture locally; a promise on the ADR-136
175
+ * sign-in box, whose sink awaits the credential store's write. */
162
176
  addClaudeAccount(
163
177
  label: string,
164
178
  token: string,
165
179
  scope: string,
166
- ): { ok: boolean; message: string; id?: string };
180
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
167
181
  addCodexAccount(
168
182
  label: string,
169
183
  authJson: string,
170
184
  scope: string,
171
- ): { ok: boolean; message: string; id?: string };
185
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
172
186
  addGrokAccount(
173
187
  label: string,
174
188
  authJson: string,
175
189
  scope: string,
176
- ): { ok: boolean; message: string; id?: string };
190
+ ): AccountAddOutcome | Promise<AccountAddOutcome>;
177
191
  persistGrokAuth(sourceFile: string): Promise<void>;
178
192
  /** Optional engines ride the standard image only when configured; a fresh
179
193
  * account may be the first configuration → rebuild (queue-deduped). */
@@ -197,6 +211,9 @@ export interface EngineLoginManagerOptions {
197
211
  tempRoot?: string;
198
212
  /** Defer Docker inventory until the first start when no runtime is selected. */
199
213
  reconcileOnCreate?: boolean;
214
+ /** ADR-136 sign-in box: only labeled, workspace-scoped sign-ins are
215
+ * admitted — the default slots are the host owner's and the box has none. */
216
+ workspaceOnly?: boolean;
200
217
  seams?: Partial<EngineLoginSeams>;
201
218
  /** Rebuild the standard image after a successful capture (optional engines). */
202
219
  ensureStandardImage?: () => Promise<unknown>;
@@ -389,12 +406,21 @@ function withDefaults(options: EngineLoginManagerOptions): EngineLoginSeams {
389
406
  */
390
407
  export class EngineLoginManager {
391
408
  private readonly seams: EngineLoginSeams;
409
+ private readonly workspaceOnly: boolean;
392
410
  private readonly operations = new Map<string, LoginOperation>();
393
- private readonly engineOperations = new Map<EngineLoginKind, LoginOperation>();
411
+ /**
412
+ * The DEFAULT-SLOT lock: one unlabeled capture per engine at a time, since
413
+ * two of them would race on the host's own `~/.codex`-style slot. Labeled
414
+ * (workspace) captures land in isolated accounts and never take it — on
415
+ * the ADR-136 sign-in box every workspace's sign-in runs alongside every
416
+ * other's; a user is never told to wait for someone else's login.
417
+ */
418
+ private readonly defaultSlotOperations = new Map<EngineLoginKind, LoginOperation>();
394
419
  private reconciliation: Promise<boolean>;
395
420
 
396
421
  constructor(options: EngineLoginManagerOptions) {
397
422
  this.seams = withDefaults(options);
423
+ this.workspaceOnly = options.workspaceOnly === true;
398
424
  this.reconciliation =
399
425
  options.reconcileOnCreate === false
400
426
  ? Promise.resolve(false)
@@ -449,6 +475,23 @@ export class EngineLoginManager {
449
475
  );
450
476
  return false;
451
477
  }
478
+ // ADR-136 sign-in box: nothing here may write a default slot, and a
479
+ // host-scope account would belong to a runner that owns no credential.
480
+ if (
481
+ this.workspaceOnly &&
482
+ (scope === undefined || scope === HOST_ACCOUNT_SCOPE || label === undefined)
483
+ ) {
484
+ safeEmit(
485
+ emit,
486
+ failedEvent(
487
+ opId,
488
+ engine,
489
+ "start_failed",
490
+ "The Uai Cloud sign-in box signs in workspace accounts only.",
491
+ ),
492
+ );
493
+ return false;
494
+ }
452
495
  // A workspace's sign-in is always a labeled account: the default slot is
453
496
  // the host owner's and must never be written on a tenant's behalf.
454
497
  if (scope !== undefined && scope !== HOST_ACCOUNT_SCOPE && label === undefined) {
@@ -469,7 +512,7 @@ export class EngineLoginManager {
469
512
  safeEmit(emit, failedEvent(opId, engine, "unavailable", "Login operation already exists."));
470
513
  return false;
471
514
  }
472
- if (this.engineOperations.has(engine)) {
515
+ if (label === undefined && this.defaultSlotOperations.has(engine)) {
473
516
  safeEmit(
474
517
  emit,
475
518
  failedEvent(
@@ -481,10 +524,16 @@ export class EngineLoginManager {
481
524
  );
482
525
  return false;
483
526
  }
527
+ // Every login is a container; bound the fan-out without serialising it.
484
528
  if (this.operations.size >= MAX_CONCURRENT_ENGINE_LOGINS) {
485
529
  safeEmit(
486
530
  emit,
487
- failedEvent(opId, engine, "unavailable", "Too many login operations are active."),
531
+ failedEvent(
532
+ opId,
533
+ engine,
534
+ "unavailable",
535
+ "Too many sign-ins are running on this runner right now; try again in a minute.",
536
+ ),
488
537
  );
489
538
  return false;
490
539
  }
@@ -516,7 +565,7 @@ export class EngineLoginManager {
516
565
  postInputOutputSeen: false,
517
566
  };
518
567
  this.operations.set(opId, operation);
519
- this.engineOperations.set(engine, operation);
568
+ if (label === undefined) this.defaultSlotOperations.set(engine, operation);
520
569
  operation.timer = this.seams.timers.setTimeout(() => {
521
570
  void this.timeout(operation);
522
571
  }, ENGINE_LOGIN_TIMEOUT_MS);
@@ -1043,7 +1092,7 @@ export class EngineLoginManager {
1043
1092
  // ADR-116: labeled capture — an extra account, not the default slot.
1044
1093
  // Extras are picked up at the next agent spawn / rotation, so no
1045
1094
  // running-agent refresh is needed (mirrors the local accounts API).
1046
- const added = this.seams.addClaudeAccount(
1095
+ const added = await this.seams.addClaudeAccount(
1047
1096
  operation.accountLabel,
1048
1097
  token,
1049
1098
  operation.accountScope,
@@ -1118,10 +1167,10 @@ export class EngineLoginManager {
1118
1167
  if (operation.accountLabel !== null) {
1119
1168
  // ADR-116: labeled capture — the auth.json this login produced becomes
1120
1169
  // an isolated extra account instead of overwriting ~/.codex.
1121
- let added: { ok: boolean; message: string; id?: string };
1170
+ let added: AccountAddOutcome;
1122
1171
  try {
1123
1172
  const contents = await readFile(authFile, "utf8");
1124
- added = grok
1173
+ added = await (grok
1125
1174
  ? this.seams.addGrokAccount(
1126
1175
  operation.accountLabel,
1127
1176
  contents,
@@ -1131,7 +1180,7 @@ export class EngineLoginManager {
1131
1180
  operation.accountLabel,
1132
1181
  contents,
1133
1182
  operation.accountScope,
1134
- );
1183
+ ));
1135
1184
  } catch {
1136
1185
  added = {
1137
1186
  ok: false,
@@ -1279,8 +1328,8 @@ export class EngineLoginManager {
1279
1328
  if (this.operations.get(operation.opId) === operation) {
1280
1329
  this.operations.delete(operation.opId);
1281
1330
  }
1282
- if (this.engineOperations.get(operation.engine) === operation) {
1283
- this.engineOperations.delete(operation.engine);
1331
+ if (this.defaultSlotOperations.get(operation.engine) === operation) {
1332
+ this.defaultSlotOperations.delete(operation.engine);
1284
1333
  }
1285
1334
  });
1286
1335
  return operation.settlementPromise;
package/lib/env-file.ts CHANGED
@@ -79,6 +79,36 @@ export function writeEnvValuesAtomic(
79
79
  replaceEnvFileAtomic(file, `${kept.join("\n")}\n`);
80
80
  }
81
81
 
82
+ /**
83
+ * Atomically drop `keys` from a dotenv file (a missing file or key is fine).
84
+ * The sign-in box (ADR-136) strips engine credentials from `.env.local` at
85
+ * start so no default-slot secret survives on that machine.
86
+ */
87
+ export function removeEnvValuesAtomic(
88
+ file: string,
89
+ keys: readonly string[],
90
+ ): string[] {
91
+ if (keys.length === 0 || !existsSync(file)) return [];
92
+ for (const key of keys) {
93
+ if (!ENV_KEY.test(key)) throw new Error(`invalid environment key: ${key}`);
94
+ }
95
+ const drop = new Set(keys);
96
+ const removed: string[] = [];
97
+ const current = readFileSync(file, "utf8");
98
+ const kept = current.split(/\r?\n/).filter((line, index, lines) => {
99
+ if (index === lines.length - 1 && line === "") return false;
100
+ const equals = line.indexOf("=");
101
+ if (equals <= 0) return true;
102
+ const key = line.slice(0, equals);
103
+ if (!drop.has(key)) return true;
104
+ removed.push(key);
105
+ return false;
106
+ });
107
+ if (removed.length === 0) return [];
108
+ replaceEnvFileAtomic(file, kept.length === 0 ? "" : `${kept.join("\n")}\n`);
109
+ return [...new Set(removed)];
110
+ }
111
+
82
112
  /** Capture the exact bytes needed to roll back a multi-store config write. */
83
113
  export function readEnvFileSnapshot(file: string): EnvFileSnapshot {
84
114
  return existsSync(file)