@runuai/host 0.9.117 → 0.9.119

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.
@@ -1048,8 +1048,14 @@ const DEFAULT_NODE_BIN =
1048
1048
  `/opt/asdf-data/installs/nodejs/${STANDARD_DEFAULT_NODE_VERSION}/bin`;
1049
1049
  const ROOT_RUNTIME_PATH =
1050
1050
  `${DEFAULT_NODE_BIN}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`;
1051
+ // `npx` through ROOT_RUNTIME_PATH, not an absolute asdf path: the machine
1052
+ // image (ADR-121) carries no asdf — node is /usr/bin/node there — and the
1053
+ // absolute path made this worker exit 127 on every task machine, so its
1054
+ // done-marker was never written and each ensure re-ran it (live 2026-09-09).
1055
+ // The path lists the asdf bin first and never the shims, so a container
1056
+ // resolves exactly what it did before.
1051
1057
  const APT_DEPS_CMD =
1052
- `PATH=${ROOT_RUNTIME_PATH} ${DEFAULT_NODE_BIN}/npx ` +
1058
+ `PATH=${ROOT_RUNTIME_PATH} npx ` +
1053
1059
  "-y playwright@latest install-deps chromium";
1054
1060
  const APT_VIEWER_CMD =
1055
1061
  "/usr/bin/apt-get install -y --no-install-recommends xvfb x11vnc novnc websockify";
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. */
@@ -1595,6 +1615,15 @@ export class Orchestrator {
1595
1615
  session: AgentSession,
1596
1616
  ): void {
1597
1617
  channel.sessions.set(agentId, session);
1618
+ // The launch screen's `ready` rides the bind, not the tail of the start
1619
+ // loop: whichever path brings the last roster agent up — the initial
1620
+ // start, a reconcile that won the race with it, a respawn after Resume —
1621
+ // is the moment the task can take a message. Live 2026-09-09: `agents`
1622
+ // was stored, Jonah was up and idle, and no `ready` ever followed, so
1623
+ // the page stayed covered for minutes. Idempotent on the cloud side.
1624
+ if (channel.roster.every((agent) => channel.sessions.has(agent.id))) {
1625
+ this.emitTaskStage(channel.taskId, "ready");
1626
+ }
1598
1627
  // Adapters may synchronously emit `error` followed by `turn_complete` (or
1599
1628
  // `exit`). Terminal classification now awaits a daemon proof, so preserve
1600
1629
  // transport emission order across that await; otherwise the later boundary
@@ -1963,10 +1992,19 @@ export class Orchestrator {
1963
1992
  cliSecret,
1964
1993
  this.isPromptLevelSecretary(channel, agent.id),
1965
1994
  ),
1995
+ this.machineProjectEnv(task),
1966
1996
  ),
1967
1997
  },
1968
1998
  );
1969
- if (!session) return false;
1999
+ if (!session) {
2000
+ // The agents that did come up are usable; the reconcile pass keeps
2001
+ // retrying this one. Do not leave the page covered for a session
2002
+ // that may never bind — `ready` otherwise rides bindSession.
2003
+ if (channel.sessions.size > 0) {
2004
+ this.emitTaskStage(channel.taskId, "ready");
2005
+ }
2006
+ return false;
2007
+ }
1970
2008
  this.startNextPendingPrompt(channel, agent.id);
1971
2009
  } finally {
1972
2010
  channel.spawning.delete(agent.id);
@@ -1980,7 +2018,8 @@ export class Orchestrator {
1980
2018
  // so it both duplicated the opening turn AND re-injected it on a host
1981
2019
  // restart, making agents redo the first turn mid-task. The persona/mission
1982
2020
  // an agent needs live in the always-on system preamble, so nothing is lost.
1983
- this.emitTaskStage(channel.taskId, "ready");
2021
+ // `ready` itself was emitted by bindSession as the last roster agent
2022
+ // bound (or above, when one could not).
1984
2023
  return true;
1985
2024
  }
1986
2025
 
@@ -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.117",
3
+ "version": "0.9.119",
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.