@runuai/host 0.9.100 → 0.9.102

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.
@@ -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
  }
@@ -64,6 +64,11 @@ interface KindDescriptor {
64
64
 
65
65
  const KINDS: Record<string, KindDescriptor> = {
66
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" },
67
72
  codex: {
68
73
  authKind: "config-dir",
69
74
  defaultHostDir: (owner) => join(owner, ".codex"),
@@ -82,6 +87,39 @@ const KINDS: Record<string, KindDescriptor> = {
82
87
  },
83
88
  };
84
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
+
85
123
  /** Public (secret-free) account descriptor for the host UI. */
86
124
  export interface EngineAccountInfo {
87
125
  id: string;
@@ -672,6 +710,28 @@ export function addEngineAccount(
672
710
  return { ok: true, message: `Added Claude account "${trimmedLabel}".`, id };
673
711
  }
674
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
+
675
735
  if (kind === "codex") {
676
736
  const d = KINDS.codex as KindDescriptor;
677
737
  const hostDir = (d.extraHostDir as (o: string, i: string) => string)(
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.100",
3
+ "version": "0.9.102",
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/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 { listEngineAccountsAllScopes } 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 {
@@ -225,6 +229,7 @@ import {
225
229
  type TaskDownInput,
226
230
  type TaskLaunchInput,
227
231
  type FilesOpInput,
232
+ engineAccountScopeFromWire,
228
233
  } from "./protocol";
229
234
 
230
235
  setContainerRuntimeOperationalCheck(() => containerRuntimeProblem() === null);
@@ -697,7 +702,7 @@ function engineCatalogCapabilities(): EngineCatalogCapability[] {
697
702
  /** ADR-116: the labeled-accounts advertisement — bounded, labels-only. */
698
703
  function engineAccountSummaries(): EngineAccountSummary[] {
699
704
  const summaries: EngineAccountSummary[] = [];
700
- for (const kind of ["claude", "codex", "opencode"] as const) {
705
+ for (const kind of ["claude", "codex", "grok", "cursor", "opencode"] as const) {
701
706
  try {
702
707
  // ADR-131 D3: every scope — the cloud filters per caller.
703
708
  for (const account of listEngineAccountsAllScopes(kind)) {
@@ -744,6 +749,9 @@ function flushStoppedTaskStatuses(socket: WebSocket | null = ws): void {
744
749
  });
745
750
  }
746
751
 
752
+ // ADR-131 D3: a stored account (any scope) makes a kind advertisable even
753
+ // when the host's own credential is absent — see hasStoredEngineAccounts.
754
+ setAdvertisementFallback((kind) => hasStoredEngineAccounts(kind));
747
755
  onRegistryChange(() => sendCapabilities());
748
756
  onContainerRuntimeChange((next, previous) => {
749
757
  if (stopping) return;
@@ -3198,6 +3206,9 @@ function expectChannelEnsureInput(
3198
3206
  if (typeof input.globalContext === "string") {
3199
3207
  out.globalContext = input.globalContext;
3200
3208
  }
3209
+ // ADR-131 D3: same scope the task launched with (refreshChannel adopts it).
3210
+ const channelScope = engineAccountScopeFromWire(input.engineAccountScope);
3211
+ if (channelScope !== undefined) out.engineAccountScope = channelScope;
3201
3212
  // ADR-083: secretary identity drives the role-specific transcript preamble.
3202
3213
  // Both are optional for wire compatibility with older clouds.
3203
3214
  const mode = parseChannelMode(input.mode);
@@ -3300,6 +3311,14 @@ function expectTaskCommandTask(value: unknown): TaskCommandTask {
3300
3311
  ) {
3301
3312
  out.sharedFiles = row.sharedFiles;
3302
3313
  }
3314
+ // ADR-131 D3: the workspace whose engine accounts this task resolves.
3315
+ // Dropping it here silently re-scoped every shared-host task to the host's
3316
+ // own credentials (live 2026-09-07) — see engineAccountScopeFromWire.
3317
+ const taskScope = engineAccountScopeFromWire(
3318
+ row.engineAccountScope,
3319
+ "task.engineAccountScope",
3320
+ );
3321
+ if (taskScope !== undefined) out.engineAccountScope = taskScope;
3303
3322
  return out;
3304
3323
  }
3305
3324
 
package/src/protocol.ts CHANGED
@@ -121,6 +121,26 @@ export function isEngineAccountScope(value: unknown): value is string {
121
121
  (typeof value === "string" && ORG_ACCOUNT_SCOPE_RE.test(value))
122
122
  );
123
123
  }
124
+
125
+ /**
126
+ * ADR-131 D3: read an optional `engineAccountScope` off a wire object.
127
+ * Returns the scope when present and well-formed, undefined when absent, and
128
+ * THROWS on a malformed value — a tenant scope must never degrade silently
129
+ * to the host's credentials. LIVE 2026-09-07: the host's task-up and
130
+ * channel-ensure validators rebuilt their inputs field by field and never
131
+ * copied this key, so every shared-host task ran in the HOST scope — Codex
132
+ * had no account there and looped "Reconnecting" while the cloud had sent
133
+ * `org:<id>` all along. Every `expect*Input` that carries a scope must call
134
+ * this; `host-agent/src/protocol.test.ts` pins the shape.
135
+ */
136
+ export function engineAccountScopeFromWire(
137
+ value: unknown,
138
+ field = "engineAccountScope",
139
+ ): string | undefined {
140
+ if (value === undefined || value === null) return undefined;
141
+ if (isEngineAccountScope(value)) return value;
142
+ throw new Error(`invalid ${field}`);
143
+ }
124
144
  export const MAX_ENGINE_CONNECT_SECRET_CHARS = 8_192;
125
145
  export const MAX_ENGINE_CONNECT_LINE_CHARS = 512;
126
146
  export const MAX_ENGINE_CATALOG_ENTRIES = 8;
@@ -398,7 +418,8 @@ export interface EngineLoginCapability {
398
418
  * Operator-chosen label + shape metadata only — never credentials. */
399
419
  export interface EngineAccountSummary {
400
420
  id: string;
401
- kind: "claude" | "codex" | "opencode";
421
+ /** grok/cursor since host 0.9.101 (API-key accounts, ADR-131 D3). */
422
+ kind: "claude" | "codex" | "grok" | "cursor" | "opencode";
402
423
  label: string;
403
424
  authKind: "env" | "config-dir";
404
425
  isDefault: boolean;