@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,58 @@
1
+ /**
2
+ * A task machine's own guards, held by its sidecar (root) before the task
3
+ * runs anything there (ADR-133; ADR-153 §3, "Where the proxy runs"):
4
+ *
5
+ * - The runner's data directory is root's alone, so the task's user cannot
6
+ * read the store's database. Its values are sealed with a root-only key,
7
+ * but its key names and bindings would still be there to read.
8
+ * - The instance metadata service is closed to the task's user, so it can
9
+ * read neither the machine's user-data, which carries the sidecar's host
10
+ * token, nor the instance role's credentials.
11
+ *
12
+ * The image's prepare step sets both up too. The runner insists on them at
13
+ * every task-up, since an image that skipped or failed that step would
14
+ * otherwise let the task start without them.
15
+ */
16
+
17
+ import { execFileSync } from "node:child_process";
18
+ import { chmodSync, mkdirSync } from "node:fs";
19
+
20
+ /** Rejects the task user's traffic to the instance metadata service, with
21
+ * whichever iptables backend the kernel serves (as the proxy's chain does),
22
+ * adding the rule only when it is missing. */
23
+ export const METADATA_BLOCK_SCRIPT = [
24
+ "set -eu",
25
+ "ipt=iptables",
26
+ "if ! command -v iptables >/dev/null 2>&1 || ! iptables -S OUTPUT >/dev/null 2>&1; then ipt=iptables-legacy; fi",
27
+ 'command -v "$ipt" >/dev/null 2>&1 || { echo "iptables is not installed" >&2; exit 1; }',
28
+ '"$ipt" -C OUTPUT -d 169.254.169.254 -m owner --uid-owner node -j REJECT 2>/dev/null \\',
29
+ ' || "$ipt" -I OUTPUT -d 169.254.169.254 -m owner --uid-owner node -j REJECT',
30
+ ].join("\n");
31
+
32
+ export interface SidecarGuardDeps {
33
+ dataDir: string;
34
+ /** Runs a script as this (root) process; throws when it fails. */
35
+ runScript(script: string): void;
36
+ }
37
+
38
+ const defaultRunScript = (script: string): void => {
39
+ execFileSync("/bin/sh", ["-c", script], { stdio: ["ignore", "ignore", "pipe"], timeout: 15_000 });
40
+ };
41
+
42
+ /** Puts both guards in place, or says which could not be. */
43
+ export function guardSidecarMachine(deps: Partial<SidecarGuardDeps> & Pick<SidecarGuardDeps, "dataDir">): { ok: true } | { ok: false; reason: string } {
44
+ const runScript = deps.runScript ?? defaultRunScript;
45
+ try {
46
+ mkdirSync(deps.dataDir, { recursive: true, mode: 0o700 });
47
+ chmodSync(deps.dataDir, 0o700);
48
+ } catch (error) {
49
+ return { ok: false, reason: `its data directory could not be made private (${error instanceof Error ? error.message : String(error)})` };
50
+ }
51
+ try {
52
+ runScript(METADATA_BLOCK_SCRIPT);
53
+ } catch (error) {
54
+ const detail = error instanceof Error && "stderr" in error ? String((error as { stderr?: unknown }).stderr ?? "").trim() : "";
55
+ return { ok: false, reason: `the instance metadata service could not be closed to the task${detail ? ` (${detail})` : ""}` };
56
+ }
57
+ return { ok: true };
58
+ }
@@ -19,6 +19,10 @@ import { getDb } from "./db";
19
19
  import { mountSharedFilesFromLease } from "./shared-files-s3";
20
20
  import { sidecarTaskId } from "./sidecar";
21
21
  import { reconstructPersistedTaskEnvironment } from "./task-environment";
22
+ import { installMachineProjectEnvLoader } from "./machine-project-env-loader";
23
+ import { activeSecretsProxy } from "./secrets-proxy/host";
24
+ import { taskViewProven } from "./secrets-proxy/sidecar-view";
25
+ import { reconcileSidecarTaskProxy } from "./secrets-proxy/task-trust";
22
26
  import { ensureCodeServer } from "./task-environment/machine-task-up";
23
27
  import type { TaskEnvironmentHandle } from "./task-environment/types";
24
28
 
@@ -33,12 +37,15 @@ export interface SidecarResumeDeps {
33
37
  workspacePath: string,
34
38
  task: { sharedFiles?: string },
35
39
  ) => Promise<string | null>;
40
+ loader?: (handle: TaskEnvironmentHandle<unknown>) => Promise<void>;
36
41
  editor?: (handle: TaskEnvironmentHandle<unknown>, workspacePath: string) => Promise<void>;
42
+ proxy?: typeof reconcileSidecarTaskProxy;
37
43
  }
38
44
 
39
45
  export interface SidecarResumeResult {
40
- /** `no-task`: not a sidecar. `not-up`: the task has not been provisioned on
41
- * this machine yet (a fresh launch — task-up does the work). `resumed`: the
46
+ /** `no-task`: not a sidecar. `not-up`: the task has not come up on this
47
+ * machine yet (a fresh launch, or a start that a runner restart cut short —
48
+ * task-up does the work). `resumed`: the
42
49
  * task was up before this runner started; its mounts and editor were
43
50
  * re-asserted, with any per-step warning listed. */
44
51
  outcome: "no-task" | "not-up" | "resumed";
@@ -52,18 +59,59 @@ function mountedModeFromMarker(bytes: Uint8Array | null): "ro" | "rw" | null {
52
59
  return Buffer.from(bytes).toString("utf8").trim() === "rw" ? "rw" : "ro";
53
60
  }
54
61
 
62
+ /**
63
+ * ADR-153 machines, at the runner's start and before any command can reach
64
+ * the task: a task this machine has a row for, in whatever state (`starting`
65
+ * included), is held for this boot unless its hosts-view record proves it.
66
+ * A start driven again, or a resume, then finds the hold. Only task-up for a
67
+ * task with no row admits it fresh (src/index.ts).
68
+ */
69
+ export function classifySidecarTaskView(
70
+ deps: Pick<SidecarResumeDeps, "taskId" | "persistedTask"> = {},
71
+ ): void {
72
+ const taskId = (deps.taskId ?? sidecarTaskId)();
73
+ if (!taskId) return;
74
+ if ((deps.persistedTask ?? defaultPersistedTask)(taskId) === undefined) return;
75
+ // Asked for its record: one with none is held from here on.
76
+ taskViewProven(taskId);
77
+ }
78
+
55
79
  export async function resumeSidecarTask(
56
80
  deps: SidecarResumeDeps = {},
57
81
  ): Promise<SidecarResumeResult> {
58
82
  const taskId = (deps.taskId ?? sidecarTaskId)();
59
83
  if (!taskId) return { outcome: "no-task", warnings: [] };
60
84
  const task = (deps.persistedTask ?? defaultPersistedTask)(taskId);
61
- if (!task?.environmentLocator) return { outcome: "not-up", warnings: [] };
85
+ // Only a task that came all the way up: one still starting (a runner that
86
+ // died mid-provision) is task-up's to finish, and resuming it would race
87
+ // that retry's loader, proxy steps and editor.
88
+ if (!task?.environmentLocator || task.statusMirror !== "running") return { outcome: "not-up", warnings: [] };
62
89
  const handle = await (deps.reconstruct ?? reconstructPersistedTaskEnvironment)(task);
63
90
  if (!handle) return { outcome: "not-up", warnings: [] };
64
91
  const workspacePath = handle.descriptor.workspacePath;
65
92
  const warnings: string[] = [];
66
93
 
94
+ if (task.envMode === "loader") {
95
+ try {
96
+ await (deps.loader ?? installMachineProjectEnvLoader)(handle);
97
+ } catch (error) {
98
+ // The loader remains fail-closed: no project value was baked into this
99
+ // machine. Keep repairing the editor/mounts, but make the degraded env
100
+ // channel visible to the sidecar's restart log.
101
+ warnings.push(
102
+ `project env loader: ${error instanceof Error ? error.message : String(error)}`,
103
+ );
104
+ }
105
+ // ADR-153: the proxy's CA and pins again (a reboot keeps neither the
106
+ // chain nor this runner's record of any of it).
107
+ const proxyWarning = await (deps.proxy ?? reconcileSidecarTaskProxy)(
108
+ handle,
109
+ activeSecretsProxy()?.runtime ?? null,
110
+ { start: false },
111
+ );
112
+ if (proxyWarning !== null) warnings.push(proxyWarning);
113
+ }
114
+
67
115
  const marker = await handle
68
116
  .readWorkspaceFile(`${workspacePath}/.uai/files-mounted`)
69
117
  .catch(() => null);
@@ -60,6 +60,9 @@ export const ASDF_DATA_VOLUME = "uai-asdf-data";
60
60
  */
61
61
  export const STANDARD_DEFAULT_NODE_VERSION = "22.11.0";
62
62
 
63
+ /** Exact Codex CLI release exposed to task runtimes through the shared volume. */
64
+ export const STANDARD_CODEX_CLI_VERSION = "0.157.0";
65
+
63
66
  /**
64
67
  * Runtimes the standard image's asdf plugins can satisfy, with a sensible set
65
68
  * of advertised versions per kind (ADR-021 `runtimes`). These are the
@@ -510,9 +513,19 @@ async function runMaintenanceContainer(
510
513
  * e.g. a stale volume with `codex` but no `claude`, which makes `claude` exit
511
514
  * 127 in every task container. Reconciled on each host start.
512
515
  */
513
- const VOLUME_AGENT_CLIS: { bin: string; pkg: string }[] = [
514
- { bin: "claude", pkg: "@anthropic-ai/claude-code" },
515
- { bin: "codex", pkg: "@openai/codex" },
516
+ const VOLUME_AGENT_CLIS: {
517
+ bin: string;
518
+ pkg: string;
519
+ version: string;
520
+ expectedVersionOutput?: string;
521
+ }[] = [
522
+ { bin: "claude", pkg: "@anthropic-ai/claude-code", version: "latest" },
523
+ {
524
+ bin: "codex",
525
+ pkg: "@openai/codex",
526
+ version: STANDARD_CODEX_CLI_VERSION,
527
+ expectedVersionOutput: `codex-cli ${STANDARD_CODEX_CLI_VERSION}`,
528
+ },
516
529
  ];
517
530
 
518
531
  /**
@@ -521,7 +534,16 @@ const VOLUME_AGENT_CLIS: { bin: string; pkg: string }[] = [
521
534
  * locked reconcile below always performs its own proof before releasing.
522
535
  */
523
536
  async function probeVolumeAgentClis(): Promise<string[] | null> {
524
- const bins = VOLUME_AGENT_CLIS.map((c) => c.bin).join(" ");
537
+ const checks = VOLUME_AGENT_CLIS.map((cli) => {
538
+ const missing = `echo "UAI_MISSING:${cli.bin}"`;
539
+ if (cli.expectedVersionOutput === undefined) {
540
+ return `"${cli.bin}" --version >/dev/null 2>&1 || ${missing}`;
541
+ }
542
+ return (
543
+ `version="$("${cli.bin}" --version 2>/dev/null)" && ` +
544
+ `[ "$version" = "${cli.expectedVersionOutput}" ] || ${missing}`
545
+ );
546
+ }).join("; ");
525
547
  // Probe by EXECUTING each bin (`--version`), not `command -v`. A stale asdf
526
548
  // shim — the shim file survives in the volume but the reshim DB no longer
527
549
  // maps it to an installed exe — passes `command -v` (the file is present and
@@ -542,7 +564,7 @@ async function probeVolumeAgentClis(): Promise<string[] | null> {
542
564
  STANDARD_IMAGE_TAG,
543
565
  "/bin/bash",
544
566
  "-c",
545
- `for b in ${bins}; do "$b" --version >/dev/null 2>&1 || echo "UAI_MISSING:$b"; done`,
567
+ checks,
546
568
  ], SHORT_DOCKER_TIMEOUT_MS);
547
569
  if (check.code !== 0) {
548
570
  console.warn(
@@ -609,7 +631,9 @@ async function prepareAsdfMaintenanceLock(): Promise<boolean> {
609
631
  export function standardImageVolumeMaintenanceCommandForTest(
610
632
  autoUpdate: boolean,
611
633
  ): string {
612
- const pkgs = VOLUME_AGENT_CLIS.map((c) => `${c.pkg}@latest`).join(" ");
634
+ const pkgs = VOLUME_AGENT_CLIS.map(
635
+ (cli) => `${cli.pkg}@${cli.version}`,
636
+ ).join(" ");
613
637
  const trustedNpmFlags =
614
638
  "--registry=https://registry.npmjs.org/ --userconfig=/dev/null " +
615
639
  "--globalconfig=/dev/uai-empty-npmrc";
@@ -625,6 +649,7 @@ export function standardImageVolumeMaintenanceCommandForTest(
625
649
  `COREPACK_ENABLE_UNSAFE_CUSTOM_URLS=0 COREPACK_INTEGRITY_CHECK=1 ` +
626
650
  `COREPACK_ENABLE_NETWORK=0 COREPACK_NPM_REGISTRY=https://registry.npmjs.org; ` +
627
651
  `default_node="$(/usr/bin/awk '$1 == "nodejs" { print $2; exit }' /home/node/.tool-versions)"; ` +
652
+ `required_codex="${STANDARD_CODEX_CLI_VERSION}"; ` +
628
653
  `if ! printf '%s' "$default_node" | /usr/bin/grep -Eq '^[0-9]+\\.[0-9]+\\.[0-9]+$'; then ` +
629
654
  `fail default-node; fi; ` +
630
655
  `[ "$default_node" = "${STANDARD_DEFAULT_NODE_VERSION}" ] || ` +
@@ -781,14 +806,17 @@ export function standardImageVolumeMaintenanceCommandForTest(
781
806
  `contained_executable "$candidate" "$candidate/bin/$b" || return 1; ` +
782
807
  `direct_version="$(as_node "$candidate/bin/$b" --version 2>/dev/null ` +
783
808
  `| /usr/bin/head -1)"; [ -n "$direct_version" ] || return 1; ` +
809
+ `if [ "$b" = codex ] && ` +
810
+ `[ "$direct_version" != "codex-cli $required_codex" ]; then return 1; fi; ` +
784
811
  `shim="$shims_root/$b"; [ ! -L "$shim" ] && [ -f "$shim" ] ` +
785
812
  `&& [ -x "$shim" ] || return 1; ` +
786
813
  `shim_version="$(as_node "$shim" --version 2>/dev/null ` +
787
814
  `| /usr/bin/head -1)"; [ "$shim_version" = "$direct_version" ]; }; ` +
788
815
  `repair_cli_once() { b="$1"; case "$b" in ` +
789
816
  `claude) pkg='@anthropic-ai/claude-code'; scope='@anthropic-ai'; ` +
790
- `package_name='claude-code';; ` +
791
- `codex) pkg='@openai/codex'; scope='@openai'; package_name='codex';; ` +
817
+ `package_name='claude-code'; version='latest';; ` +
818
+ `codex) pkg='@openai/codex'; scope='@openai'; package_name='codex'; ` +
819
+ `version="$required_codex";; ` +
792
820
  `*) return 1;; esac; ` +
793
821
  `package_path="$candidate/lib/node_modules/$scope/$package_name"; ` +
794
822
  `npm_prefix_is_safe || return 1; ` +
@@ -796,7 +824,7 @@ export function standardImageVolumeMaintenanceCommandForTest(
796
824
  `|| return 1; ` +
797
825
  `as_node /usr/bin/rm -rf --one-file-system -- ` +
798
826
  `"$candidate/bin/$b" "$package_path" || return 1; ` +
799
- `as_node "$npm_bin" install -g --force "$pkg@latest" ${trustedNpmFlags} ` +
827
+ `as_node "$npm_bin" install -g --force "$pkg@$version" ${trustedNpmFlags} ` +
800
828
  `>/dev/null 2>&1 || return 1; ` +
801
829
  `run_asdf_reshim nodejs "$default_node" ` +
802
830
  `>/dev/null 2>&1 || return 1; cli_ready_once "$b"; }; ` +
@@ -841,6 +869,9 @@ export function standardImageVolumeMaintenanceCommandForTest(
841
869
  `|| [ ! -x "$shim" ]; then echo "UAI_STILL_BROKEN:$b-shim" >&2; ` +
842
870
  `failed=1; continue; fi; ` +
843
871
  `shim_version="$(as_node "$shim" --version 2>/dev/null | /usr/bin/head -1)"; ` +
872
+ `if [ "$b" = codex ] && ` +
873
+ `[ "$version" != "codex-cli $required_codex" ]; then ` +
874
+ `echo "UAI_STILL_BROKEN:$b-version" >&2; failed=1; continue; fi; ` +
844
875
  `if [ -z "$version" ] || [ "$shim_version" != "$version" ]; then ` +
845
876
  `echo "UAI_STILL_BROKEN:$b" >&2; failed=1; ` +
846
877
  `else printf '%s ' "$version"; fi; done; ` +
@@ -947,7 +978,11 @@ async function writeVolumeReconcileProof(): Promise<void> {
947
978
  try {
948
979
  writeFileSync(
949
980
  await volumeReconcileProofPath(),
950
- `${JSON.stringify({ schemaVersion: 1, provedAt: Date.now() })}\n`,
981
+ `${JSON.stringify({
982
+ schemaVersion: 1,
983
+ codexVersion: STANDARD_CODEX_CLI_VERSION,
984
+ provedAt: Date.now(),
985
+ })}\n`,
951
986
  { mode: 0o600 },
952
987
  );
953
988
  } catch (error) {
@@ -963,8 +998,11 @@ async function volumeReconcileProofExists(): Promise<boolean> {
963
998
  try {
964
999
  const raw = JSON.parse(
965
1000
  readFileSync(await volumeReconcileProofPath(), "utf8"),
966
- ) as { schemaVersion?: unknown };
967
- return raw?.schemaVersion === 1;
1001
+ ) as { schemaVersion?: unknown; codexVersion?: unknown };
1002
+ return (
1003
+ raw?.schemaVersion === 1 &&
1004
+ raw.codexVersion === STANDARD_CODEX_CLI_VERSION
1005
+ );
968
1006
  } catch {
969
1007
  return false;
970
1008
  }
@@ -0,0 +1,228 @@
1
+ import type { TaskEnvironmentHandle } from "./task-environment/types";
2
+ import { isEnvironmentTransportProvider } from "./task-environment/types";
3
+ import { MACHINE_GATEWAY_TUNNEL_PORT } from "./machine-gateway-tunnel";
4
+ import {
5
+ installMachineProjectEnvLoader,
6
+ installMachineTaskForwarderAsset,
7
+ } from "./machine-project-env-loader";
8
+ import { MCP_GATEWAY_PORT, taskGatewayHost } from "./mcp-gateway";
9
+ import { STANDARD_DEFAULT_NODE_VERSION } from "./standard-image";
10
+ import {
11
+ taskGatewayForwarderSecretInput,
12
+ taskGatewayRegistrationForRecovery,
13
+ } from "./task-gateway-state";
14
+ import { TASK_FORWARDER_SECRET_BYTES } from "./task-gateway-protocol";
15
+ import type { TaskGatewayRegistration } from "./task-gateway-state";
16
+
17
+ const STEP_OUTPUT_BYTES = 256 * 1024;
18
+ const APPLE_FORWARDER_NODE_PATH =
19
+ `/run/uai/asdf-lower/installs/nodejs/${STANDARD_DEFAULT_NODE_VERSION}/bin/node` as const;
20
+
21
+ type TaskForwarderNodePath =
22
+ | "/usr/bin/node"
23
+ | "/usr/local/bin/node"
24
+ | typeof APPLE_FORWARDER_NODE_PATH;
25
+
26
+ // Shared by initial machine provisioning and every recovery topology. Keep
27
+ // the ownership/process proof in lockstep with task-up.sh's container
28
+ // launcher. The generation secret arrives on stdin; no task file, argv entry,
29
+ // or environment variable ever contains it.
30
+ const START_TASK_FORWARDER = String.raw`set -eu
31
+ task_id=$1
32
+ generation=$2
33
+ gateway_host=$3
34
+ gateway_port=$4
35
+ node_path=$5
36
+ case "$node_path" in
37
+ /usr/bin/node | /usr/local/bin/node) ;;
38
+ ${APPLE_FORWARDER_NODE_PATH})
39
+ [ ! -L "$node_path" ] && [ -f "$node_path" ] && [ -x "$node_path" ] \
40
+ || exit 1
41
+ ;;
42
+ *) exit 1 ;;
43
+ esac
44
+ case "$gateway_port" in ''|*[!0-9]*) exit 1 ;; esac
45
+ [ "$gateway_port" -ge 1 ] && [ "$gateway_port" -le 65535 ] || exit 1
46
+ state_dir=/run/uai/task-forwarder
47
+ pid_file=$state_dir/pid
48
+ ready_file=$state_dir/ready
49
+ error_file=$state_dir/error.log
50
+ umask 077
51
+ [ ! -L "$state_dir" ] || exit 1
52
+ [ ! -e "$state_dir" ] || [ -d "$state_dir" ] || exit 1
53
+ /usr/bin/install -d -o root -g root -m 0700 "$state_dir"
54
+
55
+ if [ -f "$pid_file" ] && [ ! -L "$pid_file" ]; then
56
+ old_pid=$(/bin/cat "$pid_file" 2>/dev/null || true)
57
+ case "$old_pid" in ''|*[!0-9]*) old_pid= ;; esac
58
+ if [ -n "$old_pid" ] && /bin/kill -0 "$old_pid" 2>/dev/null; then
59
+ old_uid=$(/usr/bin/awk '/^Uid:/ { print $2; exit }' "/proc/$old_pid/status" 2>/dev/null || true)
60
+ old_args=$(/usr/bin/tr '\000' '\n' < "/proc/$old_pid/cmdline" 2>/dev/null || true)
61
+ # /run survives a Docker stop/start. Its recorded pid may have been
62
+ # reused by uai-init or another innocent process; treat that record as
63
+ # stale and never signal a process unless both uid and argv prove it is
64
+ # this task's prior forwarder.
65
+ if [ "$old_uid" = 65532 ] \
66
+ && printf '%s\n' "$old_args" | /bin/grep -Fxq '/usr/local/share/uai/uai-task-forwarder.mjs' \
67
+ && printf '%s\n' "$old_args" | /bin/grep -Fxq "$task_id"; then
68
+ /bin/kill -TERM "$old_pid"
69
+ attempts=0
70
+ while /bin/kill -0 "$old_pid" 2>/dev/null && [ "$attempts" -lt 50 ]; do
71
+ /bin/sleep 0.1
72
+ attempts=$((attempts + 1))
73
+ done
74
+ if /bin/kill -0 "$old_pid" 2>/dev/null; then
75
+ /bin/kill -KILL "$old_pid"
76
+ fi
77
+ fi
78
+ fi
79
+ fi
80
+ /bin/rm -f -- "$pid_file" "$ready_file" "$error_file"
81
+
82
+ exec 3<&0
83
+ /usr/bin/env -i PATH=/usr/bin:/bin \
84
+ /usr/bin/setsid \
85
+ /usr/bin/setpriv \
86
+ --reuid=65532 \
87
+ --regid=65532 \
88
+ --clear-groups \
89
+ --bounding-set=-all \
90
+ --inh-caps=-all \
91
+ --ambient-caps=-all \
92
+ --no-new-privs \
93
+ "$node_path" \
94
+ /usr/local/share/uai/uai-task-forwarder.mjs \
95
+ --task-id "$task_id" \
96
+ --generation "$generation" \
97
+ --gateway-host "$gateway_host" \
98
+ --gateway-port "$gateway_port" \
99
+ --listen-port 5877 \
100
+ <&3 3<&- >"$ready_file" 2>"$error_file" &
101
+ forwarder_pid=$!
102
+ exec 3<&-
103
+ printf '%s\n' "$forwarder_pid" > "$pid_file"
104
+
105
+ attempts=0
106
+ while [ "$attempts" -lt 50 ]; do
107
+ /bin/kill -0 "$forwarder_pid" 2>/dev/null || exit 1
108
+ if [ -f "$ready_file" ] \
109
+ && [ "$(/bin/cat "$ready_file" 2>/dev/null || true)" = READY ]; then
110
+ exit 0
111
+ fi
112
+ /bin/sleep 0.1
113
+ attempts=$((attempts + 1))
114
+ done
115
+ exit 1`;
116
+
117
+ export interface TaskEnvForwarderLaunchOptions {
118
+ gatewayHost: string;
119
+ gatewayPort: number;
120
+ nodePath: TaskForwarderNodePath;
121
+ }
122
+
123
+ /** Launch or replace one authenticated task forwarder. Consumes and wipes
124
+ * the caller-owned secret copy on every outcome. */
125
+ export async function launchTaskEnvForwarder(
126
+ environment: Pick<TaskEnvironmentHandle<unknown>, "exec">,
127
+ identity: TaskGatewayRegistration,
128
+ secret: Uint8Array,
129
+ options: TaskEnvForwarderLaunchOptions,
130
+ ): Promise<void> {
131
+ if (
132
+ secret.byteLength !== TASK_FORWARDER_SECRET_BYTES ||
133
+ !Number.isSafeInteger(options.gatewayPort) ||
134
+ options.gatewayPort < 1 ||
135
+ options.gatewayPort > 65_535
136
+ ) {
137
+ secret.fill(0);
138
+ throw new Error("task env forwarder launch parameters are invalid");
139
+ }
140
+ try {
141
+ const result = await environment.exec({
142
+ argv: [
143
+ "/bin/bash",
144
+ "-c",
145
+ START_TASK_FORWARDER,
146
+ "uai-task-forwarder",
147
+ identity.taskId,
148
+ identity.generation,
149
+ options.gatewayHost,
150
+ String(options.gatewayPort),
151
+ options.nodePath,
152
+ ],
153
+ user: "root",
154
+ projectEnvProfile: "maintenance",
155
+ stdin: secret,
156
+ timeoutMs: 30_000,
157
+ maxOutputBytes: STEP_OUTPUT_BYTES,
158
+ });
159
+ if (
160
+ result.exitCode !== 0 ||
161
+ result.signal !== null ||
162
+ result.stdoutTruncated ||
163
+ result.stderrTruncated
164
+ ) {
165
+ throw new Error(
166
+ `task env forwarder failed to start: ${Buffer.from(result.stderr)
167
+ .toString("utf8")
168
+ .trim()
169
+ .slice(-512) || `exit ${String(result.exitCode)}`}`,
170
+ );
171
+ }
172
+ } finally {
173
+ secret.fill(0);
174
+ }
175
+ }
176
+
177
+ /** Reassert the loader process after runtime recovery, before consumers.
178
+ * Uses the task only through `exec`, so a caller can fence every exec. */
179
+ export async function ensureContainerTaskEnvForwarder(
180
+ environment: Pick<TaskEnvironmentHandle<unknown>, "descriptor" | "exec">,
181
+ ): Promise<void> {
182
+ const { descriptor } = environment;
183
+ if (descriptor.envMode !== "loader") return;
184
+ const isTransport = isEnvironmentTransportProvider(
185
+ descriptor.locator.provider,
186
+ );
187
+ const directMachine = descriptor.locator.provider === "machine";
188
+ if (isTransport && !directMachine) {
189
+ // A fixed-task sidecar serves its own loopback and must not bind a second
190
+ // listener. Other non-machine transports share that conservative shape.
191
+ return;
192
+ }
193
+ if (directMachine) {
194
+ // A machine reboot preserves disk but not /run or the uid-65532 process.
195
+ // Refresh the current runner's root-owned assets before exporting the
196
+ // generation secret, then recreate the process below.
197
+ await installMachineProjectEnvLoader(environment);
198
+ await installMachineTaskForwarderAsset(environment);
199
+ }
200
+ const identity = taskGatewayRegistrationForRecovery(descriptor.taskId);
201
+ if (identity === null) {
202
+ throw new Error("task env gateway identity is not active during recovery");
203
+ }
204
+ const launch: TaskEnvForwarderLaunchOptions = directMachine
205
+ ? {
206
+ gatewayHost: "127.0.0.1",
207
+ gatewayPort: MACHINE_GATEWAY_TUNNEL_PORT,
208
+ nodePath: "/usr/bin/node",
209
+ }
210
+ : {
211
+ gatewayHost: await taskGatewayHost(),
212
+ gatewayPort: MCP_GATEWAY_PORT,
213
+ nodePath:
214
+ descriptor.locator.provider === "apple-container"
215
+ ? APPLE_FORWARDER_NODE_PATH
216
+ : "/usr/local/bin/node",
217
+ };
218
+ // The secret copy is taken last, with nothing left to await before the
219
+ // launch, which wipes it on every outcome: no failure strands it.
220
+ const secret = taskGatewayForwarderSecretInput(
221
+ identity.taskId,
222
+ identity.generation,
223
+ );
224
+ if (secret === null) {
225
+ throw new Error("task env gateway secret is unavailable during recovery");
226
+ }
227
+ await launchTaskEnvForwarder(environment, identity, secret, launch);
228
+ }