@runuai/host 0.9.14 → 0.9.42

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.
Files changed (97) hide show
  1. package/README.md +22 -5
  2. package/db/migrations/0014_host_inventory_event_index.sql +1 -0
  3. package/db/migrations/0015_host_settings.sql +9 -0
  4. package/db/migrations/0016_task_environment.sql +2 -0
  5. package/db/migrations/meta/_journal.json +21 -0
  6. package/db/schema.ts +80 -30
  7. package/images/standard/Dockerfile +36 -10
  8. package/images/standard/README.md +63 -18
  9. package/images/standard/container/corepack-version +1 -0
  10. package/images/standard/container/uai-init +308 -38
  11. package/images/standard/container/uai-materialize-runtimes +1527 -0
  12. package/lib/agent-cli.ts +33 -2
  13. package/lib/agent.ts +46 -7
  14. package/lib/agents/claude.ts +13 -8
  15. package/lib/agents/codex.ts +11 -6
  16. package/lib/agents/cursor.ts +39 -29
  17. package/lib/agents/durable-proc.ts +20 -27
  18. package/lib/agents/factory.ts +9 -25
  19. package/lib/agents/grok.ts +43 -30
  20. package/lib/agents/kimi.ts +44 -29
  21. package/lib/agents/opencode.ts +43 -31
  22. package/lib/agents/proc.ts +149 -114
  23. package/lib/agents/transport.ts +62 -50
  24. package/lib/agents/types.ts +6 -4
  25. package/lib/apple-runtime-recycle.ts +236 -0
  26. package/lib/apple-uninstall-teardown.ts +224 -0
  27. package/lib/browser-testing.ts +233 -93
  28. package/lib/codex-auth.ts +40 -6
  29. package/lib/command-db.ts +20 -0
  30. package/lib/container-runtime.ts +1338 -0
  31. package/lib/db.ts +1 -0
  32. package/lib/docker-exec.ts +87 -5
  33. package/lib/engine-accounts.ts +68 -5
  34. package/lib/engine-login.ts +1952 -0
  35. package/lib/enrollment-state.ts +251 -0
  36. package/lib/env-file.ts +155 -0
  37. package/lib/env.ts +4 -0
  38. package/lib/git-diff.ts +98 -32
  39. package/lib/git-identity.ts +199 -87
  40. package/lib/github-tokens.ts +202 -91
  41. package/lib/host-cloud-url.ts +62 -0
  42. package/lib/host-config.ts +279 -0
  43. package/lib/host-logs.ts +962 -0
  44. package/lib/keyed-promise-tail.ts +23 -0
  45. package/lib/legacy-runtime-v1.fixture.ts +627 -0
  46. package/lib/managed-activation-watcher.ts +72 -0
  47. package/lib/managed-install-owner-watcher.ts +55 -0
  48. package/lib/managed-operation-drain.ts +49 -0
  49. package/lib/managed-runtime.ts +3644 -0
  50. package/lib/managed-update-scheduler.ts +125 -0
  51. package/lib/mcp-gateway.ts +450 -23
  52. package/lib/orchestrator.ts +3060 -218
  53. package/lib/preview-sidecar.ts +57 -13
  54. package/lib/release-manifest.ts +708 -0
  55. package/lib/release-trust.ts +28 -0
  56. package/lib/runtime-activation-tail.ts +232 -0
  57. package/lib/runtime-archive.ts +1086 -0
  58. package/lib/runtime-authority.ts +79 -0
  59. package/lib/runtime-guard.ts +36 -0
  60. package/lib/runtime-provider-state.ts +169 -0
  61. package/lib/runtime-state.ts +232 -12
  62. package/lib/skills.ts +24 -3
  63. package/lib/ssh.ts +18 -0
  64. package/lib/standard-image.ts +1104 -141
  65. package/lib/stopped-task-status-queue.ts +44 -0
  66. package/lib/task-container-cli.ts +269 -0
  67. package/lib/task-diff.ts +66 -46
  68. package/lib/task-environment/apple-container.ts +757 -0
  69. package/lib/task-environment/docker.ts +945 -0
  70. package/lib/task-environment/index.ts +364 -0
  71. package/lib/task-environment/legacy-adoption.ts +443 -0
  72. package/lib/task-environment/registry.ts +58 -0
  73. package/lib/task-environment/types.ts +408 -0
  74. package/lib/task-identity.ts +19 -0
  75. package/lib/task-inventory.ts +585 -0
  76. package/lib/tunnel-registry.ts +135 -19
  77. package/lib/tunnel-runtime.ts +235 -0
  78. package/package.json +1 -1
  79. package/scripts/agent/_common.sh +123 -3
  80. package/scripts/agent/task-down.sh +146 -38
  81. package/scripts/agent/task-status.sh +19 -3
  82. package/scripts/agent/task-up.sh +1405 -107
  83. package/scripts/install/darwin.ts +848 -50
  84. package/scripts/install/linux.ts +838 -35
  85. package/scripts/install/types.ts +43 -0
  86. package/scripts/install/util.ts +215 -8
  87. package/scripts/install/win.ts +12 -0
  88. package/src/apple-tunnel-route.ts +104 -0
  89. package/src/cli.ts +1464 -72
  90. package/src/event-outbox.ts +83 -4
  91. package/src/index.ts +766 -42
  92. package/src/main.ts +1398 -255
  93. package/src/paths.ts +17 -1
  94. package/src/protocol.ts +695 -1
  95. package/src/runtime-bootstrap.ts +165 -0
  96. package/src/ui/server.ts +46 -10
  97. package/src/ui/types.ts +37 -0
@@ -43,8 +43,18 @@
43
43
  */
44
44
  import { createHash } from "node:crypto";
45
45
 
46
- import { dockerCli } from "./docker-exec";
46
+ import {
47
+ taskAppContainerName,
48
+ taskContainerBackend,
49
+ taskContainerCli,
50
+ } from "./task-container-cli";
47
51
  import { MCP_CONFIG_LOCK_PATH } from "./mcp-config-lock";
52
+ import {
53
+ RUNTIME_AUTHORITY_ENV,
54
+ runtimeAuthorityDockerExecArgs,
55
+ } from "./runtime-authority";
56
+ import { STANDARD_DEFAULT_NODE_VERSION } from "./standard-image";
57
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
48
58
 
49
59
  const SERVER_DISPLAY = ":99";
50
60
  /**
@@ -935,17 +945,23 @@ const VIEWER_STEPS: string[] = [
935
945
  // Xvfb — one-shot; the X lockfile is the truth (also set when an agent
936
946
  // started the display itself). If it dies the lock clears and the next
937
947
  // session ensure relaunches it.
938
- `command -v Xvfb >/dev/null 2>&1 || exit 0; [ -e ${X_LOCK} ] || nohup ${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
948
+ `[ -x /usr/bin/Xvfb ] || exit 0; [ -e ${X_LOCK} ] || /usr/bin/nohup /usr/bin/${XVFB_CMD} >>/tmp/uai-xvfb.log 2>&1 &`,
939
949
  // x11vnc under a restart loop (it exits whenever the X server isn't up
940
950
  // yet). Pidfile-guarded.
941
- `command -v x11vnc >/dev/null 2>&1 || exit 0; [ -f /tmp/uai-x11vnc.pid ] && kill -0 "$(cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; nohup sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; sleep 2; done' >/dev/null 2>&1 &`,
951
+ `[ -x /usr/bin/x11vnc ] || exit 0; [ -f /tmp/uai-x11vnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-x11vnc.pid)" 2>/dev/null && exit 0; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-x11vnc.pid; while true; do /usr/bin/x11vnc -display ${SERVER_DISPLAY} -forever -shared -nopw -quiet >>/tmp/uai-x11vnc.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
942
952
  // websockify/noVNC under the same pattern.
943
- `command -v websockify >/dev/null 2>&1 || exit 0; [ -f /tmp/uai-novnc.pid ] && kill -0 "$(cat /tmp/uai-novnc.pid)" 2>/dev/null && exit 0; nohup sh -c 'echo $$ > /tmp/uai-novnc.pid; while true; do websockify --web=/usr/share/novnc 0.0.0.0:6080 localhost:5900 >>/tmp/uai-websockify.log 2>&1; sleep 2; done' >/dev/null 2>&1 &`,
953
+ `[ -x /usr/bin/websockify ] || exit 0; [ -f /tmp/uai-novnc.pid ] && /usr/bin/kill -0 "$(/usr/bin/cat /tmp/uai-novnc.pid)" 2>/dev/null && exit 0; /usr/bin/nohup /bin/sh -c 'echo $$ > /tmp/uai-novnc.pid; while true; do /usr/bin/websockify --web=/usr/share/novnc 0.0.0.0:6080 localhost:5900 >>/tmp/uai-websockify.log 2>&1; /bin/sleep 2; done' >/dev/null 2>&1 &`,
944
954
  ];
945
955
 
946
- const APT_DEPS_CMD = "npx -y playwright@latest install-deps chromium";
956
+ const DEFAULT_NODE_BIN =
957
+ `/opt/asdf-data/installs/nodejs/${STANDARD_DEFAULT_NODE_VERSION}/bin`;
958
+ const ROOT_RUNTIME_PATH =
959
+ `${DEFAULT_NODE_BIN}:/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin`;
960
+ const APT_DEPS_CMD =
961
+ `PATH=${ROOT_RUNTIME_PATH} ${DEFAULT_NODE_BIN}/npx ` +
962
+ "-y playwright@latest install-deps chromium";
947
963
  const APT_VIEWER_CMD =
948
- "apt-get install -y --no-install-recommends xvfb x11vnc novnc websockify";
964
+ "/usr/bin/apt-get install -y --no-install-recommends xvfb x11vnc novnc websockify";
949
965
 
950
966
  function aptGuardPaths(): { done: string; lock: string } {
951
967
  const digest = createHash("sha256")
@@ -978,10 +994,19 @@ export async function setupBrowserTesting(
978
994
  containerName: string,
979
995
  hasCodex: boolean,
980
996
  codexHomes: readonly string[] = hasCodex ? [DEFAULT_CODEX_HOME] : [],
997
+ environment?: Pick<TaskEnvironmentHandle, "exec" | "launchDetachedSession">,
981
998
  ): Promise<BrowserSetup> {
982
999
  let ready = false;
983
1000
  let configured = false;
984
1001
  let changed = false;
1002
+ // The no-handle fallbacks below exec by container name. The passed name is
1003
+ // the Docker Compose replica shape; on the Apple backend the container has
1004
+ // the provider's fixed name and the `docker` binary does not exist (live
1005
+ // 2026-08-18: every fallback died with spawn docker ENOENT, which is why
1006
+ // sessions never saw their browser MCP tools). Normalize both here.
1007
+ const container = taskContainerBackend().apple
1008
+ ? taskAppContainerName(taskId)
1009
+ : containerName;
985
1010
  try {
986
1011
  const selectedCodexHomes = hasCodex
987
1012
  ? [...new Set(codexHomes.length > 0 ? codexHomes : [DEFAULT_CODEX_HOME])]
@@ -1001,9 +1026,27 @@ export async function setupBrowserTesting(
1001
1026
  // ONE exec per daemon: the single-line nohup shape is the only one that
1002
1027
  // reliably survives `docker exec -d`.
1003
1028
  for (const step of VIEWER_STEPS) {
1004
- await dockerCli(["exec", "-d", containerName, "sh", "-c", step], {
1005
- timeoutMs: 10_000,
1006
- });
1029
+ if (environment) {
1030
+ await environment.launchDetachedSession({
1031
+ argv: ["sh", "-c", step],
1032
+ env: RUNTIME_AUTHORITY_ENV,
1033
+ maxOutputBytes: 64 * 1024,
1034
+ launchTimeoutMs: 10_000,
1035
+ });
1036
+ } else {
1037
+ await taskContainerCli(
1038
+ [
1039
+ "exec",
1040
+ "-d",
1041
+ ...runtimeAuthorityDockerExecArgs(),
1042
+ container,
1043
+ "sh",
1044
+ "-c",
1045
+ step,
1046
+ ],
1047
+ { timeoutMs: 10_000 },
1048
+ );
1049
+ }
1007
1050
  }
1008
1051
 
1009
1052
  // Browser readiness — AWAITED, and deliberately OUTSIDE the marker guard.
@@ -1019,23 +1062,68 @@ export async function setupBrowserTesting(
1019
1062
  // from outside: the volume is host-wide, so another container's Playwright
1020
1063
  // can evict our build mid-task. Re-running it is how that heals — and it
1021
1064
  // costs ~0.5s once the build is present.
1022
- await dockerCli(
1023
- ["exec", "-u", "root", containerName, "chown", "node:node", "/opt/pw-browsers"],
1024
- { timeoutMs: 5_000 },
1025
- );
1026
- const installed = await dockerCli(
1027
- ["exec", "-w", "/workspace", containerName, "sh", "-lc", PREWARM_CMD],
1028
- { timeoutMs: INSTALL_TIMEOUT_MS },
1029
- );
1030
- ready = installed.status === 0;
1065
+ const installed = environment
1066
+ ? await (async () => {
1067
+ await environment.exec({
1068
+ argv: ["/usr/bin/chown", "node:node", "/opt/pw-browsers"],
1069
+ user: "root",
1070
+ env: RUNTIME_AUTHORITY_ENV,
1071
+ timeoutMs: 5_000,
1072
+ maxOutputBytes: 64 * 1024,
1073
+ });
1074
+ return environment.exec({
1075
+ argv: ["sh", "-lc", PREWARM_CMD],
1076
+ cwd: "/workspace",
1077
+ env: RUNTIME_AUTHORITY_ENV,
1078
+ timeoutMs: INSTALL_TIMEOUT_MS,
1079
+ maxOutputBytes: 256 * 1024,
1080
+ });
1081
+ })()
1082
+ : await taskContainerCli(
1083
+ [
1084
+ "exec",
1085
+ "-u",
1086
+ "root",
1087
+ ...runtimeAuthorityDockerExecArgs(),
1088
+ container,
1089
+ "/usr/bin/chown",
1090
+ "node:node",
1091
+ "/opt/pw-browsers",
1092
+ ],
1093
+ { timeoutMs: 5_000 },
1094
+ ).then(async (chown) => {
1095
+ if (chown.status !== 0) return chown;
1096
+ return taskContainerCli(
1097
+ [
1098
+ "exec",
1099
+ "-w",
1100
+ "/workspace",
1101
+ ...runtimeAuthorityDockerExecArgs(),
1102
+ container,
1103
+ "sh",
1104
+ "-lc",
1105
+ PREWARM_CMD,
1106
+ ],
1107
+ { timeoutMs: INSTALL_TIMEOUT_MS },
1108
+ );
1109
+ });
1110
+ const installedStatus =
1111
+ "exitCode" in installed ? installed.exitCode : installed.status;
1112
+ const installedStderr =
1113
+ "stderr" in installed && installed.stderr instanceof Uint8Array
1114
+ ? Buffer.from(installed.stderr).toString("utf8")
1115
+ : typeof installed.stderr === "string"
1116
+ ? installed.stderr
1117
+ : "";
1118
+ ready = installedStatus === 0;
1031
1119
  if (!ready) {
1032
1120
  // Agents still spawn — a task without a browser beats no task — but the
1033
1121
  // caller retries on this, and it is logged loudly, because the symptom
1034
1122
  // downstream (a browser that won't launch) reads as anything but an
1035
1123
  // install that quietly failed up here.
1036
1124
  console.warn(
1037
- `[browser] task ${taskId}: browser install failed (status ${installed.status}); ` +
1038
- `see ${INSTALL_LOG} in the container. ${installed.stderr.slice(0, 200)}`,
1125
+ `[browser] task ${taskId}: browser install failed (status ${installedStatus}); ` +
1126
+ `see ${INSTALL_LOG} in the container. ${installedStderr.slice(0, 200)}`,
1039
1127
  );
1040
1128
  }
1041
1129
 
@@ -1048,55 +1136,80 @@ export async function setupBrowserTesting(
1048
1136
  // survives those copies, so a gated setup could skip the rewrite and
1049
1137
  // report ready while Codex had no browser at all. The migration is cheap
1050
1138
  // and byte-idempotent, so the honest fix is to re-assert.
1051
- const wrote = await dockerCli(
1052
- [
1053
- "exec",
1054
- // asdf resolves node only where a .tool-versions applies.
1055
- "-w",
1056
- "/workspace",
1057
- "-e",
1058
- `UAI_BROWSER_DEF=${JSON.stringify(SERVER_DEF)}`,
1059
- "-e",
1060
- `UAI_MCP_PATH=${MCP_CONFIG_PATH}`,
1061
- "-e",
1062
- `UAI_CLAUDE_SETTINGS_PATH=${CLAUDE_SETTINGS_PATH}`,
1063
- "-e",
1064
- `UAI_CLAUDE_SETTINGS_DEF=${CLAUDE_SETTINGS_JSON}`,
1065
- ...(selectedCodexHomes.length > 0
1066
- ? ["-e", `UAI_CODEX_HOMES=${JSON.stringify(selectedCodexHomes)}`]
1067
- : []),
1068
- containerName,
1069
- "flock",
1070
- "-w",
1071
- "10",
1072
- MCP_CONFIG_LOCK_PATH,
1073
- "timeout",
1074
- "--kill-after=5",
1075
- String(
1076
- Math.max(45, 15 + selectedCodexHomes.length * 12),
1077
- ),
1078
- "node",
1079
- "-e",
1080
- MIGRATE_JS,
1081
- ],
1082
- {
1083
- // Lock wait + the per-home-scaled in-container bound + SIGKILL grace,
1084
- // with ten seconds left for Docker/process overhead. The host must
1085
- // never win this race: killing docker exec does not kill its child.
1086
- timeoutMs:
1087
- (10 +
1088
- Math.max(45, 15 + selectedCodexHomes.length * 12) +
1089
- 5 +
1090
- 10) *
1091
- 1_000,
1092
- },
1093
- );
1094
- if (wrote.status !== 0) {
1139
+ const writeTimeoutMs =
1140
+ (10 + Math.max(45, 15 + selectedCodexHomes.length * 12) + 5 + 10) *
1141
+ 1_000;
1142
+ const writeEnv = {
1143
+ ...RUNTIME_AUTHORITY_ENV,
1144
+ UAI_BROWSER_DEF: JSON.stringify(SERVER_DEF),
1145
+ UAI_MCP_PATH: MCP_CONFIG_PATH,
1146
+ UAI_CLAUDE_SETTINGS_PATH: CLAUDE_SETTINGS_PATH,
1147
+ UAI_CLAUDE_SETTINGS_DEF: CLAUDE_SETTINGS_JSON,
1148
+ ...(selectedCodexHomes.length > 0
1149
+ ? { UAI_CODEX_HOMES: JSON.stringify(selectedCodexHomes) }
1150
+ : {}),
1151
+ };
1152
+ const writeArgv = [
1153
+ "flock",
1154
+ "-w",
1155
+ "10",
1156
+ MCP_CONFIG_LOCK_PATH,
1157
+ "timeout",
1158
+ "--kill-after=5",
1159
+ String(Math.max(45, 15 + selectedCodexHomes.length * 12)),
1160
+ "node",
1161
+ "-e",
1162
+ MIGRATE_JS,
1163
+ ] as [string, ...string[]];
1164
+ const wrote = environment
1165
+ ? await environment.exec({
1166
+ argv: writeArgv,
1167
+ cwd: "/workspace",
1168
+ env: writeEnv,
1169
+ timeoutMs: writeTimeoutMs,
1170
+ maxOutputBytes: 256 * 1024,
1171
+ })
1172
+ : await taskContainerCli(
1173
+ [
1174
+ "exec",
1175
+ "-w",
1176
+ "/workspace",
1177
+ "-e",
1178
+ `UAI_BROWSER_DEF=${JSON.stringify(SERVER_DEF)}`,
1179
+ "-e",
1180
+ `UAI_MCP_PATH=${MCP_CONFIG_PATH}`,
1181
+ "-e",
1182
+ `UAI_CLAUDE_SETTINGS_PATH=${CLAUDE_SETTINGS_PATH}`,
1183
+ "-e",
1184
+ `UAI_CLAUDE_SETTINGS_DEF=${CLAUDE_SETTINGS_JSON}`,
1185
+ ...(selectedCodexHomes.length > 0
1186
+ ? ["-e", `UAI_CODEX_HOMES=${JSON.stringify(selectedCodexHomes)}`]
1187
+ : []),
1188
+ ...runtimeAuthorityDockerExecArgs(),
1189
+ container,
1190
+ ...writeArgv,
1191
+ ],
1192
+ { timeoutMs: writeTimeoutMs },
1193
+ );
1194
+ const wroteStatus = "exitCode" in wrote ? wrote.exitCode : wrote.status;
1195
+ const wroteStdout =
1196
+ "stdout" in wrote && wrote.stdout instanceof Uint8Array
1197
+ ? Buffer.from(wrote.stdout).toString("utf8")
1198
+ : typeof wrote.stdout === "string"
1199
+ ? wrote.stdout
1200
+ : "";
1201
+ const wroteStderr =
1202
+ "stderr" in wrote && wrote.stderr instanceof Uint8Array
1203
+ ? Buffer.from(wrote.stderr).toString("utf8")
1204
+ : typeof wrote.stderr === "string"
1205
+ ? wrote.stderr
1206
+ : "";
1207
+ if (wroteStatus !== 0) {
1095
1208
  console.warn(
1096
- `[browser] task ${taskId}: MCP config write failed: ${wrote.stderr.slice(0, 300)}`,
1209
+ `[browser] task ${taskId}: MCP config write failed: ${wroteStderr.slice(0, 300)}`,
1097
1210
  );
1098
1211
  } else {
1099
- const resultLine = wrote.stdout
1212
+ const resultLine = wroteStdout
1100
1213
  .split(/\r?\n/)
1101
1214
  .find((line) => line.startsWith(CONFIG_RESULT_TOKEN));
1102
1215
  if (!resultLine) {
@@ -1147,43 +1260,70 @@ export async function setupBrowserTesting(
1147
1260
  APT_VIEWER_CMD,
1148
1261
  // Close lock fd 9 before starting long-lived viewer grandchildren, or
1149
1262
  // they inherit it and keep the apt lock forever after this worker exits.
1150
- `su -s /bin/sh node -c ${shellQuote(viewerSetup)} 9>&-`,
1151
- "chown -R node:node /home/node/.npm 2>/dev/null || true",
1152
- `touch ${aptGuard.done}`,
1263
+ `/usr/bin/su -s /bin/sh node -c ${shellQuote(viewerSetup)} 9>&-`,
1264
+ "/usr/bin/chown -R node:node /home/node/.npm 2>/dev/null || true",
1265
+ `/usr/bin/touch ${aptGuard.done}`,
1153
1266
  ].join("; ");
1154
1267
  const guardedAptStart = [
1155
1268
  `[ -f ${aptGuard.done} ] && exit 0`,
1156
1269
  `exec 9>${aptGuard.lock}`,
1157
- "flock -n 9 || exit 0",
1270
+ "/usr/bin/flock -n 9 || exit 0",
1158
1271
  // A different worker may have completed between the first check and
1159
1272
  // this lock acquisition.
1160
1273
  `[ -f ${aptGuard.done} ] && exit 0`,
1161
- `nohup sh -lc ${shellQuote(aptWorker)} 9>&9 </dev/null ` +
1274
+ `/usr/bin/nohup /bin/sh -c ${shellQuote(aptWorker)} 9>&9 </dev/null ` +
1162
1275
  ">/tmp/uai-pw-deps.log 2>&1 &",
1163
1276
  ].join("; ");
1164
1277
 
1165
- await dockerCli(
1166
- [
1167
- "exec",
1168
- "-u",
1169
- "root",
1170
- // HOME=/home/node is for asdf version resolution ONLY — without the
1171
- // cache redirect below, root's npx writes ROOT-OWNED entries into
1172
- // node's ~/.npm and breaks the node user's own npx (which launches
1173
- // the MCP server). Found live 2026-07-08.
1174
- "-e",
1175
- "HOME=/home/node",
1176
- "-e",
1177
- "npm_config_cache=/tmp/uai-root-npm-cache",
1178
- "-w",
1179
- "/workspace",
1180
- containerName,
1181
- "sh",
1182
- "-lc",
1183
- guardedAptStart,
1184
- ],
1185
- { timeoutMs: 10_000 },
1186
- );
1278
+ const rootWorkerEnv = {
1279
+ ...RUNTIME_AUTHORITY_ENV,
1280
+ NPM_CONFIG_CACHE: "/tmp/uai-root-npm-cache",
1281
+ npm_config_cache: "/tmp/uai-root-npm-cache",
1282
+ NPM_CONFIG_LOGS_DIR: "/tmp/uai-root-npm-cache/_logs",
1283
+ npm_config_logs_dir: "/tmp/uai-root-npm-cache/_logs",
1284
+ };
1285
+ if (environment) {
1286
+ await environment.exec({
1287
+ argv: ["/bin/sh", "-c", guardedAptStart],
1288
+ user: "root",
1289
+ cwd: "/workspace",
1290
+ env: rootWorkerEnv,
1291
+ timeoutMs: 10_000,
1292
+ maxOutputBytes: 64 * 1024,
1293
+ });
1294
+ } else {
1295
+ await taskContainerCli(
1296
+ [
1297
+ "exec",
1298
+ "-u",
1299
+ "root",
1300
+ // HOME=/home/node is for asdf version resolution ONLY — without the
1301
+ // cache redirect below, root's npx writes ROOT-OWNED entries into
1302
+ // node's ~/.npm and breaks the node user's own npx (which launches
1303
+ // the MCP server). Found live 2026-07-08.
1304
+ "-w",
1305
+ "/workspace",
1306
+ ...runtimeAuthorityDockerExecArgs(),
1307
+ // These root-worker exceptions must follow the common authority args:
1308
+ // Docker/npm use the final value when a spelling is repeated. Keep
1309
+ // both cases aligned so npm's case-insensitive config lookup cannot
1310
+ // fall back to node's private cache or logs directory.
1311
+ "-e",
1312
+ "NPM_CONFIG_CACHE=/tmp/uai-root-npm-cache",
1313
+ "-e",
1314
+ "npm_config_cache=/tmp/uai-root-npm-cache",
1315
+ "-e",
1316
+ "NPM_CONFIG_LOGS_DIR=/tmp/uai-root-npm-cache/_logs",
1317
+ "-e",
1318
+ "npm_config_logs_dir=/tmp/uai-root-npm-cache/_logs",
1319
+ container,
1320
+ "/bin/sh",
1321
+ "-c",
1322
+ guardedAptStart,
1323
+ ],
1324
+ { timeoutMs: 10_000 },
1325
+ );
1326
+ }
1187
1327
  console.log(
1188
1328
  `[browser] task ${taskId}: Playwright MCP wired (browser ${ready ? "ready" : "NOT ready"}; apt deps backgrounded)`,
1189
1329
  );
package/lib/codex-auth.ts CHANGED
@@ -42,6 +42,12 @@ import { isNull } from "drizzle-orm";
42
42
 
43
43
  import { getDb, schema } from "./db";
44
44
  import { dockerCli } from "./docker-exec";
45
+ import {
46
+ listRunningTaskAppContainerNames,
47
+ taskAppContainerName,
48
+ taskContainerCli,
49
+ } from "./task-container-cli";
50
+ import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
45
51
 
46
52
  /** The exact set task-up.sh copies into `/home/node/.codex`. */
47
53
  const CODEX_ITEMS = [
@@ -72,7 +78,7 @@ function ownerCodexDir(): string {
72
78
  }
73
79
 
74
80
  const defaultExec: NonNullable<CodexDeps["exec"]> = async (args) => {
75
- const res = await dockerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
81
+ const res = await taskContainerCli(args, { timeoutMs: EXEC_TIMEOUT_MS });
76
82
  return { status: res.status, stdout: res.stdout, stderr: res.stderr };
77
83
  };
78
84
 
@@ -82,7 +88,7 @@ function defaultActiveTaskContainers(): string[] {
82
88
  .from(schema.hostTasks)
83
89
  .where(isNull(schema.hostTasks.endedAt))
84
90
  .all()
85
- .map((r) => `task-${r.taskId}-app-1`);
91
+ .map((r) => taskAppContainerName(r.taskId));
86
92
  }
87
93
 
88
94
  /**
@@ -93,6 +99,13 @@ function defaultActiveTaskContainers(): string[] {
93
99
  async function dockerRunningNames(
94
100
  exec: NonNullable<CodexDeps["exec"]>,
95
101
  ): Promise<Set<string> | null> {
102
+ // The list verb differs per backend (docker ps vs apple container list);
103
+ // the shared helper owns that translation. The injected exec seam is kept
104
+ // for tests, which script docker-shaped ps answers.
105
+ if (exec === defaultExec) {
106
+ const names = await listRunningTaskAppContainerNames();
107
+ return names === null ? null : new Set(names);
108
+ }
96
109
  const res = await exec(["ps", "--filter", "status=running", "--format", "{{.Names}}"]);
97
110
  if (res.status !== 0) return null;
98
111
  return new Set(
@@ -129,7 +142,16 @@ async function copyCodexInto(
129
142
  }
130
143
  };
131
144
  await must(
132
- ["exec", "-u", "root", container, "mkdir", "-p", "/home/node/.codex"],
145
+ [
146
+ "exec",
147
+ "-u",
148
+ "root",
149
+ ...runtimeAuthorityDockerExecArgs(),
150
+ container,
151
+ "/bin/mkdir",
152
+ "-p",
153
+ "/home/node/.codex",
154
+ ],
133
155
  "mkdir /home/node/.codex",
134
156
  );
135
157
  for (const item of items) {
@@ -138,7 +160,17 @@ async function copyCodexInto(
138
160
  await must(["cp", src, `${container}:/home/node/.codex/`], `docker cp ${item}`);
139
161
  }
140
162
  await must(
141
- ["exec", "-u", "root", container, "chown", "-R", "node:node", "/home/node/.codex"],
163
+ [
164
+ "exec",
165
+ "-u",
166
+ "root",
167
+ ...runtimeAuthorityDockerExecArgs(),
168
+ container,
169
+ "/bin/chown",
170
+ "-R",
171
+ "node:node",
172
+ "/home/node/.codex",
173
+ ],
142
174
  "chown -R node:node /home/node/.codex",
143
175
  );
144
176
  }
@@ -220,7 +252,7 @@ let debounceTimer: ReturnType<typeof setTimeout> | null = null;
220
252
  * missing `~/.codex` dir is a no-op — the host-start reinject already handles
221
253
  * a first login that flips Codex available. UAI_CODEX_REINJECT=0 disables it.
222
254
  */
223
- export function watchCodexAuth(): void {
255
+ export function watchCodexAuth(canReinject: () => boolean = () => true): void {
224
256
  if (watcher || process.env.UAI_CODEX_REINJECT === "0") return;
225
257
  const dir = ownerCodexDir();
226
258
  if (!existsSync(dir)) return;
@@ -229,7 +261,9 @@ export function watchCodexAuth(): void {
229
261
  // filename is null on some platforms — then we can't tell, so proceed.
230
262
  if (filename && filename !== "auth.json") return;
231
263
  if (debounceTimer) clearTimeout(debounceTimer);
232
- debounceTimer = setTimeout(() => void reinjectCodexRunningTasks(), 2_000);
264
+ debounceTimer = setTimeout(() => {
265
+ if (canReinject()) void reinjectCodexRunningTasks();
266
+ }, 2_000);
233
267
  debounceTimer.unref?.();
234
268
  });
235
269
  } catch (err) {
package/lib/command-db.ts CHANGED
@@ -37,6 +37,26 @@ export function createTaskDownCommandDb(
37
37
  return dbPath;
38
38
  }
39
39
 
40
+ /**
41
+ * Environment-provider teardown reconstructs from a durable task locator, not
42
+ * from the cloud command payload that originally provisioned it. The teardown
43
+ * script needs only the exact task identity/runtime row; it deliberately
44
+ * derives every destructive Docker/filesystem target from that identity.
45
+ */
46
+ export function createTaskEnvironmentDownCommandDb(
47
+ taskId: string,
48
+ runtime: HostTask | null,
49
+ ): string {
50
+ const dbPath = newCommandDbPath(taskId);
51
+ const db = openCommandDb(dbPath);
52
+ try {
53
+ insertTask(db, runtimeTask(taskId, runtime));
54
+ } finally {
55
+ db.close();
56
+ }
57
+ return dbPath;
58
+ }
59
+
40
60
  export function createTaskStatusCommandDb(
41
61
  taskId: string,
42
62
  runtime: HostTask | null,