@runuai/host 0.9.148 → 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/codex.ts +9 -1
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +2 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/transport.ts +9 -5
- 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 +391 -69
- 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/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 +939 -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 +2219 -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
|
+
}
|
package/lib/sidecar-resume.ts
CHANGED
|
@@ -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
|
|
41
|
-
*
|
|
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
|
-
|
|
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);
|
package/lib/standard-image.ts
CHANGED
|
@@ -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: {
|
|
514
|
-
|
|
515
|
-
|
|
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
|
|
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
|
-
|
|
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(
|
|
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
|
|
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({
|
|
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
|
|
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
|
+
}
|