@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.
@@ -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
 
@@ -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
- this.apiKey = args.apiKey ?? process.env.CURSOR_API_KEY ?? "";
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 {
@@ -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((adapter) => adapter.available?.() ?? true)
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
  }
@@ -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(kind: string, s: AccountSeams): ResolvedEngineAccount[] {
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((r) => r.kind === kind);
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
- const def = synthesizeDefault(kind, s);
255
- if (def) accounts.push(def);
256
- accounts.push(...resolveExtras(kind, s));
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: { exclude?: Set<string>; seams?: Partial<AccountSeams>; now?: number } = {},
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(kind, opts.seams ?? {});
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
- export function removeEngineAccount(id: string): { ok: boolean; message: string } {
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 {
@@ -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(engine, label, { token: secret });
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,
@@ -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(label: string, token: string): { ok: boolean; message: string };
149
- addCodexAccount(label: string, authJson: string): { ok: boolean; message: string };
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(operation.accountLabel, token);
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(operation.accountLabel, contents);
1048
+ added = this.seams.addCodexAccount(
1049
+ operation.accountLabel,
1050
+ contents,
1051
+ operation.accountScope,
1052
+ );
1015
1053
  } catch {
1016
1054
  added = {
1017
1055
  ok: false,
@@ -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(latestKinds);
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) return false;
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];
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.99",
3
+ "version": "0.9.101",
4
4
  "description": "Uai host — runs ephemeral AI tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Uai Tech <team@runuai.com>",
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 { listEngineAccounts } from "../lib/engine-accounts";
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
- for (const account of listEngineAccounts(kind)) {
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
- return { id: (value as { id: string }).id };
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
- kind: "claude" | "codex" | "opencode";
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(value, ["kind", "opId", "engine"], ["label"]);
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,