@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.
- package/lib/browser-testing.ts +6 -1
- package/lib/credential-store.ts +34 -2
- package/lib/engine-accounts.ts +438 -133
- package/lib/engine-connect.ts +40 -9
- package/lib/engine-login.ts +64 -15
- package/lib/env-file.ts +30 -0
- package/lib/github-tokens.ts +281 -17
- package/lib/host-env.ts +4 -0
- package/lib/mcp-connections.ts +208 -5
- package/lib/signin-box-credentials.ts +465 -0
- package/lib/signin-box.ts +114 -0
- package/lib/ssh.ts +109 -6
- package/package.json +1 -1
- package/src/cli.ts +39 -6
- package/src/main.ts +210 -3
- package/src/protocol.ts +11 -5
package/lib/engine-connect.ts
CHANGED
|
@@ -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
|
-
|
|
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: "
|
|
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
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
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;
|
package/lib/engine-login.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
-
):
|
|
180
|
+
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
167
181
|
addCodexAccount(
|
|
168
182
|
label: string,
|
|
169
183
|
authJson: string,
|
|
170
184
|
scope: string,
|
|
171
|
-
):
|
|
185
|
+
): AccountAddOutcome | Promise<AccountAddOutcome>;
|
|
172
186
|
addGrokAccount(
|
|
173
187
|
label: string,
|
|
174
188
|
authJson: string,
|
|
175
189
|
scope: string,
|
|
176
|
-
):
|
|
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
|
-
|
|
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.
|
|
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(
|
|
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.
|
|
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:
|
|
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.
|
|
1283
|
-
this.
|
|
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)
|