@runuai/host 0.9.118 → 0.9.120

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,5 @@
1
+ -- Machine tasks (ADR-121) have no compose env, so per-project env values
2
+ -- (ADR-015) must ride each agent session's env instead — and the host only
3
+ -- knew a task's project SLUGS while env is keyed by project id. Lead project
4
+ -- first. Older rows stay '[]' (Docker tasks never read it).
5
+ ALTER TABLE `uai_host_tasks` ADD `project_ids` text NOT NULL DEFAULT '[]';
@@ -155,6 +155,13 @@
155
155
  "when": 1788900000003,
156
156
  "tag": "0021_project_env_store_ref",
157
157
  "breakpoints": true
158
+ },
159
+ {
160
+ "idx": 22,
161
+ "version": "6",
162
+ "when": 1788900000004,
163
+ "tag": "0022_task_project_ids",
164
+ "breakpoints": true
158
165
  }
159
166
  ]
160
167
  }
package/db/schema.ts CHANGED
@@ -37,6 +37,9 @@ 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
+ // Lead project first. Machine tasks resolve per-project env through these
41
+ // ids at every agent spawn (Docker tasks take env from compose instead).
42
+ projectIds: text("project_ids").notNull().default("[]"),
40
43
  // ADR-131 D3: which engine-account scope this task resolves credentials
41
44
  // from ("org:<orgId>" on a shared host; null = "host"). Persisted so a
42
45
  // host restart re-provisions the same scope before the cloud re-sends it.
package/lib/host-env.ts CHANGED
@@ -209,3 +209,49 @@ export function getDecryptedForProject(projectId: string): Record<string, string
209
209
  }
210
210
  return out;
211
211
  }
212
+
213
+ /**
214
+ * Names a project may declare but a task must never receive from project env —
215
+ * the same list task-up.sh's `is_reserved_task_env_name` enforces for
216
+ * container tasks: git/gh plumbing, loader and shell hooks, toolchain config,
217
+ * the host's own `UAI_*` namespace, engine credentials, and the browser path.
218
+ */
219
+ const RESERVED_TASK_ENV_EXACT = new Set([
220
+ "GH_TOKEN", "GITHUB_TOKEN", "GH_CONFIG_DIR", "GH_HOST",
221
+ "GIT_DIR", "GIT_WORK_TREE", "GIT_COMMON_DIR", "GIT_OBJECT_DIRECTORY",
222
+ "GIT_ALTERNATE_OBJECT_DIRECTORIES", "GIT_INDEX_FILE", "GIT_EXEC_PATH",
223
+ "GIT_ASKPASS", "SSH_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND",
224
+ "HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME",
225
+ "PATH", "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "NODE_OPTIONS",
226
+ "NODE_PATH", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
227
+ "CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN",
228
+ "XAI_API_KEY", "PLAYWRIGHT_BROWSERS_PATH",
229
+ ]);
230
+ const RESERVED_TASK_ENV_NPM = /^npm_config_(prefix|cache|logs[_-]dir|umask)$/i;
231
+
232
+ export function isReservedTaskEnvName(name: string): boolean {
233
+ if (RESERVED_TASK_ENV_EXACT.has(name)) return true;
234
+ if (name.startsWith("GIT_CONFIG") || name.startsWith("ASDF_")) return true;
235
+ if (name.startsWith("COREPACK_") || name.startsWith("UAI_")) return true;
236
+ return RESERVED_TASK_ENV_NPM.test(name);
237
+ }
238
+
239
+ /**
240
+ * Project env for a task that has no container env to carry it (ADR-121
241
+ * machine tasks): every project's decrypted values, lead project winning a
242
+ * key collision, minus the reserved names above — exactly what task-up hands
243
+ * Compose for a Docker task. Live 2026-09-09: a machine task on Uai Cloud
244
+ * had the Infisical identity in the store and the host, and the agent never
245
+ * saw it, because only the compose path injected project env.
246
+ */
247
+ export function projectEnvForTask(projectIds: readonly string[]): Record<string, string> {
248
+ const merged: Record<string, string> = {};
249
+ for (const projectId of [...projectIds].reverse()) {
250
+ Object.assign(merged, getDecryptedForProject(projectId));
251
+ }
252
+ const out: Record<string, string> = {};
253
+ for (const [key, value] of Object.entries(merged)) {
254
+ if (!isReservedTaskEnvName(key)) out[key] = value;
255
+ }
256
+ return out;
257
+ }
@@ -43,7 +43,8 @@ import {
43
43
  } from "./agents/types";
44
44
  import { ACTIVE_STATUSES, isActive, isOnDashboard } from "./task-status";
45
45
  import { isSafeHostTaskId } from "./task-identity";
46
- import { getHostTask, upsertHostTask } from "./runtime-state";
46
+ import { projectEnvForTask } from "./host-env";
47
+ import { getHostTask, hostTaskProjectIds, upsertHostTask } from "./runtime-state";
47
48
  import { clearRefresh, reconcileTaskGitAuth } from "./github-tokens";
48
49
  import { ensureTaskSshIdentity, setupTaskGitIdentity } from "./git-identity";
49
50
  import { dockerCli } from "./docker-exec";
@@ -1505,6 +1506,7 @@ export class Orchestrator {
1505
1506
  cliSecret,
1506
1507
  this.isPromptLevelSecretary(channel, agent.id),
1507
1508
  ),
1509
+ this.machineProjectEnv(task),
1508
1510
  ),
1509
1511
  },
1510
1512
  );
@@ -1575,17 +1577,35 @@ export class Orchestrator {
1575
1577
  channel: Channel,
1576
1578
  agent: RosterAgent,
1577
1579
  base: Record<string, string>,
1580
+ /** Lowest precedence: project env for tasks whose environment has no
1581
+ * container env to carry it. The account's env and the uai base win. */
1582
+ floor: Record<string, string> = {},
1578
1583
  ): Record<string, string> {
1579
1584
  const account = pickEngineAccount(agent.kind, {
1580
1585
  scope: channel.engineAccountScope,
1581
1586
  });
1582
1587
  if (!account) {
1583
1588
  channel.accountByAgent.delete(agent.id);
1584
- return base;
1589
+ return { ...floor, ...base };
1585
1590
  }
1586
1591
  channel.accountByAgent.set(agent.id, account.id);
1587
1592
  noteEngineAccountUsed(account.id);
1588
- return { ...account.execEnv, ...base };
1593
+ return { ...floor, ...account.execEnv, ...base };
1594
+ }
1595
+
1596
+ /**
1597
+ * ADR-015 project env for a machine task (ADR-121): Docker tasks receive it
1598
+ * from the compose env task-up renders, but a machine has no container, so
1599
+ * it rides every agent session's env instead. Read at each spawn, so a
1600
+ * respawn picks up values set since the task started. Empty for container
1601
+ * tasks — their env already carries it and must not be doubled into argv.
1602
+ */
1603
+ private machineProjectEnv(task: {
1604
+ environmentProvider: string | null;
1605
+ projectIds?: string | null;
1606
+ }): Record<string, string> {
1607
+ if (task.environmentProvider !== "machine") return {};
1608
+ return projectEnvForTask(hostTaskProjectIds(task));
1589
1609
  }
1590
1610
 
1591
1611
  /** Install a session with an event handler scoped to this exact generation. */
@@ -1939,7 +1959,13 @@ export class Orchestrator {
1939
1959
  return false;
1940
1960
  }
1941
1961
 
1942
- this.emitTaskStage(channel.taskId, "agents");
1962
+ // Live 2026-09-10 (Mac Studio, host 0.9.119): a reconcile pass had
1963
+ // already spawned the whole roster, bindSession had emitted `ready`, and
1964
+ // this unconditional `agents` then overwrote it — the page stayed
1965
+ // covered for good. Only announce the wake-up when there is one to do.
1966
+ const allBound = (): boolean =>
1967
+ initialRoster.every((agent) => channel.sessions.has(agent.id));
1968
+ if (!allBound()) this.emitTaskStage(channel.taskId, "agents");
1943
1969
  for (const agent of initialRoster) {
1944
1970
  if (!this.isActiveChannel(channel)) return false;
1945
1971
  // A prior partial start or an independently guarded recovery may already
@@ -1972,6 +1998,7 @@ export class Orchestrator {
1972
1998
  cliSecret,
1973
1999
  this.isPromptLevelSecretary(channel, agent.id),
1974
2000
  ),
2001
+ this.machineProjectEnv(task),
1975
2002
  ),
1976
2003
  },
1977
2004
  );
@@ -1997,8 +2024,12 @@ export class Orchestrator {
1997
2024
  // so it both duplicated the opening turn AND re-injected it on a host
1998
2025
  // restart, making agents redo the first turn mid-task. The persona/mission
1999
2026
  // an agent needs live in the always-on system preamble, so nothing is lost.
2000
- // `ready` itself was emitted by bindSession as the last roster agent
2001
- // bound (or above, when one could not).
2027
+ // `ready` is emitted by bindSession as the last roster agent binds (or
2028
+ // above, when one could not). Re-assert it here for the roster that was
2029
+ // already bound when this loop began — every bind then predates this
2030
+ // pass and could have been overwritten by an `agents` from a start
2031
+ // that lost the race.
2032
+ if (allBound()) this.emitTaskStage(channel.taskId, "ready");
2002
2033
  return true;
2003
2034
  }
2004
2035
 
@@ -232,16 +232,32 @@ export function recordTaskOwner(
232
232
  ownerName: string | null,
233
233
  projectSlugs: string[],
234
234
  engineAccountScope: string | null = null,
235
+ /** Lead project first — the order project env merges in. */
236
+ projectIds: string[] = [],
235
237
  ): void {
236
238
  upsertHostTask(taskId, {
237
239
  ownerUserId,
238
240
  ownerEmail,
239
241
  ownerName,
240
242
  projectSlugs: JSON.stringify(projectSlugs),
243
+ projectIds: JSON.stringify(projectIds),
241
244
  engineAccountScope,
242
245
  });
243
246
  }
244
247
 
248
+ /** The task's project ids, lead project first; `[]` for rows that predate
249
+ * the column or carry an unreadable value. */
250
+ export function hostTaskProjectIds(task: { projectIds?: string | null }): string[] {
251
+ try {
252
+ const parsed: unknown = JSON.parse(task.projectIds ?? "[]");
253
+ return Array.isArray(parsed)
254
+ ? parsed.filter((id): id is string => typeof id === "string" && id.length > 0)
255
+ : [];
256
+ } catch {
257
+ return [];
258
+ }
259
+ }
260
+
245
261
  /** Append a task lifecycle event to the host event log (local `/api/events`). */
246
262
  export function recordHostEvent(
247
263
  taskId: string,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@runuai/host",
3
- "version": "0.9.118",
3
+ "version": "0.9.120",
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
@@ -776,6 +776,10 @@ export const hostCommands: HostCommands = {
776
776
  input.projects.map((p) => p.slug),
777
777
  // ADR-131 D3: persisted so recovery re-provisions the same scope.
778
778
  input.task.engineAccountScope ?? null,
779
+ // Lead project first (position 0 wins env collisions, as in task-up).
780
+ [...input.projects]
781
+ .sort((a, b) => a.position - b.position)
782
+ .map((p) => p.id),
779
783
  );
780
784
  // ADR-048: persist the cloud-issued per-task cli secret (host-private) so the
781
785
  // `uai` CLI's per-agent tokens can be minted now + after a host restart.