@runuai/host 0.9.148 → 0.9.150

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 (93) hide show
  1. package/db/migrations/0024_task_env_mode.sql +9 -0
  2. package/db/migrations/0025_project_env_authority.sql +147 -0
  3. package/db/migrations/0026_project_env_replica.sql +150 -0
  4. package/db/migrations/meta/_journal.json +21 -0
  5. package/db/schema.ts +312 -2
  6. package/images/standard/Dockerfile +44 -6
  7. package/images/standard/container/uai-init +85 -2
  8. package/images/standard/container/uai-task-forwarder.mjs +303 -0
  9. package/lib/agent-cli.ts +18 -1
  10. package/lib/agent.ts +99 -34
  11. package/lib/agents/codex.ts +9 -1
  12. package/lib/agents/cursor.ts +15 -1
  13. package/lib/agents/grok.ts +8 -0
  14. package/lib/agents/kimi.ts +1 -0
  15. package/lib/agents/machine-durable.ts +2 -0
  16. package/lib/agents/opencode.ts +1 -0
  17. package/lib/agents/proc.ts +6 -1
  18. package/lib/agents/transport.ts +9 -5
  19. package/lib/browser-testing.ts +6 -0
  20. package/lib/codex-auth.ts +2 -0
  21. package/lib/credential-store.ts +141 -6
  22. package/lib/engine-accounts.ts +1 -0
  23. package/lib/env-file.ts +7 -0
  24. package/lib/env-schema.ts +841 -51
  25. package/lib/git-diff.ts +1 -0
  26. package/lib/git-identity.ts +2 -0
  27. package/lib/github-tokens.ts +1 -0
  28. package/lib/host-env.ts +98 -18
  29. package/lib/lease.ts +11 -3
  30. package/lib/machine-gateway-tunnel.ts +195 -17
  31. package/lib/machine-project-env-loader.ts +152 -0
  32. package/lib/managed-runtime.ts +270 -51
  33. package/lib/mcp-gateway.ts +777 -26
  34. package/lib/orchestrator.ts +408 -81
  35. package/lib/project-env-authority.ts +1319 -0
  36. package/lib/project-env-feature.ts +74 -0
  37. package/lib/project-env-loader.ts +237 -0
  38. package/lib/project-env-replica.ts +2094 -0
  39. package/lib/project-env-schema-capture.ts +342 -0
  40. package/lib/project-env-staging.ts +148 -0
  41. package/lib/project-env-task-policy.ts +176 -0
  42. package/lib/runtime-archive.ts +79 -16
  43. package/lib/runtime-authority.ts +44 -0
  44. package/lib/runtime-state.ts +175 -10
  45. package/lib/secrets-proxy/address.ts +72 -0
  46. package/lib/secrets-proxy/audit.ts +30 -0
  47. package/lib/secrets-proxy/ca-store.ts +200 -0
  48. package/lib/secrets-proxy/ca.ts +470 -0
  49. package/lib/secrets-proxy/container-steps.ts +138 -0
  50. package/lib/secrets-proxy/der.ts +273 -0
  51. package/lib/secrets-proxy/engine.ts +1633 -0
  52. package/lib/secrets-proxy/host.ts +93 -0
  53. package/lib/secrets-proxy/hostname.ts +60 -0
  54. package/lib/secrets-proxy/placeholders.ts +196 -0
  55. package/lib/secrets-proxy/policy.ts +141 -0
  56. package/lib/secrets-proxy/readiness.ts +148 -0
  57. package/lib/secrets-proxy/runtime.ts +582 -0
  58. package/lib/secrets-proxy/scrub.ts +559 -0
  59. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  60. package/lib/secrets-proxy/task-scripts.ts +262 -0
  61. package/lib/secrets-proxy/task-trust.ts +574 -0
  62. package/lib/secrets-proxy/trust-watch.ts +80 -0
  63. package/lib/shared-files-s3.ts +3 -0
  64. package/lib/sidecar-guard.ts +58 -0
  65. package/lib/sidecar-resume.ts +51 -3
  66. package/lib/standard-image.ts +50 -12
  67. package/lib/task-env-forwarder.ts +228 -0
  68. package/lib/task-env-source.ts +193 -0
  69. package/lib/task-environment/apple-container.ts +45 -11
  70. package/lib/task-environment/docker.ts +51 -12
  71. package/lib/task-environment/index.ts +6 -2
  72. package/lib/task-environment/machine-task-up.ts +258 -25
  73. package/lib/task-environment/machine.ts +80 -22
  74. package/lib/task-environment/registry.ts +3 -2
  75. package/lib/task-environment/self-identity.ts +20 -0
  76. package/lib/task-environment/self.ts +92 -16
  77. package/lib/task-environment/types.ts +63 -0
  78. package/lib/task-gateway-port.ts +22 -0
  79. package/lib/task-gateway-protocol.ts +190 -0
  80. package/lib/task-gateway-recovery.ts +44 -0
  81. package/lib/task-gateway-state.ts +497 -0
  82. package/lib/transcript.ts +1 -0
  83. package/package.json +13 -1
  84. package/scripts/agent/env-hook.sh +626 -0
  85. package/scripts/agent/task-up.sh +476 -27
  86. package/scripts/agent/uai-env-exec.sh +26 -0
  87. package/src/cli.ts +26 -3
  88. package/src/index.ts +273 -19
  89. package/src/main.ts +939 -48
  90. package/src/project-env-latest.ts +130 -0
  91. package/src/project-env-policy.ts +281 -0
  92. package/src/project-env-wording.ts +47 -0
  93. package/src/protocol.ts +2219 -15
@@ -30,12 +30,20 @@ FROM debian:bookworm-slim
30
30
  # asdf release to clone. Pinned for reproducible builds; bump deliberately.
31
31
  ENV ASDF_VERSION=v0.14.1
32
32
 
33
+ # Qualified agent builds. These are execution-contract pins: per-command env
34
+ # freshness is requalified before any of them moves.
35
+ ENV UAI_CODEX_CLI_VERSION=0.157.0
36
+ ENV UAI_CURSOR_AGENT_VERSION=2026.08.25-3e8eec8
37
+ ENV UAI_GROK_CLI_VERSION=1.0.5
38
+
33
39
  # ---------------------------------------------------------------------------
34
40
  # 1. System packages.
35
41
  #
36
42
  # build-essential + the -dev libs cover what asdf's nodejs/python/ruby/rust
37
43
  # build plugins need to compile a runtime from source on first use. unzip is
38
44
  # needed by several asdf plugins; gnupg + ca-certificates for the gh apt repo.
45
+ # iptables is for the secrets proxy's NAT chain (ADR-153); only the runner's
46
+ # one privileged exec runs it, and ca-certificates also installs its CA.
39
47
  # ---------------------------------------------------------------------------
40
48
 
41
49
  ENV DEBIAN_FRONTEND=noninteractive
@@ -48,6 +56,7 @@ RUN apt-get update \
48
56
  direnv \
49
57
  git \
50
58
  gnupg \
59
+ iptables \
51
60
  jq \
52
61
  libbz2-dev \
53
62
  libffi-dev \
@@ -236,13 +245,14 @@ RUN bash -lc '\
236
245
  . /opt/asdf/asdf.sh; \
237
246
  COREPACK_VERSION="$(sed -n "1p" /usr/local/share/uai/corepack-version)"; \
238
247
  printf "%s" "$COREPACK_VERSION" | grep -Eq "^[0-9]+\\.[0-9]+\\.[0-9]+$"; \
239
- npm install -g @anthropic-ai/claude-code @openai/codex "corepack@$COREPACK_VERSION" \
248
+ npm install -g @anthropic-ai/claude-code "@openai/codex@$UAI_CODEX_CLI_VERSION" "corepack@$COREPACK_VERSION" \
240
249
  --registry=https://registry.npmjs.org/ \
241
250
  --userconfig=/dev/null \
242
251
  --globalconfig=/dev/uai-empty-npmrc; \
243
252
  corepack enable; \
244
253
  curl -fsSL https://code-server.dev/install.sh | sh -s -- --method standalone --prefix /home/node/.local; \
245
254
  asdf reshim nodejs; \
255
+ test "$(codex --version)" = "codex-cli $UAI_CODEX_CLI_VERSION"; \
246
256
  '
247
257
 
248
258
  # Optional agent CLIs — installed ONLY when the host has them configured
@@ -259,8 +269,16 @@ RUN if [ "$INSTALL_KIMI" = "1" ]; then \
259
269
 
260
270
  ARG INSTALL_GROK=0
261
271
  RUN if [ "$INSTALL_GROK" = "1" ]; then \
262
- bash -lc 'curl -fsSL https://x.ai/cli/install.sh | bash' \
263
- || echo "[warn] grok install failed — grok engine unavailable in this image"; \
272
+ bash -lc 'set -e; \
273
+ curl -fsSL https://x.ai/cli/install.sh | bash -s -- "$UAI_GROK_CLI_VERSION"; \
274
+ case "$("$HOME/.grok/bin/grok" --version)" in \
275
+ "grok $UAI_GROK_CLI_VERSION "*) ;; \
276
+ *) exit 1 ;; \
277
+ esac' \
278
+ || { \
279
+ rm -rf -- "$HOME/.grok/bin" "$HOME/.grok/downloads"; \
280
+ echo "[warn] grok install failed — grok engine unavailable in this image"; \
281
+ }; \
264
282
  fi
265
283
 
266
284
  # Cursor Agent (Anysphere) — installs to /home/node/.local/bin/cursor-agent.
@@ -268,8 +286,24 @@ RUN if [ "$INSTALL_GROK" = "1" ]; then \
268
286
  # task-up — just the binary.
269
287
  ARG INSTALL_CURSOR=0
270
288
  RUN if [ "$INSTALL_CURSOR" = "1" ]; then \
271
- bash -lc 'curl https://cursor.com/install -fsS | bash' \
272
- || echo "[warn] cursor install failed — cursor engine unavailable in this image"; \
289
+ bash -lc 'set -e; \
290
+ case "$(uname -m)" in \
291
+ x86_64|amd64) cursor_arch=x64 ;; \
292
+ aarch64|arm64) cursor_arch=arm64 ;; \
293
+ *) echo "unsupported Cursor architecture: $(uname -m)" >&2; exit 1 ;; \
294
+ esac; \
295
+ cursor_dir="$HOME/.local/share/cursor-agent/versions/$UAI_CURSOR_AGENT_VERSION"; \
296
+ mkdir -p "$cursor_dir" "$HOME/.local/bin"; \
297
+ curl -fSL "https://downloads.cursor.com/lab/$UAI_CURSOR_AGENT_VERSION/linux/$cursor_arch/agent-cli-package.tar.gz" \
298
+ | tar --strip-components=1 -xzf - -C "$cursor_dir"; \
299
+ ln -sfn "$cursor_dir/cursor-agent" "$HOME/.local/bin/agent"; \
300
+ ln -sfn "$cursor_dir/cursor-agent" "$HOME/.local/bin/cursor-agent"; \
301
+ test "$("$HOME/.local/bin/cursor-agent" --version)" = "$UAI_CURSOR_AGENT_VERSION"' \
302
+ || { \
303
+ rm -f -- "$HOME/.local/bin/agent" "$HOME/.local/bin/cursor-agent"; \
304
+ rm -rf -- "$HOME/.local/share/cursor-agent/versions/$UAI_CURSOR_AGENT_VERSION"; \
305
+ echo "[warn] cursor install failed — cursor engine unavailable in this image"; \
306
+ }; \
273
307
  fi
274
308
 
275
309
  # OpenCode (ADR-077) — multi-provider CLI. Installs to /home/node/.opencode/bin;
@@ -290,7 +324,11 @@ ENV PATH=/home/node/.local/bin:$PATH
290
324
  USER root
291
325
 
292
326
  COPY --chown=root:root container/uai-init /usr/local/bin/uai-init
293
- RUN chmod 0755 /usr/local/bin/uai-init
327
+ RUN node_version="$(awk '$1 == "nodejs" { print $2; exit }' /home/node/.tool-versions)" \
328
+ && test -n "$node_version" \
329
+ && test -x "/opt/asdf-data/installs/nodejs/$node_version/bin/node" \
330
+ && ln -s "/opt/asdf-data/installs/nodejs/$node_version/bin/node" /usr/local/bin/node \
331
+ && chmod 0755 /usr/local/bin/uai-init
294
332
 
295
333
  # Curated code-server defaults. uai-init seeds these into the per-task User
296
334
  # settings dir when none exists (slim chrome, telemetry off, trust off).
@@ -51,6 +51,27 @@ if [ "${#npm_sanitize_args[@]}" -gt 0 ]; then
51
51
  /bin/bash "${BASH_SOURCE[0]}" "$@"
52
52
  fi
53
53
 
54
+ # Parse the role only after the one-time sanitized re-exec above. Parsing it
55
+ # first would shift away --dependency-worker, causing the clean process to
56
+ # restart as the main/editor role whenever inherited npm authority was
57
+ # present (the normal task environment).
58
+ UAI_INIT_ROLE=main
59
+ case ${1-} in
60
+ --dependency-worker)
61
+ UAI_INIT_ROLE=dependency-worker
62
+ shift
63
+ ;;
64
+ "") ;;
65
+ *)
66
+ printf '%s\n' 'uai-init: invalid invocation' >&2
67
+ exit 64
68
+ ;;
69
+ esac
70
+ [ "$#" -eq 0 ] || {
71
+ printf '%s\n' 'uai-init: invalid invocation' >&2
72
+ exit 64
73
+ }
74
+
54
75
  for runtime_var in $(compgen -A variable 2>/dev/null); do
55
76
  case "$runtime_var" in
56
77
  ASDF_* | COREPACK_* | \
@@ -123,6 +144,22 @@ case "${BASH_SOURCE[0]}" in
123
144
  fi
124
145
  ;;
125
146
  esac
147
+ UAI_ENV_EXEC=/usr/local/bin/uai-env-exec
148
+ UAI_ENV_HOOK_FILE=/usr/local/share/uai/env-hook.sh
149
+ MANAGED_ENV_KEYS=/run/uai/managed-env-keys
150
+ UAI_INIT_SELF=/usr/local/bin/uai-init
151
+ case "${BASH_SOURCE[0]}" in
152
+ /usr/local/bin/uai-init | /uai-init-current) ;;
153
+ *)
154
+ UAI_INIT_SELF=${BASH_SOURCE[0]}
155
+ [ -z "${UAI_INIT_TEST_ENV_EXEC:-}" ] \
156
+ || UAI_ENV_EXEC=$UAI_INIT_TEST_ENV_EXEC
157
+ [ -z "${UAI_INIT_TEST_ENV_HOOK:-}" ] \
158
+ || UAI_ENV_HOOK_FILE=$UAI_INIT_TEST_ENV_HOOK
159
+ [ -z "${UAI_INIT_TEST_MANAGED_ENV_KEYS:-}" ] \
160
+ || MANAGED_ENV_KEYS=$UAI_INIT_TEST_MANAGED_ENV_KEYS
161
+ ;;
162
+ esac
126
163
  ASDF_SH="$ASDF_DIR/asdf.sh"
127
164
 
128
165
  log() { echo "uai-init: $*"; }
@@ -291,6 +328,7 @@ fi
291
328
  # useful in either mode for commit/tag signing.
292
329
  # ---------------------------------------------------------------------------
293
330
 
331
+ if [ "$UAI_INIT_ROLE" = main ]; then
294
332
  if [ "${UAI_SKIP_GIT_TRANSPORT:-0}" = "1" ]; then
295
333
  # Recovery/connect/disconnect reconciles the current credential immediately
296
334
  # before invoking us. Do not overwrite that live decision with the static
@@ -343,6 +381,7 @@ if [ ! -f "$cc_settings" ]; then
343
381
  printf '{\n "includeCoAuthoredBy": false\n}\n' > "$cc_settings"
344
382
  log "seeded ~/.claude/settings.json (includeCoAuthoredBy: false)"
345
383
  fi
384
+ fi
346
385
 
347
386
  # ---------------------------------------------------------------------------
348
387
  # 1. Per-folder dependency install (runtimes are already materialized).
@@ -468,6 +507,18 @@ run_workspace_installs() {
468
507
  return 0
469
508
  }
470
509
 
510
+ # The detached worker is the only uai-init role that receives project env.
511
+ # Its caller has already used uai-env-exec to perform one authenticated
512
+ # refresh; this role does no editor or Git-control-plane work and exits after
513
+ # the installs. Paths are repeated here because a fresh process cannot inherit
514
+ # the parent's shell locals.
515
+ if [ "$UAI_INIT_ROLE" = dependency-worker ]; then
516
+ deps_state_dir="$WORKSPACE/.uai/init"
517
+ deps_status_file="$deps_state_dir/deps-status"
518
+ run_workspace_installs
519
+ exit $?
520
+ fi
521
+
471
522
  if [ -d "$WORKSPACE" ] && [ -f "$RUNTIME_PROJECTS" ] \
472
523
  && [ ! -L "$RUNTIME_PROJECTS" ]; then
473
524
  deps_state_dir="$WORKSPACE/.uai/init"
@@ -506,7 +557,15 @@ if [ -d "$WORKSPACE" ] && [ -f "$RUNTIME_PROJECTS" ] \
506
557
  (
507
558
  trap 'rm -rf "$deps_lock_dir"' EXIT
508
559
  deps_rc=0
509
- run_workspace_installs || deps_rc=$?
560
+ if [ "${UAI_ENV_MODE:-baked}" = loader ]; then
561
+ UAI_ENV_HOOK=1 \
562
+ BASH_ENV="$UAI_ENV_HOOK_FILE" \
563
+ SHELL=/usr/bin/zsh \
564
+ "$UAI_ENV_EXEC" "$UAI_INIT_SELF" --dependency-worker \
565
+ || deps_rc=$?
566
+ else
567
+ run_workspace_installs || deps_rc=$?
568
+ fi
510
569
  if [ "$deps_rc" -eq 0 ]; then
511
570
  printf 'done\n' > "$deps_status_file"
512
571
  log "workspace dependency install complete"
@@ -605,7 +664,31 @@ else
605
664
  [ -z "$test_code_server_bin" ] || code_server_bin="$test_code_server_bin"
606
665
  ;;
607
666
  esac
608
- nohup "$code_server_bin" \
667
+ code_server_env=()
668
+ if [ "${UAI_ENV_MODE:-baked}" = loader ]; then
669
+ if [ -L "$MANAGED_ENV_KEYS" ] || [ ! -f "$MANAGED_ENV_KEYS" ]; then
670
+ log "error: managed env key list is unavailable; refusing editor startup"
671
+ exit 78
672
+ fi
673
+ code_server_env=(/usr/bin/env)
674
+ while IFS= read -r managed_name || [ -n "$managed_name" ]; do
675
+ case $managed_name in
676
+ ''|[!A-Za-z_]*|*[!A-Za-z0-9_]*)
677
+ log "error: managed env key list is invalid; refusing editor startup"
678
+ exit 78
679
+ ;;
680
+ esac
681
+ code_server_env+=( -u "$managed_name" )
682
+ done < "$MANAGED_ENV_KEYS"
683
+ code_server_env+=(
684
+ -u __UAI_ENV_KEYS
685
+ -u __UAI_ENV_ETAG
686
+ UAI_ENV_HOOK=1
687
+ "BASH_ENV=$UAI_ENV_HOOK_FILE"
688
+ SHELL=/usr/bin/zsh
689
+ )
690
+ fi
691
+ nohup "${code_server_env[@]}" "$code_server_bin" \
609
692
  --user-data-dir "$cs_data_dir" \
610
693
  --disable-workspace-trust \
611
694
  --auth none \
@@ -0,0 +1,303 @@
1
+ #!/usr/bin/env node
2
+
3
+ /**
4
+ * Task-local authenticated stream forwarder (ADR-153).
5
+ *
6
+ * The generation secret is read as exactly 32 raw bytes from stdin, converted
7
+ * to a KeyObject, and never placed in argv, the environment, a file or a
8
+ * network frame. The runner proves each connection with a fresh nonce/HMAC.
9
+ */
10
+
11
+ import { createHmac, createSecretKey } from "node:crypto";
12
+ import { createConnection, createServer } from "node:net";
13
+ import { pathToFileURL } from "node:url";
14
+
15
+ const PREAMBLE_MAGIC = Buffer.from([
16
+ 0x00, 0x55, 0x41, 0x49, 0x46, 0x57, 0x44, 0x01,
17
+ ]);
18
+ const CHALLENGE_MAGIC = Buffer.from("UAICHL01", "ascii");
19
+ const RESPONSE_MAGIC = Buffer.from("UAIRES01", "ascii");
20
+ const ACCEPT_MAGIC = Buffer.from("UAIOK001", "ascii");
21
+ const HMAC_DOMAIN = Buffer.from("uai-task-forwarder-v1\0", "ascii");
22
+ const FIELD_SEPARATOR = Buffer.from([0]);
23
+
24
+ const SECRET_BYTES = 32;
25
+ const NONCE_BYTES = 32;
26
+ const FORWARDER_UID = 65_532;
27
+ const PREAUTH_TIMEOUT_MS = 3_000;
28
+ const SECRET_INPUT_TIMEOUT_MS = 5_000;
29
+ const MAX_PENDING_PREAUTH = 64;
30
+ const MAX_TASK_ID_BYTES = 128;
31
+ const MAX_GENERATION_BYTES = 64;
32
+
33
+ function fail(message) {
34
+ throw new Error(message);
35
+ }
36
+
37
+ function parsePort(value, name) {
38
+ if (!/^\d+$/.test(value ?? "")) fail(`invalid ${name}`);
39
+ const parsed = Number(value);
40
+ if (!Number.isSafeInteger(parsed) || parsed < 1 || parsed > 65_535) {
41
+ fail(`invalid ${name}`);
42
+ }
43
+ return parsed;
44
+ }
45
+
46
+ export function parseForwarderArgs(argv) {
47
+ const values = new Map();
48
+ for (let index = 0; index < argv.length; index += 2) {
49
+ const flag = argv[index];
50
+ const value = argv[index + 1];
51
+ if (!flag?.startsWith("--") || value === undefined || values.has(flag)) {
52
+ fail("invalid forwarder arguments");
53
+ }
54
+ values.set(flag, value);
55
+ }
56
+ const taskId = values.get("--task-id") ?? "";
57
+ const generation = values.get("--generation") ?? "";
58
+ const gatewayHost = values.get("--gateway-host") ?? "";
59
+ const allowed = new Set([
60
+ "--task-id",
61
+ "--generation",
62
+ "--gateway-host",
63
+ "--gateway-port",
64
+ "--listen-port",
65
+ ]);
66
+ if ([...values.keys()].some((key) => !allowed.has(key))) {
67
+ fail("invalid forwarder arguments");
68
+ }
69
+ if (
70
+ !/^[a-z0-9][a-z0-9_-]*$/.test(taskId) ||
71
+ Buffer.byteLength(taskId) > MAX_TASK_ID_BYTES ||
72
+ !/^[A-Za-z0-9_-]{16,64}$/.test(generation) ||
73
+ Buffer.byteLength(generation) > MAX_GENERATION_BYTES ||
74
+ gatewayHost.length < 1 ||
75
+ gatewayHost.length > 255 ||
76
+ /[\0\s]/.test(gatewayHost)
77
+ ) {
78
+ fail("invalid forwarder arguments");
79
+ }
80
+ return {
81
+ taskId,
82
+ generation,
83
+ gatewayHost,
84
+ gatewayPort: parsePort(values.get("--gateway-port") ?? "5877", "gateway port"),
85
+ listenPort: parsePort(values.get("--listen-port") ?? "5877", "listen port"),
86
+ };
87
+ }
88
+
89
+ function encodePreamble(taskId, generation) {
90
+ const task = Buffer.from(taskId, "utf8");
91
+ const generationBytes = Buffer.from(generation, "ascii");
92
+ return Buffer.concat([
93
+ PREAMBLE_MAGIC,
94
+ Buffer.from([task.length, generationBytes.length]),
95
+ task,
96
+ generationBytes,
97
+ ]);
98
+ }
99
+
100
+ function responseMac(secret, taskId, generation, nonce) {
101
+ return createHmac("sha256", secret)
102
+ .update(HMAC_DOMAIN)
103
+ .update(taskId, "utf8")
104
+ .update(FIELD_SEPARATOR)
105
+ .update(generation, "ascii")
106
+ .update(FIELD_SEPARATOR)
107
+ .update(nonce)
108
+ .digest();
109
+ }
110
+
111
+ async function readExact(socket, length, timeoutMs) {
112
+ return await new Promise((resolve, reject) => {
113
+ const chunks = [];
114
+ let received = 0;
115
+ let done = false;
116
+ const finish = (error, value) => {
117
+ if (done) return;
118
+ done = true;
119
+ clearTimeout(timer);
120
+ socket.pause();
121
+ socket.off("data", onData);
122
+ socket.off("error", onError);
123
+ socket.off("end", onEnd);
124
+ socket.off("close", onClose);
125
+ if (error) reject(error);
126
+ else resolve(value);
127
+ };
128
+ const onData = (chunk) => {
129
+ received += chunk.length;
130
+ if (received > length) {
131
+ finish(new Error("oversized forwarder handshake frame"));
132
+ return;
133
+ }
134
+ chunks.push(chunk);
135
+ if (received === length) finish(null, Buffer.concat(chunks, length));
136
+ };
137
+ const onError = () => finish(new Error("forwarder handshake socket error"));
138
+ const onEnd = () => finish(new Error("truncated forwarder handshake frame"));
139
+ const onClose = () => finish(new Error("forwarder handshake socket closed"));
140
+ const timer = setTimeout(
141
+ () => finish(new Error("forwarder handshake timed out")),
142
+ timeoutMs,
143
+ );
144
+ timer.unref();
145
+ socket.on("data", onData);
146
+ socket.once("error", onError);
147
+ socket.once("end", onEnd);
148
+ socket.once("close", onClose);
149
+ socket.resume();
150
+ });
151
+ }
152
+
153
+ async function readSecret(input) {
154
+ const chunks = [];
155
+ let size = 0;
156
+ const timeout = setTimeout(() => input.destroy(), SECRET_INPUT_TIMEOUT_MS);
157
+ timeout.unref();
158
+ try {
159
+ for await (const chunk of input) {
160
+ const bytes = Buffer.from(chunk);
161
+ size += bytes.length;
162
+ if (size > SECRET_BYTES) fail("invalid forwarder secret input");
163
+ chunks.push(bytes);
164
+ }
165
+ } finally {
166
+ clearTimeout(timeout);
167
+ }
168
+ if (size !== SECRET_BYTES) fail("invalid forwarder secret input");
169
+ return Buffer.concat(chunks, SECRET_BYTES);
170
+ }
171
+
172
+ export function createTaskForwarder(options, secret, dependencies = {}) {
173
+ const identityPreamble = encodePreamble(options.taskId, options.generation);
174
+ const connectUpstream = dependencies.createConnection ?? createConnection;
175
+ const activeSockets = new Set();
176
+ let pendingPreauth = 0;
177
+
178
+ const server = createServer({ pauseOnConnect: true }, (local) => {
179
+ activeSockets.add(local);
180
+ local.once("close", () => activeSockets.delete(local));
181
+ if (pendingPreauth >= MAX_PENDING_PREAUTH) {
182
+ local.destroy();
183
+ return;
184
+ }
185
+ pendingPreauth += 1;
186
+ const upstream = connectUpstream({
187
+ host: options.gatewayHost,
188
+ port: options.gatewayPort,
189
+ });
190
+ const preauthTimer = setTimeout(() => {
191
+ local.destroy();
192
+ upstream.destroy();
193
+ }, PREAUTH_TIMEOUT_MS);
194
+ preauthTimer.unref();
195
+ activeSockets.add(upstream);
196
+ upstream.setNoDelay(true);
197
+ upstream.once("close", () => activeSockets.delete(upstream));
198
+ local.once("error", () => upstream.destroy());
199
+ local.once("close", () => upstream.destroy());
200
+ upstream.once("error", () => local.destroy());
201
+
202
+ void (async () => {
203
+ try {
204
+ await new Promise((resolve, reject) => {
205
+ let settled = false;
206
+ const finish = (error) => {
207
+ if (settled) return;
208
+ settled = true;
209
+ upstream.off("connect", onConnect);
210
+ upstream.off("error", onError);
211
+ upstream.off("close", onClose);
212
+ if (error) reject(error);
213
+ else resolve();
214
+ };
215
+ const onConnect = () => finish(null);
216
+ const onError = () => finish(new Error("forwarder upstream failed"));
217
+ const onClose = () => finish(new Error("forwarder upstream closed"));
218
+ upstream.once("connect", onConnect);
219
+ upstream.once("error", onError);
220
+ upstream.once("close", onClose);
221
+ if (upstream.readyState === "open") finish(null);
222
+ else if (upstream.destroyed) onClose();
223
+ });
224
+ upstream.write(identityPreamble);
225
+ const challenge = await readExact(
226
+ upstream,
227
+ CHALLENGE_MAGIC.length + NONCE_BYTES,
228
+ PREAUTH_TIMEOUT_MS,
229
+ );
230
+ if (!challenge.subarray(0, CHALLENGE_MAGIC.length).equals(CHALLENGE_MAGIC)) {
231
+ fail("invalid forwarder challenge");
232
+ }
233
+ const nonce = challenge.subarray(CHALLENGE_MAGIC.length);
234
+ const mac = responseMac(
235
+ secret,
236
+ options.taskId,
237
+ options.generation,
238
+ nonce,
239
+ );
240
+ upstream.write(Buffer.concat([RESPONSE_MAGIC, mac]));
241
+ const accepted = await readExact(
242
+ upstream,
243
+ ACCEPT_MAGIC.length,
244
+ PREAUTH_TIMEOUT_MS,
245
+ );
246
+ if (!accepted.equals(ACCEPT_MAGIC)) fail("forwarder authentication refused");
247
+ local.pipe(upstream);
248
+ upstream.pipe(local);
249
+ local.resume();
250
+ upstream.resume();
251
+ } catch {
252
+ local.destroy();
253
+ upstream.destroy();
254
+ } finally {
255
+ clearTimeout(preauthTimer);
256
+ pendingPreauth -= 1;
257
+ }
258
+ })();
259
+ });
260
+
261
+ server.shutdown = () => {
262
+ for (const socket of activeSockets) socket.destroy();
263
+ activeSockets.clear();
264
+ server.close();
265
+ };
266
+ return server;
267
+ }
268
+
269
+ async function main() {
270
+ // The launcher additionally applies no_new_privs and clears capabilities.
271
+ // Refuse a wiring regression that runs this secret holder as root or node.
272
+ if (
273
+ process.getuid?.() !== FORWARDER_UID ||
274
+ process.getgid?.() !== FORWARDER_UID
275
+ ) {
276
+ fail(`task forwarder must run as uid/gid ${FORWARDER_UID}`);
277
+ }
278
+ const options = parseForwarderArgs(process.argv.slice(2));
279
+ const rawSecret = await readSecret(process.stdin);
280
+ const secret = createSecretKey(rawSecret);
281
+ rawSecret.fill(0);
282
+ const server = createTaskForwarder(options, secret);
283
+ server.once("error", () => {
284
+ process.stderr.write("uai task forwarder could not start\n");
285
+ process.exitCode = 1;
286
+ });
287
+ server.listen(options.listenPort, "127.0.0.1", () => {
288
+ process.stdout.write("READY\n");
289
+ });
290
+ const stop = () => server.shutdown();
291
+ process.once("SIGINT", stop);
292
+ process.once("SIGTERM", stop);
293
+ }
294
+
295
+ if (
296
+ process.argv[1] &&
297
+ import.meta.url === pathToFileURL(process.argv[1]).href
298
+ ) {
299
+ main().catch(() => {
300
+ process.stderr.write("uai task forwarder failed\n");
301
+ process.exitCode = 1;
302
+ });
303
+ }
package/lib/agent-cli.ts CHANGED
@@ -22,10 +22,14 @@ import {
22
22
  import { resolve } from "node:path";
23
23
 
24
24
  import { env, taskWorkspaceDir } from "./env";
25
- import { RUNTIME_AUTHORITY_ENV } from "./runtime-authority";
25
+ import {
26
+ PROJECT_ENV_EXEC_PATH,
27
+ RUNTIME_AUTHORITY_ENV,
28
+ } from "./runtime-authority";
26
29
  import { signTaskToken } from "./task-token";
27
30
  import { assertSafeHostTaskId } from "./task-identity";
28
31
  import type { RosterAgent } from "./agents/types";
32
+ import type { TaskEnvironmentAgentSessionSurface } from "./task-environment/types";
29
33
 
30
34
  /** Container path of the CLI — what the preamble tells agents to run. */
31
35
  export const CONTAINER_CLI_PATH = "/workspace/.uai/cli.mjs";
@@ -36,6 +40,19 @@ export const CONTAINER_CLI_PATH = "/workspace/.uai/cli.mjs";
36
40
  * underneath this object so those credentials cannot replace host policy. */
37
41
  export const AGENT_RUNTIME_AUTHORITY_ENV = RUNTIME_AUTHORITY_ENV;
38
42
 
43
+ /**
44
+ * One rollout-aware argv policy for every agent CLI. The wrapper is installed
45
+ * only in PR2+ environments; legacy baked tasks must launch exactly as before.
46
+ */
47
+ export function agentCliArgv(
48
+ environment: Pick<TaskEnvironmentAgentSessionSurface, "descriptor">,
49
+ argv: readonly [string, ...string[]],
50
+ ): [string, ...string[]] {
51
+ return environment.descriptor.envMode === "loader"
52
+ ? [PROJECT_ENV_EXEC_PATH, ...argv]
53
+ : [...argv];
54
+ }
55
+
39
56
  // The task's cli secret lives HOST-PRIVATE (never the container), so it survives
40
57
  // host restarts without a DB migration. The cloud re-sends it on each task-up.
41
58
  function taskSecretPath(taskId: string): string {