@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/ssh.ts
CHANGED
|
@@ -23,10 +23,11 @@ import { join, resolve } from "node:path";
|
|
|
23
23
|
|
|
24
24
|
import { eq } from "drizzle-orm";
|
|
25
25
|
|
|
26
|
-
import { queueStoreWrite, userParameterName } from "./credential-store";
|
|
26
|
+
import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
|
|
27
27
|
import { getDb, schema } from "./db";
|
|
28
28
|
import { env } from "./env";
|
|
29
29
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
30
|
+
import { signInBoxMode } from "./signin-box";
|
|
30
31
|
import { assertSafeHostTaskId } from "./task-identity";
|
|
31
32
|
|
|
32
33
|
const withNewline = (s: string): string => (s.endsWith("\n") ? s : `${s}\n`);
|
|
@@ -137,14 +138,15 @@ export function sshKeyParameterName(userId: string): string {
|
|
|
137
138
|
return userParameterName(userId, "ssh");
|
|
138
139
|
}
|
|
139
140
|
|
|
140
|
-
/** The row as it is mirrored (private key decrypted). Never logged.
|
|
141
|
+
/** The row as it is mirrored (private key decrypted). Never logged. Null
|
|
142
|
+
* for a store-held row (sign-in box): nothing here to mirror. */
|
|
141
143
|
export function sshKeyStoreValue(userId: string): StoredSshKey | null {
|
|
142
144
|
const row = getDb()
|
|
143
145
|
.select()
|
|
144
146
|
.from(schema.sshKeys)
|
|
145
147
|
.where(eq(schema.sshKeys.userId, userId))
|
|
146
148
|
.get();
|
|
147
|
-
if (!row) return null;
|
|
149
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
148
150
|
return {
|
|
149
151
|
v: 1,
|
|
150
152
|
kind: "ssh",
|
|
@@ -175,6 +177,8 @@ export function listSshKeyStoreRows(): Array<{ userId: string; storeRef: string
|
|
|
175
177
|
}
|
|
176
178
|
|
|
177
179
|
export function mirrorSshKeyToStore(userId: string): void {
|
|
180
|
+
// ADR-136 sign-in box: rows hold no material; the store was written first.
|
|
181
|
+
if (signInBoxMode()) return;
|
|
178
182
|
queueStoreWrite(`mirror ssh key ${userId}`, async (store) => {
|
|
179
183
|
const value = sshKeyStoreValue(userId);
|
|
180
184
|
if (!value) return;
|
|
@@ -203,14 +207,15 @@ export function importSshKeyFromStore(value: StoredSshKey, storeRef: string): bo
|
|
|
203
207
|
return result.changes > 0;
|
|
204
208
|
}
|
|
205
209
|
|
|
206
|
-
/** Decrypt + return the user's private key, or null if they have none
|
|
210
|
+
/** Decrypt + return the user's private key, or null if they have none (or
|
|
211
|
+
* the row is store-held — the sign-in box never holds one). */
|
|
207
212
|
export function privateKeyForUser(userId: string): string | null {
|
|
208
213
|
const row = getDb()
|
|
209
214
|
.select()
|
|
210
215
|
.from(schema.sshKeys)
|
|
211
216
|
.where(eq(schema.sshKeys.userId, userId))
|
|
212
217
|
.get();
|
|
213
|
-
if (!row) return null;
|
|
218
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
214
219
|
return openAesGcm(
|
|
215
220
|
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
216
221
|
Buffer.from(row.privateKeyNonce as Uint8Array),
|
|
@@ -226,7 +231,7 @@ export function keyPairForUser(
|
|
|
226
231
|
.from(schema.sshKeys)
|
|
227
232
|
.where(eq(schema.sshKeys.userId, userId))
|
|
228
233
|
.get();
|
|
229
|
-
if (!row) return null;
|
|
234
|
+
if (!row || isStoreHeldSshRow(row)) return null;
|
|
230
235
|
return {
|
|
231
236
|
privateKey: openAesGcm(
|
|
232
237
|
Buffer.from(row.privateKeyCt as Uint8Array),
|
|
@@ -236,6 +241,104 @@ export function keyPairForUser(
|
|
|
236
241
|
};
|
|
237
242
|
}
|
|
238
243
|
|
|
244
|
+
// ---------------------------------------------------------------------------
|
|
245
|
+
// ADR-136 sign-in box: a key row here holds NO private key (`private_key_ct`
|
|
246
|
+
// empty — "store-held"); the keypair goes to the store first and only the
|
|
247
|
+
// public half stays, which is all the box ever answers (`ssh.key.get`). The
|
|
248
|
+
// public key also rides as a tag so a replacement box lists it without
|
|
249
|
+
// reading the value.
|
|
250
|
+
// ---------------------------------------------------------------------------
|
|
251
|
+
|
|
252
|
+
export const SSH_KEY_TAGS = { publicKey: "uai_public_key", createdAt: "uai_created_at" } as const;
|
|
253
|
+
|
|
254
|
+
export function isStoreHeldSshRow(row: { privateKeyCt: unknown }): boolean {
|
|
255
|
+
const ct = row.privateKeyCt;
|
|
256
|
+
if (ct === null || ct === undefined) return true;
|
|
257
|
+
if (ct instanceof Uint8Array) return ct.byteLength === 0;
|
|
258
|
+
return false;
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function sshKeyMetadataTags(value: { publicKey: string; createdAt: number }): Record<string, string> {
|
|
262
|
+
return {
|
|
263
|
+
[SSH_KEY_TAGS.publicKey]: value.publicKey,
|
|
264
|
+
[SSH_KEY_TAGS.createdAt]: String(value.createdAt),
|
|
265
|
+
};
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** A key row with no private half (sign-in box). Existing rows win. */
|
|
269
|
+
export function insertStoreHeldSshKeyRow(args: {
|
|
270
|
+
userId: string;
|
|
271
|
+
publicKey: string;
|
|
272
|
+
createdAt: number;
|
|
273
|
+
updatedAt: number;
|
|
274
|
+
storeRef: string;
|
|
275
|
+
}): boolean {
|
|
276
|
+
const result = getDb()
|
|
277
|
+
.insert(schema.sshKeys)
|
|
278
|
+
.values({
|
|
279
|
+
userId: args.userId,
|
|
280
|
+
publicKey: args.publicKey,
|
|
281
|
+
privateKeyCt: Buffer.alloc(0),
|
|
282
|
+
privateKeyNonce: Buffer.alloc(0),
|
|
283
|
+
createdAt: args.createdAt,
|
|
284
|
+
updatedAt: args.updatedAt,
|
|
285
|
+
storeRef: args.storeRef,
|
|
286
|
+
})
|
|
287
|
+
.onConflictDoNothing({ target: schema.sshKeys.userId })
|
|
288
|
+
.run();
|
|
289
|
+
return result.changes > 0;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/** Delete the row only (no store call). */
|
|
293
|
+
export function deleteSshKeyRowLocal(userId: string): void {
|
|
294
|
+
getDb().delete(schema.sshKeys).where(eq(schema.sshKeys.userId, userId)).run();
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
/**
|
|
298
|
+
* `ensureKeyForUser` for the sign-in box: generate, write the keypair to the
|
|
299
|
+
* store FIRST (awaited — a refused put is the operation's failure and leaves
|
|
300
|
+
* nothing), tag the public key, then keep a store-held row. A row whose
|
|
301
|
+
* public key this box could not learn (a legacy parameter it may not read)
|
|
302
|
+
* is refused rather than silently regenerated over the user's real key.
|
|
303
|
+
*/
|
|
304
|
+
export async function ensureSshKeyStoreFirst(userId: string): Promise<string> {
|
|
305
|
+
const existing = getDb()
|
|
306
|
+
.select({ publicKey: schema.sshKeys.publicKey })
|
|
307
|
+
.from(schema.sshKeys)
|
|
308
|
+
.where(eq(schema.sshKeys.userId, userId))
|
|
309
|
+
.get();
|
|
310
|
+
if (existing) {
|
|
311
|
+
if (existing.publicKey) return existing.publicKey;
|
|
312
|
+
throw new Error(
|
|
313
|
+
"this user's SSH key exists in the credential store but its public half is not tagged yet; re-run the sign-in box bootstrap with the transitional read",
|
|
314
|
+
);
|
|
315
|
+
}
|
|
316
|
+
const store = configuredCredentialStore();
|
|
317
|
+
if (!store) throw new Error("the sign-in box has no credential store to hold the key");
|
|
318
|
+
const { publicKey, privateKey } = generateEd25519(`uai-${userId}`);
|
|
319
|
+
const now = Date.now();
|
|
320
|
+
const name = sshKeyParameterName(userId);
|
|
321
|
+
const value: StoredSshKey = {
|
|
322
|
+
v: 1,
|
|
323
|
+
kind: "ssh",
|
|
324
|
+
userId,
|
|
325
|
+
publicKey,
|
|
326
|
+
privateKey,
|
|
327
|
+
createdAt: now,
|
|
328
|
+
updatedAt: now,
|
|
329
|
+
};
|
|
330
|
+
await store.put(name, JSON.stringify(value));
|
|
331
|
+
try {
|
|
332
|
+
await store.tag(name, sshKeyMetadataTags(value));
|
|
333
|
+
} catch (err) {
|
|
334
|
+
console.warn(
|
|
335
|
+
`[ssh] user ${userId}: tagging ${name} failed (${err instanceof Error ? err.message : String(err)}) — a replacement box needs the transitional read to list this key`,
|
|
336
|
+
);
|
|
337
|
+
}
|
|
338
|
+
insertStoreHeldSshKeyRow({ userId, publicKey, createdAt: now, updatedAt: now, storeRef: name });
|
|
339
|
+
return getPublicKey(userId) ?? publicKey;
|
|
340
|
+
}
|
|
341
|
+
|
|
239
342
|
// --- per-task on-disk identity (optional SSH fallback + signing, ADR-029) -----
|
|
240
343
|
//
|
|
241
344
|
// task-up.sh uses the creator's key for host Git only when GitHub is not
|
package/package.json
CHANGED
package/src/cli.ts
CHANGED
|
@@ -204,6 +204,10 @@ async function cmdRun(rest: string[] = []): Promise<void> {
|
|
|
204
204
|
// (lib/sidecar.ts) — set before main.ts loads, since its boot reads it.
|
|
205
205
|
const sidecarTask = flagValue(rest, "--task");
|
|
206
206
|
if (sidecarTask) process.env.UAI_SIDECAR_TASK = sidecarTask;
|
|
207
|
+
// ADR-136 sign-in box: `uai-host run --signin-box` on the small always-on
|
|
208
|
+
// machine behind the "Uai Cloud" row. Same package, one flag
|
|
209
|
+
// (lib/signin-box.ts); set before main.ts loads for the same reason.
|
|
210
|
+
if (rest.includes("--signin-box")) process.env.UAI_SIGNIN_BOX = "1";
|
|
207
211
|
// Timestamped service logs: the ADR-106 live smoke burned an hour dating
|
|
208
212
|
// undated scrollback. Applies to the service process only, not one-shot CLI
|
|
209
213
|
// commands.
|
|
@@ -1581,12 +1585,21 @@ async function cmdSetup(rest: string[]): Promise<void> {
|
|
|
1581
1585
|
process.exitCode = 1;
|
|
1582
1586
|
return;
|
|
1583
1587
|
}
|
|
1588
|
+
// ADR-136 step 4: a replace-machine enrollment answers with the EXISTING
|
|
1589
|
+
// row's id — this machine adopts it instead of its own proposal, so the
|
|
1590
|
+
// runner keeps its identity (and every task created against it).
|
|
1591
|
+
const outcome =
|
|
1592
|
+
success && typeof success === "object" && !Array.isArray(success)
|
|
1593
|
+
? (success as Record<string, unknown>)
|
|
1594
|
+
: null;
|
|
1595
|
+
const replacedHostId =
|
|
1596
|
+
outcome?.replaced === true && typeof outcome.hostId === "string" && outcome.hostId.length > 0
|
|
1597
|
+
? outcome.hostId
|
|
1598
|
+
: null;
|
|
1584
1599
|
if (
|
|
1585
|
-
!
|
|
1586
|
-
|
|
1587
|
-
|
|
1588
|
-
(success as Record<string, unknown>).ok !== true ||
|
|
1589
|
-
(success as Record<string, unknown>).hostId !== pending.hostId
|
|
1600
|
+
!outcome ||
|
|
1601
|
+
outcome.ok !== true ||
|
|
1602
|
+
(replacedHostId === null && outcome.hostId !== pending.hostId)
|
|
1590
1603
|
) {
|
|
1591
1604
|
console.error(red("enrollment outcome did not match the proposed host identity"));
|
|
1592
1605
|
console.error(dim("the proposed identity is saved locally; retry this exact command."));
|
|
@@ -1610,7 +1623,27 @@ async function cmdSetup(rest: string[]): Promise<void> {
|
|
|
1610
1623
|
process.exitCode = 1;
|
|
1611
1624
|
return;
|
|
1612
1625
|
}
|
|
1613
|
-
|
|
1626
|
+
if (replacedHostId !== null) {
|
|
1627
|
+
// The pending commit wrote the proposed id; the row this machine now
|
|
1628
|
+
// answers as is the replaced one. Same secret, so the bridge admits it.
|
|
1629
|
+
try {
|
|
1630
|
+
writeEnvValuesAtomic(envLocalPath(), { UAI_HOST_ID: replacedHostId });
|
|
1631
|
+
} catch (err) {
|
|
1632
|
+
console.error(
|
|
1633
|
+
red(
|
|
1634
|
+
`replaced, but could not record the runner id in ${envLocalPath()}: ${err instanceof Error ? err.message : String(err)}`,
|
|
1635
|
+
),
|
|
1636
|
+
);
|
|
1637
|
+
console.error(dim(`set UAI_HOST_ID=${replacedHostId} there by hand before starting.`));
|
|
1638
|
+
process.exitCode = 1;
|
|
1639
|
+
return;
|
|
1640
|
+
}
|
|
1641
|
+
console.log(
|
|
1642
|
+
green("✓ replaced") + dim(` — this machine now answers as runner ${replacedHostId}; config written to ${envLocalPath()}`),
|
|
1643
|
+
);
|
|
1644
|
+
} else {
|
|
1645
|
+
console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
|
|
1646
|
+
}
|
|
1614
1647
|
printTelemetryNotice();
|
|
1615
1648
|
console.log("");
|
|
1616
1649
|
console.log("Start the host:");
|
package/src/main.ts
CHANGED
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
type OutgoingHttpHeaders,
|
|
11
11
|
} from "node:http";
|
|
12
12
|
import { Socket } from "node:net";
|
|
13
|
+
import { homedir } from "node:os";
|
|
13
14
|
import { join } from "node:path";
|
|
14
15
|
|
|
15
16
|
import WebSocket, { type RawData } from "ws";
|
|
@@ -159,9 +160,13 @@ import {
|
|
|
159
160
|
type HostConfigResult,
|
|
160
161
|
} from "../lib/host-config";
|
|
161
162
|
import {
|
|
163
|
+
addCapturedConfigDirAccountToStore,
|
|
164
|
+
addEngineAccountToStore,
|
|
162
165
|
configureEngineAccountStore,
|
|
163
166
|
hasStoredEngineAccounts,
|
|
164
167
|
listEngineAccountsAllScopes,
|
|
168
|
+
purgeLocalEngineAccountSecrets,
|
|
169
|
+
reconcileEngineAccountMetadataFromStore,
|
|
165
170
|
reconcileEngineAccountsFromStore,
|
|
166
171
|
} from "../lib/engine-accounts";
|
|
167
172
|
import {
|
|
@@ -253,6 +258,21 @@ import {
|
|
|
253
258
|
import { isEnvironmentTransportProvider } from "../lib/task-environment/types";
|
|
254
259
|
import { LeaseManager } from "../lib/lease";
|
|
255
260
|
import { markSidecarReady, sidecarMode, sidecarTaskId } from "../lib/sidecar";
|
|
261
|
+
import {
|
|
262
|
+
purgeDefaultEngineSlots,
|
|
263
|
+
SIGN_IN_BOX_TASK_REFUSAL,
|
|
264
|
+
signInBoxAllowsCommand,
|
|
265
|
+
signInBoxMode,
|
|
266
|
+
} from "../lib/signin-box";
|
|
267
|
+
import {
|
|
268
|
+
deleteProjectEnvStoreOnly,
|
|
269
|
+
listProjectEnvKeysStoreOnly,
|
|
270
|
+
purgeLocalUserCredentialSecrets,
|
|
271
|
+
reconcileUserCredentialMetadataFromStore,
|
|
272
|
+
setProjectEnvStoreOnly,
|
|
273
|
+
} from "../lib/signin-box-credentials";
|
|
274
|
+
import { loadGithubTokenIntoCache } from "../lib/github-tokens";
|
|
275
|
+
import { ensureSshKeyStoreFirst } from "../lib/ssh";
|
|
256
276
|
import { resumeSidecarTask } from "../lib/sidecar-resume";
|
|
257
277
|
import { declareAgentClisReady } from "../lib/standard-image";
|
|
258
278
|
|
|
@@ -438,6 +458,11 @@ interface PausableSource {
|
|
|
438
458
|
}
|
|
439
459
|
|
|
440
460
|
console.log(`[host-agent] starting host ${hostId}`);
|
|
461
|
+
if (signInBoxMode() && sidecarMode()) {
|
|
462
|
+
console.error("[host-agent] --signin-box and --task are exclusive; refusing to start");
|
|
463
|
+
process.exit(2);
|
|
464
|
+
}
|
|
465
|
+
if (signInBoxMode()) console.log("[host-agent] sign-in box mode (ADR-136): no tasks, no credential at rest");
|
|
441
466
|
// Self-heal the recurring silent admission gate BEFORE anything can refuse
|
|
442
467
|
// work: an uninstall/update helper that died at phase "checking" (live
|
|
443
468
|
// 2026-08-11 + twice 2026-08-18) left every taskUp/channelEnsure bouncing
|
|
@@ -526,9 +551,83 @@ const repushBrokeredCopies = async (kind: string): Promise<void> => {
|
|
|
526
551
|
await getOrchestrator().refreshEngineAccountCopies(kind);
|
|
527
552
|
};
|
|
528
553
|
configureTokenBroker({ repush: repushBrokeredCopies });
|
|
554
|
+
/**
|
|
555
|
+
* ADR-136 sign-in box start. Strip every credential a previous release or an
|
|
556
|
+
* operator left on this machine, then rebuild the workspace account LIST from
|
|
557
|
+
* parameter names and tags — never a value — before the first capability
|
|
558
|
+
* frame is built, so a replacement box does not advertise an empty list and
|
|
559
|
+
* overwrite the cloud's snapshot with it. Bounded: a store outage must not
|
|
560
|
+
* keep the box offline, only empty until the next start.
|
|
561
|
+
*/
|
|
562
|
+
const SIGN_IN_BOX_RECONCILE_TIMEOUT_MS = 30_000;
|
|
563
|
+
async function startSignInBox(): Promise<void> {
|
|
564
|
+
const secrets = purgeLocalEngineAccountSecrets();
|
|
565
|
+
const slots = purgeDefaultEngineSlots({
|
|
566
|
+
ownerHome: process.env.UAI_OWNER_HOME?.trim() || homedir(),
|
|
567
|
+
envFile: join(env.uaiHome, ".env.local"),
|
|
568
|
+
});
|
|
569
|
+
const users = purgeLocalUserCredentialSecrets();
|
|
570
|
+
const purged =
|
|
571
|
+
secrets.stripped +
|
|
572
|
+
secrets.removed +
|
|
573
|
+
slots.files.length +
|
|
574
|
+
slots.dirs.length +
|
|
575
|
+
slots.envKeys.length +
|
|
576
|
+
users.githubStripped +
|
|
577
|
+
users.sshStripped +
|
|
578
|
+
users.mcpStripped +
|
|
579
|
+
users.projectEnvRemoved +
|
|
580
|
+
users.unmirroredRemoved;
|
|
581
|
+
if (purged > 0 || secrets.errors > 0) {
|
|
582
|
+
console.warn(
|
|
583
|
+
`[signin-box] purged local credentials: ${secrets.stripped} account rows stripped, ${secrets.removed} removed, ` +
|
|
584
|
+
`${slots.files.length} default-slot files, ${slots.dirs.length} stray dirs, ${slots.envKeys.length} env keys, ` +
|
|
585
|
+
`${users.githubStripped} github, ${users.sshStripped} ssh, ${users.mcpStripped} mcp rows stripped, ` +
|
|
586
|
+
`${users.projectEnvRemoved} project env rows and ${users.unmirroredRemoved} unmirrored rows removed` +
|
|
587
|
+
(secrets.errors > 0 ? `, ${secrets.errors} errors` : ""),
|
|
588
|
+
);
|
|
589
|
+
}
|
|
590
|
+
let timer: NodeJS.Timeout | null = null;
|
|
591
|
+
const deadline = new Promise<"timeout">((resolve) => {
|
|
592
|
+
timer = setTimeout(() => resolve("timeout"), SIGN_IN_BOX_RECONCILE_TIMEOUT_MS);
|
|
593
|
+
timer.unref?.();
|
|
594
|
+
});
|
|
595
|
+
try {
|
|
596
|
+
const outcome = await Promise.race([
|
|
597
|
+
Promise.all([reconcileEngineAccountMetadataFromStore(), reconcileUserCredentialMetadataFromStore()]),
|
|
598
|
+
deadline,
|
|
599
|
+
]);
|
|
600
|
+
if (outcome === "timeout") {
|
|
601
|
+
console.warn("[signin-box] metadata reconcile did not finish in time; the lists fill on the next start");
|
|
602
|
+
} else {
|
|
603
|
+
const [accounts, users] = outcome;
|
|
604
|
+
if (!accounts.skipped) {
|
|
605
|
+
console.log(
|
|
606
|
+
`[signin-box] account metadata reconciled: ${accounts.imported} imported, ${accounts.removed} removed, ${accounts.errors} errors`,
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (!users.skipped) {
|
|
610
|
+
console.log(
|
|
611
|
+
`[signin-box] user credential metadata reconciled: ${users.imported} imported, ${users.removed} removed, ` +
|
|
612
|
+
`${users.tagged} legacy parameters tagged, ${users.unreadable} unreadable, ${users.errors} errors`,
|
|
613
|
+
);
|
|
614
|
+
}
|
|
615
|
+
}
|
|
616
|
+
} catch (error) {
|
|
617
|
+
console.warn(
|
|
618
|
+
`[signin-box] account metadata reconcile failed: ${error instanceof Error ? error.message : String(error)}`,
|
|
619
|
+
);
|
|
620
|
+
} finally {
|
|
621
|
+
if (timer) clearTimeout(timer);
|
|
622
|
+
}
|
|
623
|
+
}
|
|
529
624
|
// ADR-133: a sidecar never refreshes sign-ins (the broker Lambda does), and
|
|
530
625
|
// reads the store only once it holds a lease — see leaseManager below.
|
|
531
|
-
|
|
626
|
+
// ADR-136: the sign-in box imports nothing and runs no pickup — it holds no
|
|
627
|
+
// copy for a refresh to update.
|
|
628
|
+
if (signInBoxMode()) {
|
|
629
|
+
await startSignInBox();
|
|
630
|
+
} else if (!sidecarMode()) {
|
|
532
631
|
startTokenBrokerLoop();
|
|
533
632
|
startCredentialStorePickup({ repush: repushBrokeredCopies });
|
|
534
633
|
void reconcileStores();
|
|
@@ -621,6 +720,21 @@ const engineLoginManager = createEngineLoginManager({
|
|
|
621
720
|
// A grok sign-in may be the first grok configuration on this host: the
|
|
622
721
|
// standard image needs the CLI before its tasks can run (host 0.9.105).
|
|
623
722
|
ensureStandardImage: () => ensureStandardImage(),
|
|
723
|
+
// ADR-136 sign-in box: workspace accounts only, written to the store first
|
|
724
|
+
// and never sealed here.
|
|
725
|
+
workspaceOnly: signInBoxMode(),
|
|
726
|
+
...(signInBoxMode()
|
|
727
|
+
? {
|
|
728
|
+
seams: {
|
|
729
|
+
addClaudeAccount: (label, token, scope) =>
|
|
730
|
+
addEngineAccountToStore("claude", label, { token }, scope),
|
|
731
|
+
addCodexAccount: (label, authJson, scope) =>
|
|
732
|
+
addCapturedConfigDirAccountToStore("codex", label, authJson, scope),
|
|
733
|
+
addGrokAccount: (label, authJson, scope) =>
|
|
734
|
+
addCapturedConfigDirAccountToStore("grok", label, authJson, scope),
|
|
735
|
+
},
|
|
736
|
+
}
|
|
737
|
+
: {}),
|
|
624
738
|
});
|
|
625
739
|
// ADR-117: one-shot connect (pasted key / OpenCode re-detect) over the bridge.
|
|
626
740
|
const engineConnectManager = createEngineConnectManager({
|
|
@@ -628,6 +742,13 @@ const engineConnectManager = createEngineConnectManager({
|
|
|
628
742
|
refreshEngineAccountAgents: (engine) =>
|
|
629
743
|
getOrchestrator().refreshEngineAccountAgents(engine),
|
|
630
744
|
ensureStandardImage: () => ensureStandardImage(),
|
|
745
|
+
workspaceOnly: signInBoxMode(),
|
|
746
|
+
...(signInBoxMode()
|
|
747
|
+
? {
|
|
748
|
+
addLabeledAccount: (kind, label, secret, scope) =>
|
|
749
|
+
addEngineAccountToStore(kind, label, { token: secret }, scope),
|
|
750
|
+
}
|
|
751
|
+
: {}),
|
|
631
752
|
});
|
|
632
753
|
if (initialRuntime.status === "ready") {
|
|
633
754
|
console.log(
|
|
@@ -693,10 +814,10 @@ void connect();
|
|
|
693
814
|
// Best-effort: a UI bind failure must not take the host service down.
|
|
694
815
|
// A task machine has no operator sitting at it (ADR-133): no local UI, no
|
|
695
816
|
// Mac vnode watch.
|
|
696
|
-
if (!sidecarMode()) void startLocalUi();
|
|
817
|
+
if (!sidecarMode() && !signInBoxMode()) void startLocalUi();
|
|
697
818
|
// ADR-118 follow-up: vnode-pressure observability (macOS/apple-container
|
|
698
819
|
// hosts). Logs only on band transitions; the sensor caches its sysctl read.
|
|
699
|
-
if (!sidecarMode()) {
|
|
820
|
+
if (!sidecarMode() && !signInBoxMode()) {
|
|
700
821
|
const vnodePressureTimer = setInterval(() => void logVnodePressure(), 60_000);
|
|
701
822
|
vnodePressureTimer.unref?.();
|
|
702
823
|
void logVnodePressure();
|
|
@@ -1334,6 +1455,7 @@ async function connect(): Promise<void> {
|
|
|
1334
1455
|
const frame = parseCloudFrame(data);
|
|
1335
1456
|
if (!frame) return;
|
|
1336
1457
|
if (stopping && rejectFrameDuringShutdown(socket, frame)) return;
|
|
1458
|
+
if (signInBoxMode() && rejectFrameOnSignInBox(socket, frame)) return;
|
|
1337
1459
|
|
|
1338
1460
|
switch (frame.kind) {
|
|
1339
1461
|
case "pong":
|
|
@@ -1539,6 +1661,11 @@ async function connect(): Promise<void> {
|
|
|
1539
1661
|
});
|
|
1540
1662
|
},
|
|
1541
1663
|
};
|
|
1664
|
+
// ADR-136 sign-in box: the prior grant is not on disk; read it
|
|
1665
|
+
// into the cache so an account switch still revokes it remotely.
|
|
1666
|
+
if (signInBoxMode()) {
|
|
1667
|
+
await loadGithubTokenIntoCache(frame.userId).catch(() => false);
|
|
1668
|
+
}
|
|
1542
1669
|
let result: Awaited<ReturnType<typeof onConnectSet>>;
|
|
1543
1670
|
try {
|
|
1544
1671
|
result = await onConnectSet(
|
|
@@ -1704,6 +1831,11 @@ async function connect(): Promise<void> {
|
|
|
1704
1831
|
error: "stale GitHub credential generation",
|
|
1705
1832
|
};
|
|
1706
1833
|
}
|
|
1834
|
+
// ADR-136 sign-in box: the grant is not on disk; read it into
|
|
1835
|
+
// the cache so the disconnect still revokes it remotely.
|
|
1836
|
+
if (signInBoxMode()) {
|
|
1837
|
+
await loadGithubTokenIntoCache(frame.userId).catch(() => false);
|
|
1838
|
+
}
|
|
1707
1839
|
await onConnectClear(frame.userId, {
|
|
1708
1840
|
invalidateCredentials: invalidateTaskGithubGitCredentials,
|
|
1709
1841
|
reconcile: (taskId, userId) => {
|
|
@@ -1789,6 +1921,24 @@ async function connect(): Promise<void> {
|
|
|
1789
1921
|
);
|
|
1790
1922
|
break;
|
|
1791
1923
|
}
|
|
1924
|
+
if (frame.kind === "ssh.key.ensure" && signInBoxMode()) {
|
|
1925
|
+
// ADR-136 sign-in box: the keypair goes to the store first; only
|
|
1926
|
+
// the public half stays here.
|
|
1927
|
+
void managedMutationDrain.track(() =>
|
|
1928
|
+
ensureSshKeyStoreFirst(frame.userId).then(
|
|
1929
|
+
(publicKey) =>
|
|
1930
|
+
send(socket, { kind: "ssh.key.ack", userId: frame.userId, ok: true, publicKey }),
|
|
1931
|
+
(err) =>
|
|
1932
|
+
send(socket, {
|
|
1933
|
+
kind: "ssh.key.ack",
|
|
1934
|
+
userId: frame.userId,
|
|
1935
|
+
ok: false,
|
|
1936
|
+
error: err instanceof Error ? err.message : "ssh key op failed",
|
|
1937
|
+
}),
|
|
1938
|
+
),
|
|
1939
|
+
);
|
|
1940
|
+
break;
|
|
1941
|
+
}
|
|
1792
1942
|
try {
|
|
1793
1943
|
const publicKey =
|
|
1794
1944
|
frame.kind === "ssh.key.ensure"
|
|
@@ -1831,6 +1981,30 @@ async function connect(): Promise<void> {
|
|
|
1831
1981
|
case "env.var.list":
|
|
1832
1982
|
case "env.var.set":
|
|
1833
1983
|
case "env.var.delete": {
|
|
1984
|
+
if (signInBoxMode()) {
|
|
1985
|
+
// ADR-136 sign-in box: no local rows — every op is a store call,
|
|
1986
|
+
// and the ack lists names from the store.
|
|
1987
|
+
void managedMutationDrain.track(() =>
|
|
1988
|
+
(frame.kind === "env.var.set"
|
|
1989
|
+
? setProjectEnvStoreOnly(frame.projectId, frame.key, frame.value, frame.orgId)
|
|
1990
|
+
: frame.kind === "env.var.delete"
|
|
1991
|
+
? deleteProjectEnvStoreOnly(frame.projectId, frame.key)
|
|
1992
|
+
: Promise.resolve()
|
|
1993
|
+
)
|
|
1994
|
+
.then(() => listProjectEnvKeysStoreOnly(frame.projectId))
|
|
1995
|
+
.then(
|
|
1996
|
+
(keys) => send(socket, { kind: "env.var.ack", opId: frame.opId, ok: true, keys }),
|
|
1997
|
+
(err) =>
|
|
1998
|
+
send(socket, {
|
|
1999
|
+
kind: "env.var.ack",
|
|
2000
|
+
opId: frame.opId,
|
|
2001
|
+
ok: false,
|
|
2002
|
+
error: err instanceof Error ? err.message : "env var op failed",
|
|
2003
|
+
}),
|
|
2004
|
+
),
|
|
2005
|
+
);
|
|
2006
|
+
break;
|
|
2007
|
+
}
|
|
1834
2008
|
try {
|
|
1835
2009
|
if (frame.kind === "env.var.set") {
|
|
1836
2010
|
setProjectEnvVar(frame.projectId, frame.key, frame.value, frame.orgId);
|
|
@@ -3260,6 +3434,39 @@ function rejectFrameDuringShutdown(
|
|
|
3260
3434
|
}
|
|
3261
3435
|
}
|
|
3262
3436
|
|
|
3437
|
+
/**
|
|
3438
|
+
* ADR-136 sign-in box: the runner behind the "Uai Cloud" row runs no task.
|
|
3439
|
+
* Task commands and tunnel opens get a definite, non-retryable refusal —
|
|
3440
|
+
* nothing here will ever accept them — while the managed lifecycle, engine
|
|
3441
|
+
* sign-ins and account management pass through untouched.
|
|
3442
|
+
*/
|
|
3443
|
+
function rejectFrameOnSignInBox(socket: WebSocket, frame: CloudToHost): boolean {
|
|
3444
|
+
switch (frame.kind) {
|
|
3445
|
+
case "command":
|
|
3446
|
+
if (signInBoxAllowsCommand(frame.command)) return false;
|
|
3447
|
+
send(socket, {
|
|
3448
|
+
kind: "result",
|
|
3449
|
+
commandId: frame.commandId,
|
|
3450
|
+
result: {
|
|
3451
|
+
ok: false,
|
|
3452
|
+
code: HostErrorCode.HostUnavailable,
|
|
3453
|
+
message: SIGN_IN_BOX_TASK_REFUSAL,
|
|
3454
|
+
retryable: false,
|
|
3455
|
+
},
|
|
3456
|
+
});
|
|
3457
|
+
return true;
|
|
3458
|
+
case "tunnel.open":
|
|
3459
|
+
send(socket, {
|
|
3460
|
+
kind: "tunnel.close",
|
|
3461
|
+
tunnelId: frame.tunnelId,
|
|
3462
|
+
reason: SIGN_IN_BOX_TASK_REFUSAL,
|
|
3463
|
+
});
|
|
3464
|
+
return true;
|
|
3465
|
+
default:
|
|
3466
|
+
return false;
|
|
3467
|
+
}
|
|
3468
|
+
}
|
|
3469
|
+
|
|
3263
3470
|
function rawDataToBuffer(data: RawData): Buffer {
|
|
3264
3471
|
if (Buffer.isBuffer(data)) return data;
|
|
3265
3472
|
if (data instanceof ArrayBuffer) return Buffer.from(data);
|
package/src/protocol.ts
CHANGED
|
@@ -1489,27 +1489,33 @@ export interface HostCommands {
|
|
|
1489
1489
|
* exactly like the local API; re-advertises capabilities on success. */
|
|
1490
1490
|
engineAccountRemove(
|
|
1491
1491
|
ctx: CommandContext,
|
|
1492
|
-
input: { id: string; scope?: string },
|
|
1492
|
+
input: { id: string; scope?: string; hostId?: string },
|
|
1493
1493
|
): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
|
|
1494
1494
|
/** Manage-v1: relabel an extra account (never `default:*`); a scope may
|
|
1495
1495
|
* rename only its own accounts. Re-advertises on success. */
|
|
1496
1496
|
engineAccountRename(
|
|
1497
1497
|
ctx: CommandContext,
|
|
1498
|
-
input: { id: string; label: string; scope?: string },
|
|
1498
|
+
input: { id: string; label: string; scope?: string; hostId?: string },
|
|
1499
1499
|
): Promise<HostCommandResult<{ renamed: boolean; message?: string }>>;
|
|
1500
1500
|
/** Manage-v1: set the priority order of a kind's extra accounts within a
|
|
1501
1501
|
* scope — `ids` must be exactly that set. Tasks pick the first healthy
|
|
1502
1502
|
* account in this order. Re-advertises on success. */
|
|
1503
1503
|
engineAccountReorder(
|
|
1504
1504
|
ctx: CommandContext,
|
|
1505
|
-
input: { kind: string; ids: string[]; scope?: string },
|
|
1505
|
+
input: { kind: string; ids: string[]; scope?: string; hostId?: string },
|
|
1506
1506
|
): Promise<HostCommandResult<{ reordered: boolean; message?: string }>>;
|
|
1507
1507
|
/** Disconnect-v1: forget the host's DEFAULT credential for an engine — the
|
|
1508
1508
|
* .env.local key and/or the login file the adapter detects. Extras are
|
|
1509
|
-
* untouched (remove them by id). Re-advertises on success.
|
|
1509
|
+
* untouched (remove them by id). Re-advertises on success.
|
|
1510
|
+
*
|
|
1511
|
+
* `hostId` on the four account commands above: the runner the account lives
|
|
1512
|
+
* on. The cloud's bridge routes by it (2026-09-14: without it these fell to
|
|
1513
|
+
* the any-open-host fallback and landed on whichever runner connected
|
|
1514
|
+
* first — the label step after a sign-in answered "That account no longer
|
|
1515
|
+
* exists" and Disconnect did nothing). The host ignores the field. */
|
|
1510
1516
|
engineDisconnect(
|
|
1511
1517
|
ctx: CommandContext,
|
|
1512
|
-
input: { engine: EngineConnectKind },
|
|
1518
|
+
input: { engine: EngineConnectKind; hostId?: string },
|
|
1513
1519
|
): Promise<HostCommandResult<{ disconnected: boolean; message?: string }>>;
|
|
1514
1520
|
cloneRepo(
|
|
1515
1521
|
ctx: CommandContext,
|