@runuai/host 0.9.99 → 0.9.101
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/agents/cursor.ts +6 -1
- package/lib/agents/registry.ts +20 -1
- package/lib/engine-accounts.ts +180 -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/src/index.ts +3 -1
- package/src/main.ts +26 -5
- package/src/protocol.ts +48 -6
|
@@ -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/agents/cursor.ts
CHANGED
|
@@ -192,7 +192,12 @@ export class CursorSession implements AgentSession {
|
|
|
192
192
|
this.agentEnv = args.agentEnv ?? {};
|
|
193
193
|
this.spawner =
|
|
194
194
|
args.spawner ?? ((request) => spawnEnvironmentProcess(this.environment, request));
|
|
195
|
-
|
|
195
|
+
// An account-bound key (ADR-076/ADR-131 D3, injected through agentEnv)
|
|
196
|
+
// beats the host's own .env.local key — a workspace's Cursor account must
|
|
197
|
+
// never fall through to the operator's, and on a shared host the ambient
|
|
198
|
+
// key is usually absent anyway.
|
|
199
|
+
this.apiKey =
|
|
200
|
+
args.apiKey ?? this.agentEnv.CURSOR_API_KEY ?? process.env.CURSOR_API_KEY ?? "";
|
|
196
201
|
}
|
|
197
202
|
|
|
198
203
|
onEvent(handler: AgentEventHandler): () => void {
|
package/lib/agents/registry.ts
CHANGED
|
@@ -72,6 +72,21 @@ const adapters = new Map<string, RegisteredAdapter>();
|
|
|
72
72
|
/** Listeners fired after any registry mutation (manual re-advertise hook). */
|
|
73
73
|
const changeListeners = new Set<() => void>();
|
|
74
74
|
|
|
75
|
+
/**
|
|
76
|
+
* ADR-131 D3: a second source of "usable on this host" beyond the adapter's
|
|
77
|
+
* own `available()`, which only sees the host's default credential. The host
|
|
78
|
+
* agent installs the engine-account store here so a kind with a stored
|
|
79
|
+
* (workspace- or host-scoped) account is advertised even when the owner never
|
|
80
|
+
* signed in to it. Installed by main.ts; absent in unit tests.
|
|
81
|
+
*/
|
|
82
|
+
let advertisementFallback: (kind: string) => boolean = () => false;
|
|
83
|
+
|
|
84
|
+
export function setAdvertisementFallback(
|
|
85
|
+
fallback: ((kind: string) => boolean) | null,
|
|
86
|
+
): void {
|
|
87
|
+
advertisementFallback = fallback ?? (() => false);
|
|
88
|
+
}
|
|
89
|
+
|
|
75
90
|
/**
|
|
76
91
|
* Register (or replace) an adapter for a kind. Called at module load by the
|
|
77
92
|
* adapter modules. Replacing is allowed so a later import wins deterministically.
|
|
@@ -107,7 +122,10 @@ export function supportsExecutionProfile(kind: string, profileId: string): boole
|
|
|
107
122
|
*/
|
|
108
123
|
export function capabilities(): AgentKindCapability[] {
|
|
109
124
|
return list()
|
|
110
|
-
.filter(
|
|
125
|
+
.filter(
|
|
126
|
+
(adapter) =>
|
|
127
|
+
(adapter.available?.() ?? true) || advertisementFallback(adapter.kind),
|
|
128
|
+
)
|
|
111
129
|
.map((adapter) => {
|
|
112
130
|
const out: AgentKindCapability = {
|
|
113
131
|
kind: adapter.kind,
|
|
@@ -143,4 +161,5 @@ export function onChange(listener: () => void): () => void {
|
|
|
143
161
|
export function reset(): void {
|
|
144
162
|
adapters.clear();
|
|
145
163
|
changeListeners.clear();
|
|
164
|
+
advertisementFallback = () => false;
|
|
146
165
|
}
|
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";
|
|
@@ -60,6 +64,11 @@ interface KindDescriptor {
|
|
|
60
64
|
|
|
61
65
|
const KINDS: Record<string, KindDescriptor> = {
|
|
62
66
|
claude: { authKind: "env" },
|
|
67
|
+
// API-key engines: an extra account is one env var. The default slot stays
|
|
68
|
+
// the host's own `.env.local` key (the adapters read process.env), so these
|
|
69
|
+
// never synthesize a default — `defaultAvailable` is false for them.
|
|
70
|
+
grok: { authKind: "env" },
|
|
71
|
+
cursor: { authKind: "env" },
|
|
63
72
|
codex: {
|
|
64
73
|
authKind: "config-dir",
|
|
65
74
|
defaultHostDir: (owner) => join(owner, ".codex"),
|
|
@@ -78,6 +87,39 @@ const KINDS: Record<string, KindDescriptor> = {
|
|
|
78
87
|
},
|
|
79
88
|
};
|
|
80
89
|
|
|
90
|
+
/** The env var an extra API-key account injects, per kind (the same vars the
|
|
91
|
+
* adapters read for the host's own key). Claude is special-cased in
|
|
92
|
+
* `addEngineAccount` (token vs. key); Codex writes an auth.json instead. */
|
|
93
|
+
const API_KEY_ENV_VARS: Record<string, string> = {
|
|
94
|
+
grok: "XAI_API_KEY",
|
|
95
|
+
cursor: "CURSOR_API_KEY",
|
|
96
|
+
};
|
|
97
|
+
const API_KEY_ENGINE_LABELS: Record<string, string> = {
|
|
98
|
+
grok: "Grok",
|
|
99
|
+
cursor: "Cursor",
|
|
100
|
+
};
|
|
101
|
+
|
|
102
|
+
/**
|
|
103
|
+
* Whether ANY stored account exists for `kind`, in any scope. The
|
|
104
|
+
* advertisement fallback (ADR-131 D3): an adapter's `available()` only sees
|
|
105
|
+
* the host's own credential, so a shared host whose owner never signed in to
|
|
106
|
+
* Codex hid the kind from every workspace that had connected its own account
|
|
107
|
+
* — the cloud then refused the crew with "host does not support agent kind".
|
|
108
|
+
* A stored account is a usable credential; the cloud scopes it per caller.
|
|
109
|
+
*/
|
|
110
|
+
export function hasStoredEngineAccounts(kind: string): boolean {
|
|
111
|
+
if (!KINDS[kind]) return false;
|
|
112
|
+
try {
|
|
113
|
+
return getDb()
|
|
114
|
+
.select()
|
|
115
|
+
.from(schema.engineAccounts)
|
|
116
|
+
.all()
|
|
117
|
+
.some((row) => row.kind === kind);
|
|
118
|
+
} catch {
|
|
119
|
+
return false;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
|
|
81
123
|
/** Public (secret-free) account descriptor for the host UI. */
|
|
82
124
|
export interface EngineAccountInfo {
|
|
83
125
|
id: string;
|
|
@@ -85,6 +127,8 @@ export interface EngineAccountInfo {
|
|
|
85
127
|
label: string;
|
|
86
128
|
authKind: AccountAuthKind;
|
|
87
129
|
isDefault: boolean;
|
|
130
|
+
/** ADR-131 D3: "host" or "org:<orgId>". */
|
|
131
|
+
scope: string;
|
|
88
132
|
}
|
|
89
133
|
|
|
90
134
|
/** A fully-resolved account, ready to inject / provision (host-trusted; the
|
|
@@ -94,6 +138,8 @@ export interface ResolvedEngineAccount {
|
|
|
94
138
|
kind: string;
|
|
95
139
|
label: string;
|
|
96
140
|
isDefault: boolean;
|
|
141
|
+
/** ADR-131 D3: "host" or "org:<orgId>". */
|
|
142
|
+
scope: string;
|
|
97
143
|
authKind: AccountAuthKind;
|
|
98
144
|
/** Env merged into the agent's docker exec. Empty for the default env
|
|
99
145
|
* account (the ambient `.env.local` credential is account #1). */
|
|
@@ -141,6 +187,27 @@ function unpackSecret(enc: string): string {
|
|
|
141
187
|
return openAesGcm(ct, nonce);
|
|
142
188
|
}
|
|
143
189
|
|
|
190
|
+
// ---------------------------------------------------------------------------
|
|
191
|
+
// Scopes (ADR-131 Decision 3).
|
|
192
|
+
// ---------------------------------------------------------------------------
|
|
193
|
+
|
|
194
|
+
/**
|
|
195
|
+
* Every account has a scope. `host` is the owner's: the default slot plus
|
|
196
|
+
* every extra account connected before scopes existed (and every one an
|
|
197
|
+
* owner connects on a BYO/dedicated host). `org:<orgId>` is a workspace's
|
|
198
|
+
* account on a shared host: resolved ONLY for that workspace's tasks, and
|
|
199
|
+
* never joined by the default slot — a tenant sees exactly what it connected.
|
|
200
|
+
*/
|
|
201
|
+
export { HOST_ACCOUNT_SCOPE, isEngineAccountScope };
|
|
202
|
+
|
|
203
|
+
/** A row's scope (legacy rows read as `host`) matches the requested one. */
|
|
204
|
+
export function accountInScope(
|
|
205
|
+
rowScope: string | null | undefined,
|
|
206
|
+
scope: string,
|
|
207
|
+
): boolean {
|
|
208
|
+
return (rowScope ?? HOST_ACCOUNT_SCOPE) === scope;
|
|
209
|
+
}
|
|
210
|
+
|
|
144
211
|
// ---------------------------------------------------------------------------
|
|
145
212
|
// Resolution.
|
|
146
213
|
// ---------------------------------------------------------------------------
|
|
@@ -177,6 +244,7 @@ function synthesizeDefault(
|
|
|
177
244
|
label: "Default",
|
|
178
245
|
isDefault: true,
|
|
179
246
|
authKind: "env",
|
|
247
|
+
scope: HOST_ACCOUNT_SCOPE,
|
|
180
248
|
execEnv: {},
|
|
181
249
|
};
|
|
182
250
|
}
|
|
@@ -187,6 +255,7 @@ function synthesizeDefault(
|
|
|
187
255
|
label: "Default",
|
|
188
256
|
isDefault: true,
|
|
189
257
|
authKind: "config-dir",
|
|
258
|
+
scope: HOST_ACCOUNT_SCOPE,
|
|
190
259
|
execEnv: d.selectVar ? { [d.selectVar]: containerDir } : {},
|
|
191
260
|
configDir: {
|
|
192
261
|
hostDir: (d.defaultHostDir as (o: string) => string)(s.ownerHome()),
|
|
@@ -196,14 +265,22 @@ function synthesizeDefault(
|
|
|
196
265
|
}
|
|
197
266
|
|
|
198
267
|
/** DB extras for a kind, resolved into injectable form. */
|
|
199
|
-
function resolveExtras(
|
|
268
|
+
function resolveExtras(
|
|
269
|
+
kind: string,
|
|
270
|
+
s: AccountSeams,
|
|
271
|
+
scope: string,
|
|
272
|
+
): ResolvedEngineAccount[] {
|
|
200
273
|
const d = KINDS[kind];
|
|
201
274
|
if (!d) return [];
|
|
202
275
|
const rows = getDb()
|
|
203
276
|
.select()
|
|
204
277
|
.from(schema.engineAccounts)
|
|
205
278
|
.all()
|
|
206
|
-
.filter(
|
|
279
|
+
.filter(
|
|
280
|
+
(r) =>
|
|
281
|
+
r.kind === kind &&
|
|
282
|
+
accountInScope((r as { scope?: string | null }).scope, scope),
|
|
283
|
+
);
|
|
207
284
|
const out: ResolvedEngineAccount[] = [];
|
|
208
285
|
for (const row of rows) {
|
|
209
286
|
if (row.authKind === "env") {
|
|
@@ -226,6 +303,7 @@ function resolveExtras(kind: string, s: AccountSeams): ResolvedEngineAccount[] {
|
|
|
226
303
|
label: row.label,
|
|
227
304
|
isDefault: false,
|
|
228
305
|
authKind: "env",
|
|
306
|
+
scope,
|
|
229
307
|
execEnv,
|
|
230
308
|
});
|
|
231
309
|
} else if (row.authKind === "config-dir" && row.configDir && d.selectVar) {
|
|
@@ -236,6 +314,7 @@ function resolveExtras(kind: string, s: AccountSeams): ResolvedEngineAccount[] {
|
|
|
236
314
|
label: row.label,
|
|
237
315
|
isDefault: false,
|
|
238
316
|
authKind: "config-dir",
|
|
317
|
+
scope,
|
|
239
318
|
execEnv: { [d.selectVar]: containerDir },
|
|
240
319
|
configDir: { hostDir: row.configDir, containerDir },
|
|
241
320
|
});
|
|
@@ -248,12 +327,16 @@ function resolveExtras(kind: string, s: AccountSeams): ResolvedEngineAccount[] {
|
|
|
248
327
|
export function resolveEngineAccounts(
|
|
249
328
|
kind: string,
|
|
250
329
|
seams: Partial<AccountSeams> = {},
|
|
330
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
251
331
|
): ResolvedEngineAccount[] {
|
|
252
332
|
const s = withDefaults(seams);
|
|
253
333
|
const accounts: ResolvedEngineAccount[] = [];
|
|
254
|
-
|
|
255
|
-
if (
|
|
256
|
-
|
|
334
|
+
// The default slot is the OWNER's credential: it joins the host scope only.
|
|
335
|
+
if (scope === HOST_ACCOUNT_SCOPE) {
|
|
336
|
+
const def = synthesizeDefault(kind, s);
|
|
337
|
+
if (def) accounts.push(def);
|
|
338
|
+
}
|
|
339
|
+
accounts.push(...resolveExtras(kind, s, scope));
|
|
257
340
|
return accounts;
|
|
258
341
|
}
|
|
259
342
|
|
|
@@ -261,22 +344,47 @@ export function resolveEngineAccounts(
|
|
|
261
344
|
export function listEngineAccounts(
|
|
262
345
|
kind: string,
|
|
263
346
|
seams: Partial<AccountSeams> = {},
|
|
347
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
264
348
|
): EngineAccountInfo[] {
|
|
265
|
-
return resolveEngineAccounts(kind, seams).map((a) => ({
|
|
349
|
+
return resolveEngineAccounts(kind, seams, scope).map((a) => ({
|
|
266
350
|
id: a.id,
|
|
267
351
|
kind: a.kind,
|
|
268
352
|
label: a.label,
|
|
269
353
|
authKind: a.authKind,
|
|
270
354
|
isDefault: a.isDefault,
|
|
355
|
+
scope: a.scope,
|
|
271
356
|
}));
|
|
272
357
|
}
|
|
273
358
|
|
|
359
|
+
/** Every scope holding at least one stored account for the kind, plus
|
|
360
|
+
* `host` (which may consist of the default slot alone). */
|
|
361
|
+
export function engineAccountScopes(kind: string): string[] {
|
|
362
|
+
const scopes = new Set<string>([HOST_ACCOUNT_SCOPE]);
|
|
363
|
+
for (const row of getDb().select().from(schema.engineAccounts).all()) {
|
|
364
|
+
if (row.kind !== kind) continue;
|
|
365
|
+
scopes.add((row as { scope?: string | null }).scope ?? HOST_ACCOUNT_SCOPE);
|
|
366
|
+
}
|
|
367
|
+
return [...scopes];
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Account list across ALL scopes — the capabilities advertisement. The
|
|
371
|
+
* cloud filters per caller; labels only, never credentials. */
|
|
372
|
+
export function listEngineAccountsAllScopes(
|
|
373
|
+
kind: string,
|
|
374
|
+
seams: Partial<AccountSeams> = {},
|
|
375
|
+
): EngineAccountInfo[] {
|
|
376
|
+
return engineAccountScopes(kind).flatMap((scope) =>
|
|
377
|
+
listEngineAccounts(kind, seams, scope),
|
|
378
|
+
);
|
|
379
|
+
}
|
|
380
|
+
|
|
274
381
|
/** How many accounts a kind has right now (default + extras). */
|
|
275
382
|
export function engineAccountCount(
|
|
276
383
|
kind: string,
|
|
277
384
|
seams: Partial<AccountSeams> = {},
|
|
385
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
278
386
|
): number {
|
|
279
|
-
return resolveEngineAccounts(kind, seams).length;
|
|
387
|
+
return resolveEngineAccounts(kind, seams, scope).length;
|
|
280
388
|
}
|
|
281
389
|
|
|
282
390
|
// ---------------------------------------------------------------------------
|
|
@@ -339,9 +447,19 @@ export function selectAccount(
|
|
|
339
447
|
/** Pick an account for a kind (default + extras), applying rotation state. */
|
|
340
448
|
export function pickEngineAccount(
|
|
341
449
|
kind: string,
|
|
342
|
-
opts: {
|
|
450
|
+
opts: {
|
|
451
|
+
exclude?: Set<string>;
|
|
452
|
+
seams?: Partial<AccountSeams>;
|
|
453
|
+
now?: number;
|
|
454
|
+
/** ADR-131 D3: the task's scope; default = the host's own accounts. */
|
|
455
|
+
scope?: string;
|
|
456
|
+
} = {},
|
|
343
457
|
): ResolvedEngineAccount | null {
|
|
344
|
-
const accounts = resolveEngineAccounts(
|
|
458
|
+
const accounts = resolveEngineAccounts(
|
|
459
|
+
kind,
|
|
460
|
+
opts.seams ?? {},
|
|
461
|
+
opts.scope ?? HOST_ACCOUNT_SCOPE,
|
|
462
|
+
);
|
|
345
463
|
return selectAccount(accounts, { exclude: opts.exclude, now: opts.now });
|
|
346
464
|
}
|
|
347
465
|
|
|
@@ -495,6 +613,7 @@ export async function provisionEngineAccounts(
|
|
|
495
613
|
kinds: Iterable<string>,
|
|
496
614
|
seams: Partial<AccountSeams> = {},
|
|
497
615
|
environment?: Pick<TaskEnvironmentHandle, "exec" | "copy">,
|
|
616
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
498
617
|
): Promise<ProvisionEngineAccountsResult> {
|
|
499
618
|
const s = withDefaults(seams);
|
|
500
619
|
const seen = new Set<string>();
|
|
@@ -504,7 +623,7 @@ export async function provisionEngineAccounts(
|
|
|
504
623
|
for (const kind of kinds) {
|
|
505
624
|
if (seen.has(kind)) continue;
|
|
506
625
|
seen.add(kind);
|
|
507
|
-
for (const account of resolveEngineAccounts(kind, s)) {
|
|
626
|
+
for (const account of resolveEngineAccounts(kind, s, scope)) {
|
|
508
627
|
if (account.isDefault || !account.configDir) continue;
|
|
509
628
|
if (!s.fileExists(account.configDir.hostDir)) {
|
|
510
629
|
failed += 1;
|
|
@@ -554,8 +673,12 @@ export function addEngineAccount(
|
|
|
554
673
|
label: string,
|
|
555
674
|
creds: { apiKey?: string; token?: string },
|
|
556
675
|
seams: Partial<AccountSeams> = {},
|
|
676
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
557
677
|
): AddAccountResult {
|
|
558
678
|
const s = withDefaults(seams);
|
|
679
|
+
if (!isEngineAccountScope(scope)) {
|
|
680
|
+
return { ok: false, message: "The account scope is invalid." };
|
|
681
|
+
}
|
|
559
682
|
const trimmedLabel = label.trim();
|
|
560
683
|
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
561
684
|
const secret = (creds.token ?? creds.apiKey ?? "").trim();
|
|
@@ -581,11 +704,34 @@ export function addEngineAccount(
|
|
|
581
704
|
secretEnc: packSecret(JSON.stringify({ [envVar]: secret })),
|
|
582
705
|
configDir: null,
|
|
583
706
|
createdAt: now,
|
|
707
|
+
scope,
|
|
584
708
|
})
|
|
585
709
|
.run();
|
|
586
710
|
return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
|
|
587
711
|
}
|
|
588
712
|
|
|
713
|
+
const envKeyVar = API_KEY_ENV_VARS[kind];
|
|
714
|
+
if (envKeyVar) {
|
|
715
|
+
getDb()
|
|
716
|
+
.insert(schema.engineAccounts)
|
|
717
|
+
.values({
|
|
718
|
+
id,
|
|
719
|
+
kind,
|
|
720
|
+
label: trimmedLabel,
|
|
721
|
+
authKind: "env",
|
|
722
|
+
secretEnc: packSecret(JSON.stringify({ [envKeyVar]: secret })),
|
|
723
|
+
configDir: null,
|
|
724
|
+
createdAt: now,
|
|
725
|
+
scope,
|
|
726
|
+
})
|
|
727
|
+
.run();
|
|
728
|
+
return {
|
|
729
|
+
ok: true,
|
|
730
|
+
message: `Added ${API_KEY_ENGINE_LABELS[kind] ?? kind} account "${trimmedLabel}".`,
|
|
731
|
+
id,
|
|
732
|
+
};
|
|
733
|
+
}
|
|
734
|
+
|
|
589
735
|
if (kind === "codex") {
|
|
590
736
|
const d = KINDS.codex as KindDescriptor;
|
|
591
737
|
const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
|
|
@@ -621,6 +767,7 @@ export function addEngineAccount(
|
|
|
621
767
|
secretEnc: null,
|
|
622
768
|
configDir: hostDir,
|
|
623
769
|
createdAt: now,
|
|
770
|
+
scope,
|
|
624
771
|
})
|
|
625
772
|
.run();
|
|
626
773
|
return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
|
|
@@ -642,7 +789,11 @@ export function addCodexAccountFromAuthJson(
|
|
|
642
789
|
label: string,
|
|
643
790
|
authJson: string,
|
|
644
791
|
seams: Partial<AccountSeams> = {},
|
|
792
|
+
scope: string = HOST_ACCOUNT_SCOPE,
|
|
645
793
|
): AddAccountResult {
|
|
794
|
+
if (!isEngineAccountScope(scope)) {
|
|
795
|
+
return { ok: false, message: "The account scope is invalid." };
|
|
796
|
+
}
|
|
646
797
|
const s = withDefaults(seams);
|
|
647
798
|
const trimmedLabel = label.trim();
|
|
648
799
|
if (!trimmedLabel) return { ok: false, message: "Give the account a label." };
|
|
@@ -690,6 +841,7 @@ export function addCodexAccountFromAuthJson(
|
|
|
690
841
|
secretEnc: null,
|
|
691
842
|
configDir: hostDir,
|
|
692
843
|
createdAt: Date.now(),
|
|
844
|
+
scope,
|
|
693
845
|
})
|
|
694
846
|
.run();
|
|
695
847
|
return { ok: true, message: `Added Codex account "${trimmedLabel}".`, id };
|
|
@@ -698,7 +850,19 @@ export function addCodexAccountFromAuthJson(
|
|
|
698
850
|
/** Remove an EXTRA account (idempotent). The synthesized default can't be
|
|
699
851
|
* removed here (use the engine disconnect flow for the legacy slot). The
|
|
700
852
|
* isolated host config dir of a config-dir account is deleted too. */
|
|
701
|
-
|
|
853
|
+
/** Whether a caller acting in `scope` may remove `row`. `undefined` is the
|
|
854
|
+
* host's own local UI, which may remove anything. */
|
|
855
|
+
export function accountRemovableInScope(
|
|
856
|
+
row: { scope?: string | null },
|
|
857
|
+
scope: string | undefined,
|
|
858
|
+
): boolean {
|
|
859
|
+
return scope === undefined || accountInScope(row.scope, scope);
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
export function removeEngineAccount(
|
|
863
|
+
id: string,
|
|
864
|
+
scope?: string,
|
|
865
|
+
): { ok: boolean; message: string } {
|
|
702
866
|
if (id.startsWith("default:")) {
|
|
703
867
|
return {
|
|
704
868
|
ok: false,
|
|
@@ -711,6 +875,11 @@ export function removeEngineAccount(id: string): { ok: boolean; message: string
|
|
|
711
875
|
.where(eq(schema.engineAccounts.id, id))
|
|
712
876
|
.get();
|
|
713
877
|
if (!row) return { ok: true, message: "Account already removed." };
|
|
878
|
+
// ADR-131 D3: a workspace removes only its own accounts — never the host's
|
|
879
|
+
// and never another workspace's, whatever id it names.
|
|
880
|
+
if (!accountRemovableInScope(row, scope)) {
|
|
881
|
+
return { ok: false, message: "That account belongs to a different scope." };
|
|
882
|
+
}
|
|
714
883
|
getDb().delete(schema.engineAccounts).where(eq(schema.engineAccounts.id, id)).run();
|
|
715
884
|
if (row.authKind === "config-dir" && row.configDir) {
|
|
716
885
|
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,
|
package/lib/orchestrator.ts
CHANGED
|
@@ -106,14 +106,14 @@ import {
|
|
|
106
106
|
} from "./browser-testing";
|
|
107
107
|
import { injectCodexIntoContainer } from "./codex-auth";
|
|
108
108
|
import {
|
|
109
|
+
codexHomeIsolatable,
|
|
110
|
+
codexHomeSeedCommand,
|
|
111
|
+
codexPerAgentHome,
|
|
109
112
|
cooldownEngineAccount,
|
|
110
113
|
noteEngineAccountUsed,
|
|
111
114
|
pickEngineAccount,
|
|
112
115
|
provisionEngineAccounts,
|
|
113
116
|
resolveEngineAccounts,
|
|
114
|
-
codexHomeIsolatable,
|
|
115
|
-
codexPerAgentHome,
|
|
116
|
-
codexHomeSeedCommand,
|
|
117
117
|
type ResolvedEngineAccount,
|
|
118
118
|
} from "./engine-accounts";
|
|
119
119
|
import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
|
|
@@ -133,6 +133,7 @@ import type {
|
|
|
133
133
|
HostEvent,
|
|
134
134
|
} from "../src/protocol";
|
|
135
135
|
import {
|
|
136
|
+
HOST_ACCOUNT_SCOPE,
|
|
136
137
|
MAX_AGENT_ID_CHARS,
|
|
137
138
|
MAX_SECRETARY_DISPATCH_ID_CHARS,
|
|
138
139
|
SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
|
|
@@ -155,6 +156,9 @@ const RESTRICTED_SECRETARY_SESSION_ATTACH_KEY =
|
|
|
155
156
|
interface Channel {
|
|
156
157
|
taskId: string;
|
|
157
158
|
roster: Roster;
|
|
159
|
+
/** ADR-131 D3: the engine-account scope this task's agents resolve
|
|
160
|
+
* credentials from. `host` unless the cloud scoped the task to a workspace. */
|
|
161
|
+
engineAccountScope: string;
|
|
158
162
|
/** ADR-083: host-derived role assignment for execution-profile enforcement. */
|
|
159
163
|
mode: "open" | "secretary";
|
|
160
164
|
secretaryAgentId?: string;
|
|
@@ -527,6 +531,7 @@ export class Orchestrator {
|
|
|
527
531
|
channel.browserTesting = spec.browserTesting === true;
|
|
528
532
|
channel.sharedFiles = spec.sharedFiles ?? "ro";
|
|
529
533
|
channel.mcpConnections = spec.mcpConnections ?? [];
|
|
534
|
+
if (spec.engineAccountScope) channel.engineAccountScope = spec.engineAccountScope;
|
|
530
535
|
const nextMcpShape = JSON.stringify({
|
|
531
536
|
connections: channel.mcpConnections,
|
|
532
537
|
kinds: [...new Set(channel.roster.map((agent) => agent.kind))].sort(),
|
|
@@ -640,6 +645,10 @@ export class Orchestrator {
|
|
|
640
645
|
const channel: Channel = {
|
|
641
646
|
taskId,
|
|
642
647
|
roster,
|
|
648
|
+
engineAccountScope:
|
|
649
|
+
spec.engineAccountScope ??
|
|
650
|
+
getHostTask(taskId)?.engineAccountScope ??
|
|
651
|
+
HOST_ACCOUNT_SCOPE,
|
|
643
652
|
mode: spec.mode === "secretary" ? "secretary" : "open",
|
|
644
653
|
secretaryAgentId: spec.secretaryAgentId,
|
|
645
654
|
sessions: new Map(),
|
|
@@ -797,9 +806,9 @@ export class Orchestrator {
|
|
|
797
806
|
* copies use this gate: a false→true toggle queues migration after the older
|
|
798
807
|
* copy, so that copy cannot land last and erase the managed browser block.
|
|
799
808
|
*/
|
|
800
|
-
private codexHomes(hasCodex: boolean): string[] {
|
|
809
|
+
private codexHomes(hasCodex: boolean, scope: string): string[] {
|
|
801
810
|
if (!hasCodex) return [];
|
|
802
|
-
const homes = resolveEngineAccounts("codex")
|
|
811
|
+
const homes = resolveEngineAccounts("codex", {}, scope)
|
|
803
812
|
.map((account) => account.configDir?.containerDir)
|
|
804
813
|
.filter((home): home is string => Boolean(home));
|
|
805
814
|
return [...new Set([DEFAULT_CODEX_HOME, ...homes])];
|
|
@@ -825,9 +834,9 @@ export class Orchestrator {
|
|
|
825
834
|
if (this.isActiveChannel(channel)) channel.configGeneration += 1;
|
|
826
835
|
}
|
|
827
836
|
|
|
828
|
-
private provisionFingerprint(kinds: readonly string[]): string {
|
|
837
|
+
private provisionFingerprint(kinds: readonly string[], scope: string): string {
|
|
829
838
|
const accounts = kinds.flatMap((kind) =>
|
|
830
|
-
resolveEngineAccounts(kind)
|
|
839
|
+
resolveEngineAccounts(kind, {}, scope)
|
|
831
840
|
.filter((account) => !account.isDefault && account.configDir)
|
|
832
841
|
.map((account) => ({
|
|
833
842
|
kind,
|
|
@@ -864,15 +873,15 @@ export class Orchestrator {
|
|
|
864
873
|
const requestedHasCodex =
|
|
865
874
|
hasCodex || channel.roster.some((agent) => agent.kind === "codex");
|
|
866
875
|
const requestedHomes = requestedBrowser
|
|
867
|
-
? this.codexHomes(requestedHasCodex)
|
|
876
|
+
? this.codexHomes(requestedHasCodex, channel.engineAccountScope)
|
|
868
877
|
: [];
|
|
869
878
|
const requestedKinds = [
|
|
870
879
|
...new Set(channel.roster.map((agent) => agent.kind)),
|
|
871
880
|
];
|
|
872
881
|
const requestedProvisionFingerprint =
|
|
873
|
-
this.provisionFingerprint(requestedKinds);
|
|
882
|
+
this.provisionFingerprint(requestedKinds, channel.engineAccountScope);
|
|
874
883
|
const mayProvisionConfig = requestedKinds.some((kind) =>
|
|
875
|
-
resolveEngineAccounts(kind).some(
|
|
884
|
+
resolveEngineAccounts(kind, {}, channel.engineAccountScope).some(
|
|
876
885
|
(account) => !account.isDefault && Boolean(account.configDir),
|
|
877
886
|
),
|
|
878
887
|
);
|
|
@@ -891,13 +900,13 @@ export class Orchestrator {
|
|
|
891
900
|
requestedHasCodex ||
|
|
892
901
|
channel.roster.some((agent) => agent.kind === "codex");
|
|
893
902
|
const latestHomes = latestBrowser
|
|
894
|
-
? this.codexHomes(latestHasCodex)
|
|
903
|
+
? this.codexHomes(latestHasCodex, channel.engineAccountScope)
|
|
895
904
|
: [];
|
|
896
905
|
const latestKinds = [
|
|
897
906
|
...new Set(channel.roster.map((agent) => agent.kind)),
|
|
898
907
|
];
|
|
899
908
|
const latestProvisionFingerprint =
|
|
900
|
-
this.provisionFingerprint(latestKinds);
|
|
909
|
+
this.provisionFingerprint(latestKinds, channel.engineAccountScope);
|
|
901
910
|
if (
|
|
902
911
|
(latestBrowser &&
|
|
903
912
|
coverage?.browserAttempted !== true &&
|
|
@@ -949,10 +958,14 @@ export class Orchestrator {
|
|
|
949
958
|
requestedKinds,
|
|
950
959
|
{},
|
|
951
960
|
channel.agentHandle,
|
|
961
|
+
channel.engineAccountScope,
|
|
952
962
|
)
|
|
953
963
|
: await provisionEngineAccounts(
|
|
954
964
|
channel.containerName,
|
|
955
965
|
requestedKinds,
|
|
966
|
+
{},
|
|
967
|
+
undefined,
|
|
968
|
+
channel.engineAccountScope,
|
|
956
969
|
);
|
|
957
970
|
copiedOrUnknown =
|
|
958
971
|
provisioned.copied > 0 || provisioned.mayHaveMutated;
|
|
@@ -1075,12 +1088,15 @@ export class Orchestrator {
|
|
|
1075
1088
|
requestedHasCodex ||
|
|
1076
1089
|
channel.roster.some((agent) => agent.kind === "codex");
|
|
1077
1090
|
const latestHomes = latestBrowser
|
|
1078
|
-
? this.codexHomes(latestHasCodex)
|
|
1091
|
+
? this.codexHomes(latestHasCodex, channel.engineAccountScope)
|
|
1079
1092
|
: [];
|
|
1080
1093
|
const latestKinds = [
|
|
1081
1094
|
...new Set(channel.roster.map((agent) => agent.kind)),
|
|
1082
1095
|
];
|
|
1083
|
-
const latestProvisionFingerprint = this.provisionFingerprint(
|
|
1096
|
+
const latestProvisionFingerprint = this.provisionFingerprint(
|
|
1097
|
+
latestKinds,
|
|
1098
|
+
channel.engineAccountScope,
|
|
1099
|
+
);
|
|
1084
1100
|
if (
|
|
1085
1101
|
(latestBrowser &&
|
|
1086
1102
|
!coverage.browserAttempted &&
|
|
@@ -1169,7 +1185,7 @@ export class Orchestrator {
|
|
|
1169
1185
|
...new Set(channel.roster.map((agent) => agent.kind)),
|
|
1170
1186
|
].sort();
|
|
1171
1187
|
const codexHomes = engineKinds.includes("codex")
|
|
1172
|
-
? this.codexHomes(true)
|
|
1188
|
+
? this.codexHomes(true, channel.engineAccountScope)
|
|
1173
1189
|
: [];
|
|
1174
1190
|
const fingerprint = JSON.stringify({
|
|
1175
1191
|
connections,
|
|
@@ -1529,7 +1545,9 @@ export class Orchestrator {
|
|
|
1529
1545
|
agent: RosterAgent,
|
|
1530
1546
|
base: Record<string, string>,
|
|
1531
1547
|
): Record<string, string> {
|
|
1532
|
-
const account = pickEngineAccount(agent.kind
|
|
1548
|
+
const account = pickEngineAccount(agent.kind, {
|
|
1549
|
+
scope: channel.engineAccountScope,
|
|
1550
|
+
});
|
|
1533
1551
|
if (!account) {
|
|
1534
1552
|
channel.accountByAgent.delete(agent.id);
|
|
1535
1553
|
return base;
|
|
@@ -2598,7 +2616,7 @@ export class Orchestrator {
|
|
|
2598
2616
|
);
|
|
2599
2617
|
const boundAccountId = channel.accountByAgent.get(agentId);
|
|
2600
2618
|
const boundAccount = boundAccountId
|
|
2601
|
-
? resolveEngineAccounts(agent.kind).find(
|
|
2619
|
+
? resolveEngineAccounts(agent.kind, {}, channel.engineAccountScope).find(
|
|
2602
2620
|
(account) => account.id === boundAccountId,
|
|
2603
2621
|
)
|
|
2604
2622
|
: undefined;
|
|
@@ -2667,7 +2685,9 @@ export class Orchestrator {
|
|
|
2667
2685
|
// A concurrent terminal event for the same session is already being
|
|
2668
2686
|
// handled by the path that owns this spawn slot.
|
|
2669
2687
|
if (channel.spawning.has(agentId)) return true;
|
|
2670
|
-
if (resolveEngineAccounts(agent.kind).length < 2)
|
|
2688
|
+
if (resolveEngineAccounts(agent.kind, {}, channel.engineAccountScope).length < 2) {
|
|
2689
|
+
return false;
|
|
2690
|
+
}
|
|
2671
2691
|
|
|
2672
2692
|
const budget = (channel.rotations.get(agentId) ?? 0) + 1;
|
|
2673
2693
|
channel.rotations.set(agentId, budget);
|
|
@@ -2676,6 +2696,7 @@ export class Orchestrator {
|
|
|
2676
2696
|
const current = channel.accountByAgent.get(agentId);
|
|
2677
2697
|
if (current) cooldownEngineAccount(current);
|
|
2678
2698
|
const next = pickEngineAccount(agent.kind, {
|
|
2699
|
+
scope: channel.engineAccountScope,
|
|
2679
2700
|
exclude: current ? new Set([current]) : undefined,
|
|
2680
2701
|
});
|
|
2681
2702
|
if (!next) return false;
|
|
@@ -2918,7 +2939,7 @@ export class Orchestrator {
|
|
|
2918
2939
|
return;
|
|
2919
2940
|
}
|
|
2920
2941
|
|
|
2921
|
-
const accounts = resolveEngineAccounts(agent.kind);
|
|
2942
|
+
const accounts = resolveEngineAccounts(agent.kind, {}, channel.engineAccountScope);
|
|
2922
2943
|
const account =
|
|
2923
2944
|
accounts.find((a) => a.id === channel.accountByAgent.get(agentId)) ??
|
|
2924
2945
|
accounts[0];
|
package/lib/runtime-state.ts
CHANGED
|
@@ -231,12 +231,14 @@ export function recordTaskOwner(
|
|
|
231
231
|
ownerEmail: string | null,
|
|
232
232
|
ownerName: string | null,
|
|
233
233
|
projectSlugs: string[],
|
|
234
|
+
engineAccountScope: string | null = null,
|
|
234
235
|
): void {
|
|
235
236
|
upsertHostTask(taskId, {
|
|
236
237
|
ownerUserId,
|
|
237
238
|
ownerEmail,
|
|
238
239
|
ownerName,
|
|
239
240
|
projectSlugs: JSON.stringify(projectSlugs),
|
|
241
|
+
engineAccountScope,
|
|
240
242
|
});
|
|
241
243
|
}
|
|
242
244
|
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -768,6 +768,8 @@ export const hostCommands: HostCommands = {
|
|
|
768
768
|
input.ownerEmail ?? null,
|
|
769
769
|
input.ownerName ?? null,
|
|
770
770
|
input.projects.map((p) => p.slug),
|
|
771
|
+
// ADR-131 D3: persisted so recovery re-provisions the same scope.
|
|
772
|
+
input.task.engineAccountScope ?? null,
|
|
771
773
|
);
|
|
772
774
|
// ADR-048: persist the cloud-issued per-task cli secret (host-private) so the
|
|
773
775
|
// `uai` CLI's per-agent tokens can be minted now + after a host restart.
|
|
@@ -1227,7 +1229,7 @@ export const hostCommands: HostCommands = {
|
|
|
1227
1229
|
async engineAccountRemove(ctx, input) {
|
|
1228
1230
|
logCommand(ctx, "engineAccountRemove", input.id);
|
|
1229
1231
|
try {
|
|
1230
|
-
const result = removeEngineAccount(input.id);
|
|
1232
|
+
const result = removeEngineAccount(input.id, input.scope);
|
|
1231
1233
|
if (!result.ok) {
|
|
1232
1234
|
return ok({ removed: false, message: result.message });
|
|
1233
1235
|
}
|
package/src/main.ts
CHANGED
|
@@ -132,6 +132,7 @@ import { newId } from "../lib/ulid";
|
|
|
132
132
|
import {
|
|
133
133
|
capabilities as agentKindCapabilities,
|
|
134
134
|
onChange as onRegistryChange,
|
|
135
|
+
setAdvertisementFallback,
|
|
135
136
|
} from "../lib/agents/registry";
|
|
136
137
|
import { canAdvertiseTypedSecretaryDispatch } from "../lib/agents/mode";
|
|
137
138
|
import {
|
|
@@ -150,7 +151,10 @@ import {
|
|
|
150
151
|
patchHostConfig,
|
|
151
152
|
type HostConfigResult,
|
|
152
153
|
} from "../lib/host-config";
|
|
153
|
-
import {
|
|
154
|
+
import {
|
|
155
|
+
hasStoredEngineAccounts,
|
|
156
|
+
listEngineAccountsAllScopes,
|
|
157
|
+
} from "../lib/engine-accounts";
|
|
154
158
|
import { createEngineConnectManager } from "../lib/engine-connect";
|
|
155
159
|
import { createEngineLoginManager } from "../lib/engine-login";
|
|
156
160
|
import {
|
|
@@ -182,7 +186,9 @@ import {
|
|
|
182
186
|
HOST_TASK_STOP_PROTOCOL_FEATURE,
|
|
183
187
|
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
184
188
|
HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
|
|
189
|
+
HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
|
|
185
190
|
HOST_ENGINE_LOGIN_PROTOCOL_FEATURE,
|
|
191
|
+
isEngineAccountScope,
|
|
186
192
|
HOST_LOGS_PROTOCOL_FEATURE,
|
|
187
193
|
HOST_TASK_INVENTORY_PROTOCOL_FEATURE,
|
|
188
194
|
MAX_ENGINE_ACCOUNT_LABEL_CHARS,
|
|
@@ -601,6 +607,7 @@ function buildCapabilities(): HostCapabilities {
|
|
|
601
607
|
HOST_TASK_STOP_PROTOCOL_FEATURE,
|
|
602
608
|
HOST_ENGINE_ACCOUNTS_PROTOCOL_FEATURE,
|
|
603
609
|
HOST_ENGINE_CONNECT_PROTOCOL_FEATURE,
|
|
610
|
+
HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE,
|
|
604
611
|
// The echo adapter cannot execute the in-task CLI. Advertising typed
|
|
605
612
|
// dispatch in mock mode would let the composer create a Secretary that
|
|
606
613
|
// has no way to wake crew.
|
|
@@ -694,15 +701,17 @@ function engineCatalogCapabilities(): EngineCatalogCapability[] {
|
|
|
694
701
|
/** ADR-116: the labeled-accounts advertisement — bounded, labels-only. */
|
|
695
702
|
function engineAccountSummaries(): EngineAccountSummary[] {
|
|
696
703
|
const summaries: EngineAccountSummary[] = [];
|
|
697
|
-
for (const kind of ["claude", "codex", "opencode"] as const) {
|
|
704
|
+
for (const kind of ["claude", "codex", "grok", "cursor", "opencode"] as const) {
|
|
698
705
|
try {
|
|
699
|
-
|
|
706
|
+
// ADR-131 D3: every scope — the cloud filters per caller.
|
|
707
|
+
for (const account of listEngineAccountsAllScopes(kind)) {
|
|
700
708
|
summaries.push({
|
|
701
709
|
id: account.id,
|
|
702
710
|
kind,
|
|
703
711
|
label: account.label.slice(0, MAX_ENGINE_ACCOUNT_LABEL_CHARS),
|
|
704
712
|
authKind: account.authKind,
|
|
705
713
|
isDefault: account.isDefault,
|
|
714
|
+
scope: account.scope,
|
|
706
715
|
});
|
|
707
716
|
}
|
|
708
717
|
} catch (error) {
|
|
@@ -739,6 +748,9 @@ function flushStoppedTaskStatuses(socket: WebSocket | null = ws): void {
|
|
|
739
748
|
});
|
|
740
749
|
}
|
|
741
750
|
|
|
751
|
+
// ADR-131 D3: a stored account (any scope) makes a kind advertisable even
|
|
752
|
+
// when the host's own credential is absent — see hasStoredEngineAccounts.
|
|
753
|
+
setAdvertisementFallback((kind) => hasStoredEngineAccounts(kind));
|
|
742
754
|
onRegistryChange(() => sendCapabilities());
|
|
743
755
|
onContainerRuntimeChange((next, previous) => {
|
|
744
756
|
if (stopping) return;
|
|
@@ -1227,6 +1239,7 @@ async function connect(): Promise<void> {
|
|
|
1227
1239
|
frame.engine,
|
|
1228
1240
|
emitEngineLoginEvent,
|
|
1229
1241
|
frame.label,
|
|
1242
|
+
frame.scope,
|
|
1230
1243
|
);
|
|
1231
1244
|
break;
|
|
1232
1245
|
case "engine.login.input":
|
|
@@ -1247,6 +1260,7 @@ async function connect(): Promise<void> {
|
|
|
1247
1260
|
frame.secret,
|
|
1248
1261
|
frame.label,
|
|
1249
1262
|
(event) => sendRemoteOperationFrame(event),
|
|
1263
|
+
frame.scope,
|
|
1250
1264
|
);
|
|
1251
1265
|
break;
|
|
1252
1266
|
case "engine.connect.stop":
|
|
@@ -2434,7 +2448,7 @@ async function dispatchCommand(
|
|
|
2434
2448
|
function expectEngineAccountRemoveInput(
|
|
2435
2449
|
args: unknown[],
|
|
2436
2450
|
index: number,
|
|
2437
|
-
): { id: string } {
|
|
2451
|
+
): { id: string; scope?: string } {
|
|
2438
2452
|
const value = args[index];
|
|
2439
2453
|
if (
|
|
2440
2454
|
!value ||
|
|
@@ -2445,7 +2459,14 @@ function expectEngineAccountRemoveInput(
|
|
|
2445
2459
|
) {
|
|
2446
2460
|
throw new Error(`invalid command args: expected account id at ${index}`);
|
|
2447
2461
|
}
|
|
2448
|
-
|
|
2462
|
+
const scope = (value as { scope?: unknown }).scope;
|
|
2463
|
+
if (scope !== undefined && !isEngineAccountScope(scope)) {
|
|
2464
|
+
throw new Error(`invalid command args: bad account scope at ${index}`);
|
|
2465
|
+
}
|
|
2466
|
+
return {
|
|
2467
|
+
id: (value as { id: string }).id,
|
|
2468
|
+
...(scope !== undefined ? { scope } : {}),
|
|
2469
|
+
};
|
|
2449
2470
|
}
|
|
2450
2471
|
|
|
2451
2472
|
function expectNumberArg(args: unknown[], index: number): number {
|
package/src/protocol.ts
CHANGED
|
@@ -102,6 +102,25 @@ export const MAX_ENGINE_ACCOUNTS_ADVERTISED = 48;
|
|
|
102
102
|
/** ADR-117: one-shot engine credentials over the bridge + the full engine
|
|
103
103
|
* catalog in capabilities, so every engine renders on the cloud pane. */
|
|
104
104
|
export const HOST_ENGINE_CONNECT_PROTOCOL_FEATURE = "host-engine-connect-v1";
|
|
105
|
+
/** ADR-131 Decision 3: engine accounts carry a scope — `host` (the owner's)
|
|
106
|
+
* or `org:<orgId>` (a workspace's account on a shared host). A host that
|
|
107
|
+
* advertises this accepts `scope` on engine.login.start / engine.connect.start
|
|
108
|
+
* / engineAccountRemove, `engineAccountScope` on channelEnsure and task-up,
|
|
109
|
+
* and stamps `scope` on every EngineAccountSummary it advertises. The cloud
|
|
110
|
+
* sends none of those to a host without the feature (the v1 exact-shape
|
|
111
|
+
* guards would reject the frames). */
|
|
112
|
+
export const HOST_ENGINE_ACCOUNT_SCOPES_PROTOCOL_FEATURE =
|
|
113
|
+
"host-engine-account-scopes-v1";
|
|
114
|
+
/** The owner-level account scope; every pre-scope account lives here. */
|
|
115
|
+
export const HOST_ACCOUNT_SCOPE = "host";
|
|
116
|
+
const ORG_ACCOUNT_SCOPE_RE = /^org:[A-Za-z0-9_-]{1,128}$/;
|
|
117
|
+
/** `host` or `org:<orgId>` — bounded, single-token, safe in every surface. */
|
|
118
|
+
export function isEngineAccountScope(value: unknown): value is string {
|
|
119
|
+
return (
|
|
120
|
+
value === HOST_ACCOUNT_SCOPE ||
|
|
121
|
+
(typeof value === "string" && ORG_ACCOUNT_SCOPE_RE.test(value))
|
|
122
|
+
);
|
|
123
|
+
}
|
|
105
124
|
export const MAX_ENGINE_CONNECT_SECRET_CHARS = 8_192;
|
|
106
125
|
export const MAX_ENGINE_CONNECT_LINE_CHARS = 512;
|
|
107
126
|
export const MAX_ENGINE_CATALOG_ENTRIES = 8;
|
|
@@ -307,6 +326,10 @@ export interface EngineLoginStartFrame {
|
|
|
307
326
|
* slot. Only sent to hosts advertising `host-engine-accounts-v1`; the v1
|
|
308
327
|
* exact-shape guard on older hosts rejects frames carrying it. */
|
|
309
328
|
label?: string;
|
|
329
|
+
/** ADR-131 D3: capture into this account scope (`org:<orgId>` on a shared
|
|
330
|
+
* host). Only sent to hosts advertising `host-engine-account-scopes-v1`;
|
|
331
|
+
* absent = the host scope. An org scope requires a label. */
|
|
332
|
+
scope?: string;
|
|
310
333
|
}
|
|
311
334
|
|
|
312
335
|
/** The only generic one-time response channel. Long-lived tokens must never
|
|
@@ -375,10 +398,14 @@ export interface EngineLoginCapability {
|
|
|
375
398
|
* Operator-chosen label + shape metadata only — never credentials. */
|
|
376
399
|
export interface EngineAccountSummary {
|
|
377
400
|
id: string;
|
|
378
|
-
|
|
401
|
+
/** grok/cursor since host 0.9.101 (API-key accounts, ADR-131 D3). */
|
|
402
|
+
kind: "claude" | "codex" | "grok" | "cursor" | "opencode";
|
|
379
403
|
label: string;
|
|
380
404
|
authKind: "env" | "config-dir";
|
|
381
405
|
isDefault: boolean;
|
|
406
|
+
/** ADR-131 D3: `host` or `org:<orgId>`. Absent from hosts that predate
|
|
407
|
+
* scopes — read as `host`. */
|
|
408
|
+
scope?: string;
|
|
382
409
|
}
|
|
383
410
|
|
|
384
411
|
/** ADR-117 engine kinds — every engine the host's descriptor table knows. */
|
|
@@ -416,6 +443,8 @@ export interface EngineConnectStartFrame {
|
|
|
416
443
|
engine: EngineConnectKind;
|
|
417
444
|
secret?: string;
|
|
418
445
|
label?: string;
|
|
446
|
+
/** ADR-131 D3: see EngineLoginStartFrame.scope. */
|
|
447
|
+
scope?: string;
|
|
419
448
|
}
|
|
420
449
|
|
|
421
450
|
export interface EngineConnectStopFrame {
|
|
@@ -454,13 +483,14 @@ export function isEngineConnectStartFrame(
|
|
|
454
483
|
const frame = exactWireObject(
|
|
455
484
|
value,
|
|
456
485
|
["kind", "opId", "engine"],
|
|
457
|
-
["secret", "label"],
|
|
486
|
+
["secret", "label", "scope"],
|
|
458
487
|
);
|
|
459
488
|
if (
|
|
460
489
|
!frame ||
|
|
461
490
|
frame.kind !== "engine.connect.start" ||
|
|
462
491
|
!isHostOpId(frame.opId) ||
|
|
463
|
-
!isEngineConnectKind(frame.engine)
|
|
492
|
+
!isEngineConnectKind(frame.engine) ||
|
|
493
|
+
(frame.scope !== undefined && !isEngineAccountScope(frame.scope))
|
|
464
494
|
) {
|
|
465
495
|
return false;
|
|
466
496
|
}
|
|
@@ -705,13 +735,18 @@ export function isHostLogsEndFrame(value: unknown): value is HostLogsEndFrame {
|
|
|
705
735
|
export function isEngineLoginStartFrame(
|
|
706
736
|
value: unknown,
|
|
707
737
|
): value is EngineLoginStartFrame {
|
|
708
|
-
const frame = exactWireObject(
|
|
738
|
+
const frame = exactWireObject(
|
|
739
|
+
value,
|
|
740
|
+
["kind", "opId", "engine"],
|
|
741
|
+
["label", "scope"],
|
|
742
|
+
);
|
|
709
743
|
return Boolean(
|
|
710
744
|
frame &&
|
|
711
745
|
frame.kind === "engine.login.start" &&
|
|
712
746
|
isHostOpId(frame.opId) &&
|
|
713
747
|
isEngineLoginKind(frame.engine) &&
|
|
714
|
-
(frame.label === undefined || isEngineAccountLabel(frame.label))
|
|
748
|
+
(frame.label === undefined || isEngineAccountLabel(frame.label)) &&
|
|
749
|
+
(frame.scope === undefined || isEngineAccountScope(frame.scope)),
|
|
715
750
|
);
|
|
716
751
|
}
|
|
717
752
|
|
|
@@ -1087,6 +1122,9 @@ export interface TaskCommandTask {
|
|
|
1087
1122
|
previewEnv?: Record<string, string>;
|
|
1088
1123
|
/** ADR-062: owning org — locates the org shared-files root on the host. */
|
|
1089
1124
|
ownerOrgId?: string;
|
|
1125
|
+
/** ADR-131 D3: the engine-account scope the task's agents resolve
|
|
1126
|
+
* credentials from (`org:<orgId>` on a shared host). Absent = `host`. */
|
|
1127
|
+
engineAccountScope?: string;
|
|
1090
1128
|
/** ADR-062: shared-files mount mode ("off" | "ro" | "rw"; default "ro"). */
|
|
1091
1129
|
sharedFiles?: string;
|
|
1092
1130
|
}
|
|
@@ -1188,6 +1226,10 @@ export interface ChannelEnsureInput {
|
|
|
1188
1226
|
/** ADR-053: any joined project opted into browser testing — the host wires
|
|
1189
1227
|
* the Playwright MCP browser at session start. */
|
|
1190
1228
|
browserTesting?: boolean;
|
|
1229
|
+
/** ADR-131 D3: see TaskCommandTask.engineAccountScope — the channel's
|
|
1230
|
+
* agents pick accounts from this scope. Absent = the task's persisted
|
|
1231
|
+
* scope, else `host`. */
|
|
1232
|
+
engineAccountScope?: string;
|
|
1191
1233
|
/** ADR-057: the owner's usable MCP connections (policy "on", connected,
|
|
1192
1234
|
* remote). The host writes gateway-URL MCP configs at session start. */
|
|
1193
1235
|
mcpConnections?: Array<{ id: string; slug: string }>;
|
|
@@ -1358,7 +1400,7 @@ export interface HostCommands {
|
|
|
1358
1400
|
* exactly like the local API; re-advertises capabilities on success. */
|
|
1359
1401
|
engineAccountRemove(
|
|
1360
1402
|
ctx: CommandContext,
|
|
1361
|
-
input: { id: string },
|
|
1403
|
+
input: { id: string; scope?: string },
|
|
1362
1404
|
): Promise<HostCommandResult<{ removed: boolean; message?: string }>>;
|
|
1363
1405
|
cloneRepo(
|
|
1364
1406
|
ctx: CommandContext,
|