@runuai/host 0.9.98 → 0.9.100
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/db/migrations/0017_engine_account_scope.sql +7 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +7 -0
- package/lib/engine-accounts.ts +120 -11
- package/lib/engine-connect.ts +24 -2
- package/lib/engine-login.ts +46 -8
- package/lib/orchestrator.ts +40 -19
- package/lib/runtime-state.ts +2 -0
- package/package.json +1 -1
- package/scripts/install/darwin.ts +90 -8
- package/src/activation-respawn.ts +155 -0
- package/src/cli.ts +33 -0
- package/src/index.ts +3 -1
- package/src/machine-tunnel-route.ts +26 -6
- package/src/main.ts +65 -7
- package/src/protocol.ts +46 -5
- package/src/tunnel-dial.ts +102 -0
|
@@ -0,0 +1,7 @@
|
|
|
1
|
+
-- ADR-131 Decision 3: engine accounts carry a scope — 'host' (the owner's,
|
|
2
|
+
-- today's rows) or 'org:<orgId>' (a workspace's account on a shared host).
|
|
3
|
+
-- Tasks resolve accounts by their scope; org scopes never see the default
|
|
4
|
+
-- slot or the host's rows.
|
|
5
|
+
ALTER TABLE `host_engine_accounts` ADD `scope` text NOT NULL DEFAULT 'host';--> statement-breakpoint
|
|
6
|
+
CREATE INDEX `host_engine_accounts_scope_kind_idx` ON `host_engine_accounts` (`scope`,`kind`);--> statement-breakpoint
|
|
7
|
+
ALTER TABLE `uai_host_tasks` ADD `engine_account_scope` text;
|
|
@@ -120,6 +120,13 @@
|
|
|
120
120
|
"when": 1786406402000,
|
|
121
121
|
"tag": "0016_task_environment",
|
|
122
122
|
"breakpoints": true
|
|
123
|
+
},
|
|
124
|
+
{
|
|
125
|
+
"idx": 17,
|
|
126
|
+
"version": "6",
|
|
127
|
+
"when": 1788820000000,
|
|
128
|
+
"tag": "0017_engine_account_scope",
|
|
129
|
+
"breakpoints": true
|
|
123
130
|
}
|
|
124
131
|
]
|
|
125
132
|
}
|
package/db/schema.ts
CHANGED
|
@@ -37,6 +37,10 @@ export const hostTasks = sqliteTable(
|
|
|
37
37
|
ownerEmail: text("owner_email"),
|
|
38
38
|
ownerName: text("owner_name"),
|
|
39
39
|
projectSlugs: text("project_slugs").notNull().default("[]"),
|
|
40
|
+
// ADR-131 D3: which engine-account scope this task resolves credentials
|
|
41
|
+
// from ("org:<orgId>" on a shared host; null = "host"). Persisted so a
|
|
42
|
+
// host restart re-provisions the same scope before the cloud re-sends it.
|
|
43
|
+
engineAccountScope: text("engine_account_scope"),
|
|
40
44
|
lockedAt: integer("locked_at", { mode: "number" }),
|
|
41
45
|
startedAt: integer("started_at", { mode: "number" }),
|
|
42
46
|
endedAt: integer("ended_at", { mode: "number" }),
|
|
@@ -271,6 +275,9 @@ export const engineAccounts = sqliteTable("host_engine_accounts", {
|
|
|
271
275
|
authKind: text("auth_kind").notNull(), // "env" | "config-dir"
|
|
272
276
|
secretEnc: text("secret_enc"), // sealed JSON (env kind); null for config-dir
|
|
273
277
|
configDir: text("config_dir"), // host dir (config-dir kind); null for env
|
|
278
|
+
// ADR-131 D3: "host" (owner-level, default) or "org:<orgId>" (a workspace's
|
|
279
|
+
// account on a shared host). Resolution is by exact scope.
|
|
280
|
+
scope: text("scope").notNull().default("host"),
|
|
274
281
|
createdAt: integer("created_at", { mode: "number" }).notNull(),
|
|
275
282
|
});
|
|
276
283
|
|
package/lib/engine-accounts.ts
CHANGED
|
@@ -22,6 +22,10 @@
|
|
|
22
22
|
* caps reset over time).
|
|
23
23
|
*/
|
|
24
24
|
|
|
25
|
+
import {
|
|
26
|
+
HOST_ACCOUNT_SCOPE,
|
|
27
|
+
isEngineAccountScope,
|
|
28
|
+
} from "../src/protocol";
|
|
25
29
|
import { chmodSync, existsSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
26
30
|
import { homedir } from "node:os";
|
|
27
31
|
import { join } from "node:path";
|
|
@@ -85,6 +89,8 @@ export interface EngineAccountInfo {
|
|
|
85
89
|
label: string;
|
|
86
90
|
authKind: AccountAuthKind;
|
|
87
91
|
isDefault: boolean;
|
|
92
|
+
/** ADR-131 D3: "host" or "org:<orgId>". */
|
|
93
|
+
scope: string;
|
|
88
94
|
}
|
|
89
95
|
|
|
90
96
|
/** A fully-resolved account, ready to inject / provision (host-trusted; the
|
|
@@ -94,6 +100,8 @@ export interface ResolvedEngineAccount {
|
|
|
94
100
|
kind: string;
|
|
95
101
|
label: string;
|
|
96
102
|
isDefault: boolean;
|
|
103
|
+
/** ADR-131 D3: "host" or "org:<orgId>". */
|
|
104
|
+
scope: string;
|
|
97
105
|
authKind: AccountAuthKind;
|
|
98
106
|
/** Env merged into the agent's docker exec. Empty for the default env
|
|
99
107
|
* account (the ambient `.env.local` credential is account #1). */
|
|
@@ -141,6 +149,27 @@ function unpackSecret(enc: string): string {
|
|
|
141
149
|
return openAesGcm(ct, nonce);
|
|
142
150
|
}
|
|
143
151
|
|
|
152
|
+
// ---------------------------------------------------------------------------
|
|
153
|
+
// Scopes (ADR-131 Decision 3).
|
|
154
|
+
// ---------------------------------------------------------------------------
|
|
155
|
+
|
|
156
|
+
/**
|
|
157
|
+
* Every account has a scope. `host` is the owner's: the default slot plus
|
|
158
|
+
* every extra account connected before scopes existed (and every one an
|
|
159
|
+
* owner connects on a BYO/dedicated host). `org:<orgId>` is a workspace's
|
|
160
|
+
* account on a shared host: resolved ONLY for that workspace's tasks, and
|
|
161
|
+
* never joined by the default slot — a tenant sees exactly what it connected.
|
|
162
|
+
*/
|
|
163
|
+
export { HOST_ACCOUNT_SCOPE, isEngineAccountScope };
|
|
164
|
+
|
|
165
|
+
/** A row's scope (legacy rows read as `host`) matches the requested one. */
|
|
166
|
+
export function accountInScope(
|
|
167
|
+
rowScope: string | null | undefined,
|
|
168
|
+
scope: string,
|
|
169
|
+
): boolean {
|
|
170
|
+
return (rowScope ?? HOST_ACCOUNT_SCOPE) === scope;
|
|
171
|
+
}
|
|
172
|
+
|
|
144
173
|
// ---------------------------------------------------------------------------
|
|
145
174
|
// Resolution.
|
|
146
175
|
// ---------------------------------------------------------------------------
|
|
@@ -177,6 +206,7 @@ function synthesizeDefault(
|
|
|
177
206
|
label: "Default",
|
|
178
207
|
isDefault: true,
|
|
179
208
|
authKind: "env",
|
|
209
|
+
scope: HOST_ACCOUNT_SCOPE,
|
|
180
210
|
execEnv: {},
|
|
181
211
|
};
|
|
182
212
|
}
|
|
@@ -187,6 +217,7 @@ function synthesizeDefault(
|
|
|
187
217
|
label: "Default",
|
|
188
218
|
isDefault: true,
|
|
189
219
|
authKind: "config-dir",
|
|
220
|
+
scope: HOST_ACCOUNT_SCOPE,
|
|
190
221
|
execEnv: d.selectVar ? { [d.selectVar]: containerDir } : {},
|
|
191
222
|
configDir: {
|
|
192
223
|
hostDir: (d.defaultHostDir as (o: string) => string)(s.ownerHome()),
|
|
@@ -196,14 +227,22 @@ function synthesizeDefault(
|
|
|
196
227
|
}
|
|
197
228
|
|
|
198
229
|
/** DB extras for a kind, resolved into injectable form. */
|
|
199
|
-
function resolveExtras(
|
|
230
|
+
function resolveExtras(
|
|
231
|
+
kind: string,
|
|
232
|
+
s: AccountSeams,
|
|
233
|
+
scope: string,
|
|
234
|
+
): ResolvedEngineAccount[] {
|
|
200
235
|
const d = KINDS[kind];
|
|
201
236
|
if (!d) return [];
|
|
202
237
|
const rows = getDb()
|
|
203
238
|
.select()
|
|
204
239
|
.from(schema.engineAccounts)
|
|
205
240
|
.all()
|
|
206
|
-
.filter(
|
|
241
|
+
.filter(
|
|
242
|
+
(r) =>
|
|
243
|
+
r.kind === kind &&
|
|
244
|
+
accountInScope((r as { scope?: string | null }).scope, scope),
|
|
245
|
+
);
|
|
207
246
|
const out: ResolvedEngineAccount[] = [];
|
|
208
247
|
for (const row of rows) {
|
|
209
248
|
if (row.authKind === "env") {
|
|
@@ -226,6 +265,7 @@ function resolveExtras(kind: string, s: AccountSeams): ResolvedEngineAccount[] {
|
|
|
226
265
|
label: row.label,
|
|
227
266
|
isDefault: false,
|
|
228
267
|
authKind: "env",
|
|
268
|
+
scope,
|
|
229
269
|
execEnv,
|
|
230
270
|
});
|
|
231
271
|
} else if (row.authKind === "config-dir" && row.configDir && d.selectVar) {
|
|
@@ -236,6 +276,7 @@ function resolveExtras(kind: string, s: AccountSeams): ResolvedEngineAccount[] {
|
|
|
236
276
|
label: row.label,
|
|
237
277
|
isDefault: false,
|
|
238
278
|
authKind: "config-dir",
|
|
279
|
+
scope,
|
|
239
280
|
execEnv: { [d.selectVar]: containerDir },
|
|
240
281
|
configDir: { hostDir: row.configDir, containerDir },
|
|
241
282
|
});
|
|
@@ -248,12 +289,16 @@ function resolveExtras(kind: string, s: AccountSeams): ResolvedEngineAccount[] {
|
|
|
248
289
|
export function resolveEngineAccounts(
|
|
249
290
|
kind: string,
|
|
250
291
|
seams: Partial<AccountSeams> = {},
|
|
292
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
251
293
|
): ResolvedEngineAccount[] {
|
|
252
294
|
const s = withDefaults(seams);
|
|
253
295
|
const accounts: ResolvedEngineAccount[] = [];
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
|
|
296
|
+
// The default slot is the OWNER's credential: it joins the host scope only.
|
|
297
|
+
if (scope === HOST_ACCOUNT_SCOPE) {
|
|
298
|
+
const def = synthesizeDefault(kind, s);
|
|
299
|
+
if (def) accounts.push(def);
|
|
300
|
+
}
|
|
301
|
+
accounts.push(...resolveExtras(kind, s, scope));
|
|
257
302
|
return accounts;
|
|
258
303
|
}
|
|
259
304
|
|
|
@@ -261,22 +306,47 @@ export function resolveEngineAccounts(
|
|
|
261
306
|
export function listEngineAccounts(
|
|
262
307
|
kind: string,
|
|
263
308
|
seams: Partial<AccountSeams> = {},
|
|
309
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
264
310
|
): EngineAccountInfo[] {
|
|
265
|
-
return resolveEngineAccounts(kind, seams).map((a) => ({
|
|
311
|
+
return resolveEngineAccounts(kind, seams, scope).map((a) => ({
|
|
266
312
|
id: a.id,
|
|
267
313
|
kind: a.kind,
|
|
268
314
|
label: a.label,
|
|
269
315
|
authKind: a.authKind,
|
|
270
316
|
isDefault: a.isDefault,
|
|
317
|
+
scope: a.scope,
|
|
271
318
|
}));
|
|
272
319
|
}
|
|
273
320
|
|
|
321
|
+
/** Every scope holding at least one stored account for the kind, plus
|
|
322
|
+
* `host` (which may consist of the default slot alone). */
|
|
323
|
+
export function engineAccountScopes(kind: string): string[] {
|
|
324
|
+
const scopes = new Set<string>([HOST_ACCOUNT_SCOPE]);
|
|
325
|
+
for (const row of getDb().select().from(schema.engineAccounts).all()) {
|
|
326
|
+
if (row.kind !== kind) continue;
|
|
327
|
+
scopes.add((row as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE);
|
|
328
|
+
}
|
|
329
|
+
return [...scopes];
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
/** Account list across ALL scopes — the capabilities advertisement. The
|
|
333
|
+
* cloud filters per caller; labels only, never credentials. */
|
|
334
|
+
export function listEngineAccountsAllScopes(
|
|
335
|
+
kind: string,
|
|
336
|
+
seams: Partial<AccountSeams> = {},
|
|
337
|
+
): EngineAccountInfo[] {
|
|
338
|
+
return engineAccountScopes(kind).flatMap((scope) =>
|
|
339
|
+
listEngineAccounts(kind, seams, scope),
|
|
340
|
+
);
|
|
341
|
+
}
|
|
342
|
+
|
|
274
343
|
/** How many accounts a kind has right now (default + extras). */
|
|
275
344
|
export function engineAccountCount(
|
|
276
345
|
kind: string,
|
|
277
346
|
seams: Partial<AccountSeams> = {},
|
|
347
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
278
348
|
): number {
|
|
279
|
-
return resolveEngineAccounts(kind, seams).length;
|
|
349
|
+
return resolveEngineAccounts(kind, seams, scope).length;
|
|
280
350
|
}
|
|
281
351
|
|
|
282
352
|
// ---------------------------------------------------------------------------
|
|
@@ -339,9 +409,19 @@ export function selectAccount(
|
|
|
339
409
|
/** Pick an account for a kind (default + extras), applying rotation state. */
|
|
340
410
|
export function pickEngineAccount(
|
|
341
411
|
kind: string,
|
|
342
|
-
opts: {
|
|
412
|
+
opts: {
|
|
413
|
+
exclude?: Set<string>;
|
|
414
|
+
seams?: Partial<AccountSeams>;
|
|
415
|
+
now?: number;
|
|
416
|
+
/** ADR-131 D3: the task's scope; default = the host's own accounts. */
|
|
417
|
+
scope?: string;
|
|
418
|
+
} = {},
|
|
343
419
|
): ResolvedEngineAccount | null {
|
|
344
|
-
const accounts = resolveEngineAccounts(
|
|
420
|
+
const accounts = resolveEngineAccounts(
|
|
421
|
+
kind,
|
|
422
|
+
opts.seams ?? {},
|
|
423
|
+
opts.scope ?? HOST_ACCOUNT_SCOPE,
|
|
424
|
+
);
|
|
345
425
|
return selectAccount(accounts, { exclude: opts.exclude, now: opts.now });
|
|
346
426
|
}
|
|
347
427
|
|
|
@@ -495,6 +575,7 @@ export async function provisionEngineAccounts(
|
|
|
495
575
|
kinds: Iterable<string>,
|
|
496
576
|
seams: Partial<AccountSeams> = {},
|
|
497
577
|
environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
|
|
578
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
498
579
|
): Promise<ProvisionEngineAccountsResult> {
|
|
499
580
|
const s = withDefaults(seams);
|
|
500
581
|
const seen = new Set<string>();
|
|
@@ -504,7 +585,7 @@ export async function provisionEngineAccounts(
|
|
|
504
585
|
for (const kind of kinds) {
|
|
505
586
|
if (seen.has(kind)) continue;
|
|
506
587
|
seen.add(kind);
|
|
507
|
-
for (const account of resolveEngineAccounts(kind, s)) {
|
|
588
|
+
for (const account of resolveEngineAccounts(kind, s, scope)) {
|
|
508
589
|
if (account.isDefault || !account.configDir) continue;
|
|
509
590
|
if (!s.fileExists(account.configDir.hostDir)) {
|
|
510
591
|
failed += 1;
|
|
@@ -554,8 +635,12 @@ export function addEngineAccount(
|
|
|
554
635
|
label: string,
|
|
555
636
|
creds: { apiKey?: string; token?: string },
|
|
556
637
|
seams: Partial<AccountSeams> = {},
|
|
638
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
557
639
|
): AddAccountResult {
|
|
558
640
|
const s = withDefaults(seams);
|
|
641
|
+
if (!isEngineAccountScope(scope)) {
|
|
642
|
+
return { ok: false, message: "The account scope is invalid." };
|
|
643
|
+
}
|
|
559
644
|
const trimmedLabel = label.trim();
|
|
560
645
|
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
561
646
|
const secret = (creds.token ?? creds.apiKey ?? "").trim();
|
|
@@ -581,6 +666,7 @@ export function addEngineAccount(
|
|
|
581
666
|
secretEnc: packSecret(JSON.stringify({ [envVar]: secret })),
|
|
582
667
|
configDir: null,
|
|
583
668
|
createdAt: now,
|
|
669
|
+
scope,
|
|
584
670
|
})
|
|
585
671
|
.run();
|
|
586
672
|
return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
|
|
@@ -621,6 +707,7 @@ export function addEngineAccount(
|
|
|
621
707
|
secretEnc: null,
|
|
622
708
|
configDir: hostDir,
|
|
623
709
|
createdAt: now,
|
|
710
|
+
scope,
|
|
624
711
|
})
|
|
625
712
|
.run();
|
|
626
713
|
return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
|
|
@@ -642,7 +729,11 @@ export function addCodexAccountFromAuthJson(
|
|
|
642
729
|
label: string,
|
|
643
730
|
authJson: string,
|
|
644
731
|
seams: Partial<AccountSeams> = {},
|
|
732
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
645
733
|
): AddAccountResult {
|
|
734
|
+
if (!isEngineAccountScope(scope)) {
|
|
735
|
+
return { ok: false, message: "The account scope is invalid." };
|
|
736
|
+
}
|
|
646
737
|
const s = withDefaults(seams);
|
|
647
738
|
const trimmedLabel = label.trim();
|
|
648
739
|
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
@@ -690,6 +781,7 @@ export function addCodexAccountFromAuthJson(
|
|
|
690
781
|
secretEnc: null,
|
|
691
782
|
configDir: hostDir,
|
|
692
783
|
createdAt: Date.now(),
|
|
784
|
+
scope,
|
|
693
785
|
})
|
|
694
786
|
.run();
|
|
695
787
|
return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
|
|
@@ -698,7 +790,19 @@ export function addCodexAccountFromAuthJson(
|
|
|
698
790
|
/** Remove an EXTRA account (idempotent). The synthesized default can't be
|
|
699
791
|
* removed here (use the engine disconnect flow for the legacy slot). The
|
|
700
792
|
* isolated host config dir of a config-dir account is deleted too. */
|
|
701
|
-
|
|
793
|
+
/** Whether a caller acting in `scope` may remove `row`. `undefined` is the
|
|
794
|
+
* host's own local UI, which may remove anything. */
|
|
795
|
+
export function accountRemovableInScope(
|
|
796
|
+
row: { scope?: string | null },
|
|
797
|
+
scope: string | undefined,
|
|
798
|
+
): boolean {
|
|
799
|
+
return scope === undefined || accountInScope(row.scope, scope);
|
|
800
|
+
}
|
|
801
|
+
|
|
802
|
+
export function removeEngineAccount(
|
|
803
|
+
id: string,
|
|
804
|
+
scope?: string,
|
|
805
|
+
): { ok: boolean; message: string } {
|
|
702
806
|
if (id.startsWith("default:")) {
|
|
703
807
|
return {
|
|
704
808
|
ok: false,
|
|
@@ -711,6 +815,11 @@ export function removeEngineAccount(id: string): { ok: boolean; message: string
|
|
|
711
815
|
.where(eq(schema.engineAccounts.id, id))
|
|
712
816
|
.get();
|
|
713
817
|
if (!row) return { ok: true, message: "Account already removed." };
|
|
818
|
+
// ADR-131 D3: a workspace removes only its own accounts — never the host's
|
|
819
|
+
// and never another workspace's, whatever id it names.
|
|
820
|
+
if (!accountRemovableInScope(row, scope)) {
|
|
821
|
+
return { ok: false, message: "That account belongs to a different scope." };
|
|
822
|
+
}
|
|
714
823
|
getDb().delete(schema.engineAccounts).where(eq(schema.engineAccounts.id, id)).run();
|
|
715
824
|
if (row.authKind === "config-dir" && row.configDir) {
|
|
716
825
|
try {
|
package/lib/engine-connect.ts
CHANGED
|
@@ -10,7 +10,9 @@
|
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
12
|
import {
|
|
13
|
+
HOST_ACCOUNT_SCOPE,
|
|
13
14
|
isEngineAccountLabel,
|
|
15
|
+
isEngineAccountScope,
|
|
14
16
|
isHostOpId,
|
|
15
17
|
MAX_ENGINE_CONNECT_LINE_CHARS,
|
|
16
18
|
MAX_ENGINE_CONNECT_SECRET_CHARS,
|
|
@@ -57,8 +59,10 @@ export class EngineConnectManager {
|
|
|
57
59
|
secret: string | undefined,
|
|
58
60
|
label: string | undefined,
|
|
59
61
|
emit: EmitEngineConnectEvent,
|
|
62
|
+
scope: string | undefined = undefined,
|
|
60
63
|
): boolean {
|
|
61
64
|
if (!isHostOpId(opId) || this.operations.has(opId)) return false;
|
|
65
|
+
if (scope !== undefined && !isEngineAccountScope(scope)) return false;
|
|
62
66
|
if (
|
|
63
67
|
secret !== undefined &&
|
|
64
68
|
(secret.length < 1 || secret.length > MAX_ENGINE_CONNECT_SECRET_CHARS)
|
|
@@ -93,7 +97,7 @@ export class EngineConnectManager {
|
|
|
93
97
|
this.finish(operation, false, "Engine connect timed out.");
|
|
94
98
|
}, CONNECT_TIMEOUT_MS);
|
|
95
99
|
operation.timer.unref?.();
|
|
96
|
-
void this.run(operation, secret, label).catch((error: unknown) => {
|
|
100
|
+
void this.run(operation, secret, label, scope).catch((error: unknown) => {
|
|
97
101
|
this.finish(
|
|
98
102
|
operation,
|
|
99
103
|
false,
|
|
@@ -118,6 +122,7 @@ export class EngineConnectManager {
|
|
|
118
122
|
operation: ConnectOperation,
|
|
119
123
|
secret: string | undefined,
|
|
120
124
|
label: string | undefined,
|
|
125
|
+
scope: string | undefined,
|
|
121
126
|
): Promise<void> {
|
|
122
127
|
const { engine } = operation;
|
|
123
128
|
const emitLine = (line: string): void => {
|
|
@@ -153,11 +158,28 @@ export class EngineConnectManager {
|
|
|
153
158
|
|
|
154
159
|
// ADR-116 labeled extra account — the sink validates kind support.
|
|
155
160
|
if (label !== undefined && secret !== undefined) {
|
|
156
|
-
const added = addEngineAccount(
|
|
161
|
+
const added = addEngineAccount(
|
|
162
|
+
engine,
|
|
163
|
+
label,
|
|
164
|
+
{ token: secret },
|
|
165
|
+
{},
|
|
166
|
+
scope ?? HOST_ACCOUNT_SCOPE,
|
|
167
|
+
);
|
|
157
168
|
if (added.ok) this.afterMutation(engine, false);
|
|
158
169
|
this.finish(operation, added.ok, added.message);
|
|
159
170
|
return;
|
|
160
171
|
}
|
|
172
|
+
// ADR-131 D3: everything below writes the host's DEFAULT slot or re-detects
|
|
173
|
+
// a host-side login — the owner's credential, never a workspace's. A
|
|
174
|
+
// tenant scope must not reach it.
|
|
175
|
+
if (scope !== undefined && scope !== HOST_ACCOUNT_SCOPE) {
|
|
176
|
+
this.finish(
|
|
177
|
+
operation,
|
|
178
|
+
false,
|
|
179
|
+
"Workspace accounts need a label and a pasted secret; the host's own sign-in is not available to workspaces.",
|
|
180
|
+
);
|
|
181
|
+
return;
|
|
182
|
+
}
|
|
161
183
|
|
|
162
184
|
const result = await connectEngine(
|
|
163
185
|
engine as EngineKind,
|
package/lib/engine-login.ts
CHANGED
|
@@ -27,7 +27,9 @@ import { homedir, tmpdir } from "node:os";
|
|
|
27
27
|
import { basename, dirname, join, resolve } from "node:path";
|
|
28
28
|
|
|
29
29
|
import {
|
|
30
|
+
HOST_ACCOUNT_SCOPE,
|
|
30
31
|
isEngineAccountLabel,
|
|
32
|
+
isEngineAccountScope,
|
|
31
33
|
isHostOpId,
|
|
32
34
|
MAX_ENGINE_LOGIN_QUERY_CHARS,
|
|
33
35
|
MAX_ENGINE_LOGIN_TEXT_CHARS,
|
|
@@ -145,8 +147,16 @@ export interface EngineLoginSeams {
|
|
|
145
147
|
persistClaudeToken(token: string): Promise<void>;
|
|
146
148
|
persistCodexAuth(sourceFile: string): Promise<void>;
|
|
147
149
|
/** ADR-116: labeled extra-account captures (never the default slots). */
|
|
148
|
-
addClaudeAccount(
|
|
149
|
-
|
|
150
|
+
addClaudeAccount(
|
|
151
|
+
label: string,
|
|
152
|
+
token: string,
|
|
153
|
+
scope: string,
|
|
154
|
+
): { ok: boolean; message: string };
|
|
155
|
+
addCodexAccount(
|
|
156
|
+
label: string,
|
|
157
|
+
authJson: string,
|
|
158
|
+
scope: string,
|
|
159
|
+
): { ok: boolean; message: string };
|
|
150
160
|
refreshEngineAccountAgents(engine: "claude"): Promise<void>;
|
|
151
161
|
reinjectCodexRunningTasks(): Promise<void>;
|
|
152
162
|
ownerHome(): string;
|
|
@@ -209,6 +219,8 @@ interface LoginOperation {
|
|
|
209
219
|
/** ADR-116: when set, the captured credential lands in a labeled extra
|
|
210
220
|
* account instead of the default slot. */
|
|
211
221
|
accountLabel: string | null;
|
|
222
|
+
/** ADR-131 D3: the scope a labeled capture is stored under. */
|
|
223
|
+
accountScope: string;
|
|
212
224
|
/** Diagnosis breadcrumb: has ANY output arrived after the one-time code
|
|
213
225
|
* was written? Distinguishes "stdin never delivered" from "CLI answered
|
|
214
226
|
* and we ignored it" without reading the PTY transcript. */
|
|
@@ -299,10 +311,10 @@ function defaultSeams(options: EngineLoginManagerOptions): EngineLoginSeams {
|
|
|
299
311
|
},
|
|
300
312
|
persistClaudeToken: (token) => persistClaudeTokenAtomic(token),
|
|
301
313
|
persistCodexAuth: (sourceFile) => persistCodexAuthAtomic(sourceFile),
|
|
302
|
-
addClaudeAccount: (label, token) =>
|
|
303
|
-
addEngineAccount("claude", label, { token }),
|
|
304
|
-
addCodexAccount: (label, authJson) =>
|
|
305
|
-
addCodexAccountFromAuthJson(label, authJson),
|
|
314
|
+
addClaudeAccount: (label, token, scope) =>
|
|
315
|
+
addEngineAccount("claude", label, { token }, {}, scope),
|
|
316
|
+
addCodexAccount: (label, authJson, scope) =>
|
|
317
|
+
addCodexAccountFromAuthJson(label, authJson, {}, scope),
|
|
306
318
|
refreshEngineAccountAgents: options.refreshEngineAccountAgents,
|
|
307
319
|
reinjectCodexRunningTasks: () => reinjectCodexRunningTasks(),
|
|
308
320
|
ownerHome,
|
|
@@ -370,8 +382,25 @@ export class EngineLoginManager {
|
|
|
370
382
|
engine: EngineLoginKind,
|
|
371
383
|
emit: EmitEngineLoginEvent,
|
|
372
384
|
label?: string,
|
|
385
|
+
scope?: string,
|
|
373
386
|
): boolean {
|
|
374
387
|
if (!isHostOpId(opId)) return false;
|
|
388
|
+
if (scope !== undefined && !isEngineAccountScope(scope)) {
|
|
389
|
+
safeEmit(
|
|
390
|
+
emit,
|
|
391
|
+
failedEvent(opId, engine, "start_failed", "The account scope is invalid."),
|
|
392
|
+
);
|
|
393
|
+
return false;
|
|
394
|
+
}
|
|
395
|
+
// A workspace's sign-in is always a labeled account: the default slot is
|
|
396
|
+
// the host owner's and must never be written on a tenant's behalf.
|
|
397
|
+
if (scope !== undefined && scope !== HOST_ACCOUNT_SCOPE && label === undefined) {
|
|
398
|
+
safeEmit(
|
|
399
|
+
emit,
|
|
400
|
+
failedEvent(opId, engine, "start_failed", "A workspace account needs a label."),
|
|
401
|
+
);
|
|
402
|
+
return false;
|
|
403
|
+
}
|
|
375
404
|
if (label !== undefined && !isEngineAccountLabel(label)) {
|
|
376
405
|
safeEmit(
|
|
377
406
|
emit,
|
|
@@ -426,6 +455,7 @@ export class EngineLoginManager {
|
|
|
426
455
|
pendingInput: null,
|
|
427
456
|
callbackTarget: null,
|
|
428
457
|
accountLabel: label ?? null,
|
|
458
|
+
accountScope: scope ?? HOST_ACCOUNT_SCOPE,
|
|
429
459
|
postInputOutputSeen: false,
|
|
430
460
|
};
|
|
431
461
|
this.operations.set(opId, operation);
|
|
@@ -941,7 +971,11 @@ export class EngineLoginManager {
|
|
|
941
971
|
// ADR-116: labeled capture — an extra account, not the default slot.
|
|
942
972
|
// Extras are picked up at the next agent spawn / rotation, so no
|
|
943
973
|
// running-agent refresh is needed (mirrors the local accounts API).
|
|
944
|
-
const added = this.seams.addClaudeAccount(
|
|
974
|
+
const added = this.seams.addClaudeAccount(
|
|
975
|
+
operation.accountLabel,
|
|
976
|
+
token,
|
|
977
|
+
operation.accountScope,
|
|
978
|
+
);
|
|
945
979
|
terminal = added.ok
|
|
946
980
|
? {
|
|
947
981
|
kind: "engine.login.event",
|
|
@@ -1011,7 +1045,11 @@ export class EngineLoginManager {
|
|
|
1011
1045
|
join(tempDir, "codex", "auth.json"),
|
|
1012
1046
|
"utf8",
|
|
1013
1047
|
);
|
|
1014
|
-
added = this.seams.addCodexAccount(
|
|
1048
|
+
added = this.seams.addCodexAccount(
|
|
1049
|
+
operation.accountLabel,
|
|
1050
|
+
contents,
|
|
1051
|
+
operation.accountScope,
|
|
1052
|
+
);
|
|
1015
1053
|
} catch {
|
|
1016
1054
|
added = {
|
|
1017
1055
|
ok: false,
|