@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.
- package/db/migrations/0022_task_project_ids.sql +5 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +3 -0
- package/lib/browser-testing.ts +7 -1
- package/lib/host-env.ts +46 -0
- package/lib/orchestrator.ts +44 -5
- package/lib/runtime-state.ts +16 -0
- package/package.json +1 -1
- package/src/index.ts +4 -0
|
@@ -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/browser-testing.ts
CHANGED
|
@@ -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}
|
|
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
|
+
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -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 {
|
|
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)
|
|
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
|
-
|
|
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
|
|
package/lib/runtime-state.ts
CHANGED
|
@@ -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
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.
|