@runuai/host 0.9.147 → 0.9.149
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/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +312 -2
- package/images/standard/Dockerfile +44 -6
- package/images/standard/container/uai-init +85 -2
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/lib/agent-cli.ts +18 -1
- package/lib/agent.ts +99 -34
- package/lib/agents/attach-compatibility.ts +51 -0
- package/lib/agents/codex.ts +137 -13
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/durable-proc.ts +2 -0
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +17 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/session-state.ts +107 -0
- package/lib/agents/transport.ts +125 -58
- package/lib/agents/types.ts +3 -3
- package/lib/browser-testing.ts +6 -0
- package/lib/codex-auth.ts +2 -0
- package/lib/credential-store.ts +141 -6
- package/lib/engine-accounts.ts +1 -0
- package/lib/env-file.ts +7 -0
- package/lib/env-schema.ts +841 -51
- package/lib/git-diff.ts +1 -0
- package/lib/git-identity.ts +2 -0
- package/lib/github-tokens.ts +1 -0
- package/lib/host-env.ts +98 -18
- package/lib/lease.ts +11 -3
- package/lib/machine-gateway-tunnel.ts +195 -17
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/managed-runtime.ts +270 -51
- package/lib/mcp-gateway.ts +777 -26
- package/lib/orchestrator.ts +1681 -138
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-loader.ts +237 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/runtime-archive.ts +79 -16
- package/lib/runtime-authority.ts +44 -0
- package/lib/runtime-state.ts +175 -10
- package/lib/secrets-proxy/address.ts +72 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +60 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +141 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +574 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/shared-files-s3.ts +3 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +51 -3
- package/lib/standard-image.ts +50 -12
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +193 -0
- package/lib/task-environment/apple-container.ts +45 -11
- package/lib/task-environment/docker.ts +51 -12
- package/lib/task-environment/index.ts +6 -2
- package/lib/task-environment/machine-task-up.ts +258 -25
- package/lib/task-environment/machine.ts +80 -22
- package/lib/task-environment/registry.ts +3 -2
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +92 -16
- package/lib/task-environment/types.ts +63 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +44 -0
- package/lib/task-gateway-state.ts +497 -0
- package/lib/transcript.ts +1 -0
- package/lib/ulid.ts +10 -1
- package/package.json +13 -1
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/task-up.sh +476 -27
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/src/cli.ts +26 -3
- package/src/index.ts +273 -19
- package/src/main.ts +941 -48
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +281 -0
- package/src/project-env-wording.ts +47 -0
- package/src/protocol.ts +2239 -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 '
|
|
263
|
-
|
|
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 '
|
|
272
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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 {
|