@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.
Files changed (98) 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/attach-compatibility.ts +51 -0
  12. package/lib/agents/codex.ts +137 -13
  13. package/lib/agents/cursor.ts +15 -1
  14. package/lib/agents/durable-proc.ts +2 -0
  15. package/lib/agents/grok.ts +8 -0
  16. package/lib/agents/kimi.ts +1 -0
  17. package/lib/agents/machine-durable.ts +17 -0
  18. package/lib/agents/opencode.ts +1 -0
  19. package/lib/agents/proc.ts +6 -1
  20. package/lib/agents/session-state.ts +107 -0
  21. package/lib/agents/transport.ts +125 -58
  22. package/lib/agents/types.ts +3 -3
  23. package/lib/browser-testing.ts +6 -0
  24. package/lib/codex-auth.ts +2 -0
  25. package/lib/credential-store.ts +141 -6
  26. package/lib/engine-accounts.ts +1 -0
  27. package/lib/env-file.ts +7 -0
  28. package/lib/env-schema.ts +841 -51
  29. package/lib/git-diff.ts +1 -0
  30. package/lib/git-identity.ts +2 -0
  31. package/lib/github-tokens.ts +1 -0
  32. package/lib/host-env.ts +98 -18
  33. package/lib/lease.ts +11 -3
  34. package/lib/machine-gateway-tunnel.ts +195 -17
  35. package/lib/machine-project-env-loader.ts +152 -0
  36. package/lib/managed-runtime.ts +270 -51
  37. package/lib/mcp-gateway.ts +777 -26
  38. package/lib/orchestrator.ts +1681 -138
  39. package/lib/project-env-authority.ts +1319 -0
  40. package/lib/project-env-feature.ts +74 -0
  41. package/lib/project-env-loader.ts +237 -0
  42. package/lib/project-env-replica.ts +2094 -0
  43. package/lib/project-env-schema-capture.ts +342 -0
  44. package/lib/project-env-staging.ts +148 -0
  45. package/lib/project-env-task-policy.ts +176 -0
  46. package/lib/runtime-archive.ts +79 -16
  47. package/lib/runtime-authority.ts +44 -0
  48. package/lib/runtime-state.ts +175 -10
  49. package/lib/secrets-proxy/address.ts +72 -0
  50. package/lib/secrets-proxy/audit.ts +30 -0
  51. package/lib/secrets-proxy/ca-store.ts +200 -0
  52. package/lib/secrets-proxy/ca.ts +470 -0
  53. package/lib/secrets-proxy/container-steps.ts +138 -0
  54. package/lib/secrets-proxy/der.ts +273 -0
  55. package/lib/secrets-proxy/engine.ts +1633 -0
  56. package/lib/secrets-proxy/host.ts +93 -0
  57. package/lib/secrets-proxy/hostname.ts +60 -0
  58. package/lib/secrets-proxy/placeholders.ts +196 -0
  59. package/lib/secrets-proxy/policy.ts +141 -0
  60. package/lib/secrets-proxy/readiness.ts +148 -0
  61. package/lib/secrets-proxy/runtime.ts +582 -0
  62. package/lib/secrets-proxy/scrub.ts +559 -0
  63. package/lib/secrets-proxy/sidecar-view.ts +139 -0
  64. package/lib/secrets-proxy/task-scripts.ts +262 -0
  65. package/lib/secrets-proxy/task-trust.ts +574 -0
  66. package/lib/secrets-proxy/trust-watch.ts +80 -0
  67. package/lib/shared-files-s3.ts +3 -0
  68. package/lib/sidecar-guard.ts +58 -0
  69. package/lib/sidecar-resume.ts +51 -3
  70. package/lib/standard-image.ts +50 -12
  71. package/lib/task-env-forwarder.ts +228 -0
  72. package/lib/task-env-source.ts +193 -0
  73. package/lib/task-environment/apple-container.ts +45 -11
  74. package/lib/task-environment/docker.ts +51 -12
  75. package/lib/task-environment/index.ts +6 -2
  76. package/lib/task-environment/machine-task-up.ts +258 -25
  77. package/lib/task-environment/machine.ts +80 -22
  78. package/lib/task-environment/registry.ts +3 -2
  79. package/lib/task-environment/self-identity.ts +20 -0
  80. package/lib/task-environment/self.ts +92 -16
  81. package/lib/task-environment/types.ts +63 -0
  82. package/lib/task-gateway-port.ts +22 -0
  83. package/lib/task-gateway-protocol.ts +190 -0
  84. package/lib/task-gateway-recovery.ts +44 -0
  85. package/lib/task-gateway-state.ts +497 -0
  86. package/lib/transcript.ts +1 -0
  87. package/lib/ulid.ts +10 -1
  88. package/package.json +13 -1
  89. package/scripts/agent/env-hook.sh +626 -0
  90. package/scripts/agent/task-up.sh +476 -27
  91. package/scripts/agent/uai-env-exec.sh +26 -0
  92. package/src/cli.ts +26 -3
  93. package/src/index.ts +273 -19
  94. package/src/main.ts +941 -48
  95. package/src/project-env-latest.ts +130 -0
  96. package/src/project-env-policy.ts +281 -0
  97. package/src/project-env-wording.ts +47 -0
  98. package/src/protocol.ts +2239 -15
@@ -0,0 +1,262 @@
1
+ /**
2
+ * The root-side steps that put a task behind the secrets proxy (ADR-153 §3
3
+ * "Redirect" and "Trust inside the task"): the pinned-names block in
4
+ * /etc/hosts, the Uai-owned NAT chain, and the CA in the system store.
5
+ *
6
+ * Each is a POSIX sh script the runner runs as root inside the task —
7
+ * `docker exec --privileged -u root` for the chain on Docker (only that one
8
+ * process gets NET_ADMIN), `container exec -u root` on Apple, the sidecar
9
+ * itself on machines. They are reconciliations, safe to run at every start
10
+ * and on every change: the hosts block is rewritten between its markers,
11
+ * the chain's contents are replaced, and nothing Docker owns is touched.
12
+ *
13
+ * Nothing a task controls is ever interpolated: domains must be canonical
14
+ * and proxiable, addresses must parse, ports and users are checked, and the
15
+ * CA arrives on stdin, never in argv.
16
+ */
17
+
18
+ import { canonicalHostname, isProxiableDomain } from "./hostname";
19
+
20
+ export const HOSTS_BLOCK_BEGIN = "# uai secrets proxy: begin (managed by the runner; do not edit)";
21
+ export const HOSTS_BLOCK_END = "# uai secrets proxy: end";
22
+ /** The task-side loopback address every proxied name resolves to. */
23
+ export const PROXY_PIN_ADDRESS = "127.0.0.2";
24
+ export const PROXY_CHAIN = "UAI_SECRETS";
25
+ /** Where the system store picks up local CAs. Each proxy CA gets its own
26
+ * file there: `openssl rehash` skips a file holding two certificates, and a
27
+ * CA must be a new file name, not new content under an old one, for
28
+ * `update-ca-certificates` to re-hash at all. */
29
+ export const TASK_CA_DIR = "/usr/local/share/ca-certificates";
30
+ /** Every proxy CA the task trusts, in one file: what NODE_EXTRA_CA_CERTS
31
+ * names. Outside TASK_CA_DIR, so the store does not take it twice. */
32
+ export const TASK_CA_BUNDLE_PATH = "/usr/local/share/uai/secrets-proxy-ca.pem";
33
+
34
+ /** The only PATH these root scripts run with, whatever the exec inherited:
35
+ * root-owned system directories only. The inherited one can start with
36
+ * task-writable entries (Apple's asdf shims overlay), and anything found
37
+ * there would run as root. /usr/sbin and /sbin hold Debian's
38
+ * update-ca-certificates and iptables. */
39
+ export const ROOT_SCRIPT_PATH = "/usr/sbin:/usr/bin:/sbin:/bin";
40
+
41
+ /** Sets the PATH (tests pass a directory of fakes ahead of the system one)
42
+ * and clears loader injection for every child the script starts. */
43
+ function rootPrelude(path: string): string {
44
+ return `PATH=${shellQuote(path)}; export PATH; unset LD_PRELOAD LD_AUDIT LD_LIBRARY_PATH`;
45
+ }
46
+
47
+ function shellQuote(value: string): string {
48
+ return `'${value.replace(/'/g, `'\\''`)}'`;
49
+ }
50
+
51
+ function checkedDomains(domains: readonly string[]): string[] {
52
+ const unique = new Set<string>();
53
+ for (const domain of domains) {
54
+ if (canonicalHostname(domain) !== domain || !isProxiableDomain(domain)) {
55
+ throw new Error("refusing to pin a domain that is not canonical and proxiable");
56
+ }
57
+ unique.add(domain);
58
+ }
59
+ return [...unique].sort();
60
+ }
61
+
62
+ /**
63
+ * Rewrites the marked block of `hostsPath` so each domain resolves to the
64
+ * pin. With no domains the block is removed. Docker bind-mounts /etc/hosts,
65
+ * where a rename-over fails, so the file is rewritten in place.
66
+ */
67
+ export function hostsReconcileScript(
68
+ domains: readonly string[],
69
+ hostsPath = "/etc/hosts",
70
+ options: {
71
+ path?: string;
72
+ /**
73
+ * Build the file from this one instead of itself: a task's own view of
74
+ * the hosts on a machine, the machine's file plus the block, which the
75
+ * machine's file never carries. Rebuilt from it each time, so it follows
76
+ * the machine's changes.
77
+ */
78
+ sourcePath?: string;
79
+ } = {},
80
+ ): string {
81
+ // An AAAA lookup of a name the file only pins for IPv4 falls through to DNS
82
+ // and reaches the real upstream. The IPv4-mapped form answers it; a
83
+ // dual-stack socket connects to it over IPv4, into the same chain, and a
84
+ // v6-only socket fails fast rather than going around the proxy.
85
+ const lines = checkedDomains(domains).flatMap((domain) => [
86
+ `${PROXY_PIN_ADDRESS} ${domain}`,
87
+ `::ffff:${PROXY_PIN_ADDRESS} ${domain}`,
88
+ ]);
89
+ const block = lines.length === 0 ? [] : [HOSTS_BLOCK_BEGIN, ...lines, HOSTS_BLOCK_END];
90
+ const source = options.sourcePath ?? hostsPath;
91
+ return [
92
+ "set -eu",
93
+ rootPrelude(options.path ?? ROOT_SCRIPT_PATH),
94
+ `hosts=${shellQuote(hostsPath)}`,
95
+ `from=${shellQuote(source)}`,
96
+ 'kept=$(mktemp) && next=$(mktemp)',
97
+ 'trap \'rm -f "$kept" "$next"\' EXIT',
98
+ // Everything outside a previous block survives, byte for byte.
99
+ `awk -v begin=${shellQuote(HOSTS_BLOCK_BEGIN)} -v end=${shellQuote(HOSTS_BLOCK_END)} ` +
100
+ `'$0 == begin { skip = 1; next } $0 == end { skip = 0; next } !skip { print }' "$from" > "$kept"`,
101
+ `{ cat "$kept"; ${block.map((line) => `printf '%s\\n' ${shellQuote(line)}; `).join("")}} > "$next"`,
102
+ // A view of its own is root's, readable by all; rewritten in place, so
103
+ // a process that has it bind-mounted sees each change.
104
+ ...(source === hostsPath ? [] : ['umask 022', 'mkdir -p "$(dirname "$hosts")"']),
105
+ 'cat "$next" > "$hosts"',
106
+ ].join("\n");
107
+ }
108
+
109
+ /**
110
+ * Replaces the contents of the Uai-owned NAT chain and makes sure OUTPUT
111
+ * jumps to it exactly once. TCP 443 to the pin goes to the task-local
112
+ * listener; on machines `uidOwner` limits it to the task user's traffic so
113
+ * the root sidecar's own upstream connections are never redirected. With
114
+ * `enabled: false` the chain is emptied (nothing is pinned any more).
115
+ */
116
+ export function redirectChainScript(options: {
117
+ toPort: number;
118
+ uidOwner?: string;
119
+ enabled: boolean;
120
+ path?: string;
121
+ /**
122
+ * The task's capabilities were fixed when it was created (an Apple VM), so
123
+ * a root exec without NET_ADMIN proves nothing in it ever had it. Never
124
+ * for Docker, where NET_ADMIN comes with each privileged exec: one without
125
+ * it proves nothing about an earlier one.
126
+ */
127
+ netAdminFromCreation?: boolean;
128
+ /** Where this process's capabilities are read; a test seam. */
129
+ capStatusPath?: string;
130
+ }): string {
131
+ const { toPort, uidOwner, enabled } = options;
132
+ if (!Number.isInteger(toPort) || toPort < 1 || toPort > 65_535) throw new Error("invalid redirect port");
133
+ if (uidOwner !== undefined && !/^[a-z_][a-z0-9_-]{0,31}$/.test(uidOwner)) throw new Error("invalid task user");
134
+ const owner = uidOwner === undefined ? "" : ` -m owner --uid-owner ${uidOwner}`;
135
+ return [
136
+ "set -eu",
137
+ rootPrelude(options.path ?? ROOT_SCRIPT_PATH),
138
+ // An image without iptables cannot have a chain to empty: with nothing to
139
+ // pin there is nothing to do, and a pin needs it.
140
+ "if ! command -v iptables >/dev/null 2>&1 && ! command -v iptables-legacy >/dev/null 2>&1; then",
141
+ ` ${enabled ? 'echo "iptables is not installed" >&2; exit 1' : "exit 0"}`,
142
+ "fi",
143
+ // Debian's iptables is the nf_tables backend; a kernel without it still
144
+ // has the legacy tables.
145
+ // Only where the task's capabilities were fixed at its creation: proof
146
+ // that nothing in it can ever have filled the chain, CAP_NET_ADMIN
147
+ // (0x1000) missing from this root exec's effective set. An Apple VM
148
+ // started before it was given the capability never had it: nothing to
149
+ // empty, and no pin possible. An unknown or malformed set proves
150
+ // nothing, and neither does a nat table out of reach: both go on to the
151
+ // tables below, which fail the step, and it is retried.
152
+ ...(options.netAdminFromCreation === true
153
+ ? [
154
+ `cap_eff=$(awk '$1 == "CapEff:" { print $2 }' ${options.capStatusPath === undefined ? "/proc/$$/status" : shellQuote(options.capStatusPath)} 2>/dev/null || true)`,
155
+ 'case "$cap_eff" in',
156
+ " ''|*[!0-9a-fA-F]*) ;;",
157
+ ` *) if [ $(( 0x$cap_eff & 0x1000 )) -eq 0 ]; then ${enabled ? 'echo "this task has no NET_ADMIN" >&2; exit 1' : "exit 0"}; fi ;;`,
158
+ "esac",
159
+ ]
160
+ : []),
161
+ 'ipt=iptables',
162
+ 'if ! iptables -t nat -S OUTPUT >/dev/null 2>&1; then ipt=iptables-legacy; fi',
163
+ `"$ipt" -t nat -N ${PROXY_CHAIN} 2>/dev/null || true`,
164
+ `"$ipt" -t nat -F ${PROXY_CHAIN}`,
165
+ ...(enabled
166
+ ? [
167
+ `"$ipt" -t nat -A ${PROXY_CHAIN} -d ${PROXY_PIN_ADDRESS}/32 -p tcp --dport 443${owner} ` +
168
+ `-j REDIRECT --to-ports ${toPort}`,
169
+ ]
170
+ : []),
171
+ `"$ipt" -t nat -C OUTPUT -j ${PROXY_CHAIN} 2>/dev/null || "$ipt" -t nat -I OUTPUT 1 -j ${PROXY_CHAIN}`,
172
+ ].join("\n");
173
+ }
174
+
175
+ /**
176
+ * Installs the CA bundle read from stdin — the signing CA and, inside its
177
+ * last 90 days, its successor — into the task's trust: one
178
+ * `uai-secrets-proxy-<checksum>.crt` per certificate in the system store
179
+ * (stale ones removed) and the whole bundle at the NODE_EXTRA_CA_CERTS path,
180
+ * then rebuilds the store. Anything but one or two distinct PEM certificates
181
+ * is refused before anything is touched; a run with nothing to change exits
182
+ * without rebuilding.
183
+ */
184
+ export function caInstallScript(
185
+ options: { caDir?: string; bundlePath?: string; systemBundle?: string; path?: string } = {},
186
+ ): string {
187
+ const {
188
+ caDir = TASK_CA_DIR,
189
+ bundlePath = TASK_CA_BUNDLE_PATH,
190
+ systemBundle = "/etc/ssl/certs/ca-certificates.crt",
191
+ path = ROOT_SCRIPT_PATH,
192
+ } = options;
193
+ return [
194
+ "set -eu",
195
+ rootPrelude(path),
196
+ "umask 022",
197
+ `dir=${shellQuote(caDir)}`,
198
+ `out=${shellQuote(bundlePath)}`,
199
+ `bundle=${shellQuote(systemBundle)}`,
200
+ // Present from the first change until a rebuild succeeds: a run that
201
+ // removed a retired CA's file and then failed to rebuild leaves a store
202
+ // that still trusts it, which nothing in the files themselves shows.
203
+ 'pending="$out.rebuilding"',
204
+ "stage=$(mktemp -d)",
205
+ 'trap \'rm -rf "$stage"\' EXIT',
206
+ 'cat > "$stage/in"',
207
+ 'begins=$(grep -c -- "-----BEGIN CERTIFICATE-----" "$stage/in" || true)',
208
+ 'ends=$(grep -c -- "-----END CERTIFICATE-----" "$stage/in" || true)',
209
+ 'case "$begins" in 1|2) ;; *) echo "not a CA bundle" >&2; exit 65 ;; esac',
210
+ '[ "$ends" = "$begins" ] || { echo "not a CA bundle" >&2; exit 65; }',
211
+ // Each certificate's first base64 line carries its random serial, so it
212
+ // names that certificate in the system bundle.
213
+ 'firsts=$(awk \'prev == "-----BEGIN CERTIFICATE-----" { print } { prev = $0 }\' "$stage/in")',
214
+ '[ "$(printf \'%s\\n\' "$firsts" | grep -c .)" = "$begins" ] || { echo "not a CA bundle" >&2; exit 65; }',
215
+ '[ -z "$(printf \'%s\\n\' "$firsts" | sort | uniq -d)" ] || { echo "not a CA bundle" >&2; exit 65; }',
216
+ // One file per certificate, named by its checksum.
217
+ "awk -v stage=\"$stage\" '/^-----BEGIN CERTIFICATE-----$/ { n++; file = stage \"/cert.\" n } " +
218
+ "file != \"\" { print > file } /^-----END CERTIFICATE-----$/ { close(file); file = \"\" }' \"$stage/in\"",
219
+ 'names=""',
220
+ ': > "$stage/bundle"',
221
+ 'for cert in "$stage"/cert.*; do',
222
+ ' name="uai-secrets-proxy-$(cksum < "$cert" | awk \'{ print $1 }\').crt"',
223
+ ' mv "$cert" "$stage/$name"',
224
+ ' cat "$stage/$name" >> "$stage/bundle"',
225
+ ' names="$names $name"',
226
+ "done",
227
+ // Up to date: the same files, no stale ones, the same NODE_EXTRA_CA_CERTS
228
+ // bundle, and every certificate already in the system bundle. A run that
229
+ // placed the files but whose rebuild failed is repaired here.
230
+ "current=1",
231
+ 'for name in $names; do cmp -s "$stage/$name" "$dir/$name" || current=0; done',
232
+ 'for existing in "$dir"/uai-secrets-proxy*.crt; do',
233
+ ' [ -e "$existing" ] || continue',
234
+ ' case " $names " in *" ${existing##*/} "*) ;; *) current=0 ;; esac',
235
+ "done",
236
+ 'cmp -s "$stage/bundle" "$out" 2>/dev/null || current=0',
237
+ '[ -f "$bundle" ] || current=0',
238
+ '[ ! -e "$pending" ] || current=0',
239
+ 'if [ "$current" = 1 ]; then',
240
+ ' for first in $firsts; do grep -qxF -- "$first" "$bundle" || current=0; done',
241
+ "fi",
242
+ '[ "$current" = 0 ] || exit 0',
243
+ 'mkdir -p "$dir" "$(dirname "$out")"',
244
+ // A rebuild left pending by an earlier run may hide a removal: rebuild
245
+ // everything, as for one.
246
+ "fresh=0",
247
+ '[ ! -e "$pending" ] || fresh=1',
248
+ ': > "$pending"',
249
+ 'for existing in "$dir"/uai-secrets-proxy*.crt; do',
250
+ ' [ -e "$existing" ] || continue',
251
+ ' case " $names " in *" ${existing##*/} "*) ;; *) rm -f "$existing"; fresh=1 ;; esac',
252
+ "done",
253
+ // Renamed into place, so no reader ever sees a half-written file.
254
+ 'for name in $names; do cp "$stage/$name" "$dir/$name.uai-tmp" && mv "$dir/$name.uai-tmp" "$dir/$name"; done',
255
+ 'cp "$stage/bundle" "$out.uai-tmp" && mv "$out.uai-tmp" "$out"',
256
+ // A removal alone adds no link, so the tool would neither re-hash nor
257
+ // clear the retired CA's links; --fresh rebuilds them all (under a
258
+ // second, once per rotation).
259
+ 'if [ "$fresh" = 1 ]; then update-ca-certificates --fresh >/dev/null; else update-ca-certificates >/dev/null; fi',
260
+ 'rm -f "$pending"',
261
+ ].join("\n");
262
+ }