@runuai/host 0.9.148 → 0.9.150
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- 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 +408 -81
- 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,574 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Putting a task behind the proxy (ADR-153 §3 "Order") and keeping it there:
|
|
3
|
+
* what task-up.sh runs inside the container before uai-init, and the same
|
|
4
|
+
* steps for a running task. Every root mutation of a task (its CA, its pins)
|
|
5
|
+
* goes through one queue per task, so no two ever interleave and the
|
|
6
|
+
* recorded readiness is always that of the last one to land; shutdown stops
|
|
7
|
+
* the queues and waits for them.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { chmodSync, mkdirSync, rmSync, writeFileSync } from "node:fs";
|
|
11
|
+
import { join } from "node:path";
|
|
12
|
+
|
|
13
|
+
import { isSafeHostTaskId } from "../task-identity";
|
|
14
|
+
import { SELF_TASK_ENVIRONMENT_PROVIDER, SELF_TASK_USER, TASK_HOSTS_VIEW_PATH } from "../task-environment/self-identity";
|
|
15
|
+
import type { TaskEnvironmentHandle } from "../task-environment/types";
|
|
16
|
+
import { MCP_GATEWAY_PORT } from "../task-gateway-port";
|
|
17
|
+
import { taskViewProven } from "./sidecar-view";
|
|
18
|
+
import { installTaskTrust, reconcileTaskPins, type TaskNetAdmin } from "./container-steps";
|
|
19
|
+
import {
|
|
20
|
+
beginTaskStart,
|
|
21
|
+
endTaskStart,
|
|
22
|
+
forgetTaskProxyReadiness,
|
|
23
|
+
isTaskStarting,
|
|
24
|
+
recordTaskProxyReadiness,
|
|
25
|
+
taskProxyReadiness,
|
|
26
|
+
type ProxyStepOutcome,
|
|
27
|
+
} from "./readiness";
|
|
28
|
+
import type { SecretsProxyRuntime } from "./runtime";
|
|
29
|
+
import { caInstallScript, hostsReconcileScript, redirectChainScript } from "./task-scripts";
|
|
30
|
+
|
|
31
|
+
/** Where the task forwarder listens (both launchers pass `--listen-port
|
|
32
|
+
* 5877`): the NAT chain sends a pinned name's :443 here. */
|
|
33
|
+
export const TASK_FORWARDER_LISTEN_PORT = 5877;
|
|
34
|
+
|
|
35
|
+
/** How the proxy's steps run on one runtime. */
|
|
36
|
+
interface ProxyRuntime {
|
|
37
|
+
/** Where the redirect step's NET_ADMIN comes from. */
|
|
38
|
+
readonly netAdmin: TaskNetAdmin;
|
|
39
|
+
/** Where the chain sends a pinned name's :443. */
|
|
40
|
+
readonly listenPort: () => number;
|
|
41
|
+
/** Whose traffic it redirects, where not everyone's in the namespace. */
|
|
42
|
+
readonly uidOwner?: string;
|
|
43
|
+
/** Where the names are pinned, where /etc/hosts is not the task's alone,
|
|
44
|
+
* and whether every process of the task sees them. */
|
|
45
|
+
readonly hostsView?: {
|
|
46
|
+
readonly path: string;
|
|
47
|
+
readonly from: string;
|
|
48
|
+
readonly proven: (taskId: string) => boolean;
|
|
49
|
+
};
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* The runtimes this runner puts behind the proxy (the CA and the pins):
|
|
54
|
+
* - Docker: the redirect gets NET_ADMIN for that one exec (`--privileged`),
|
|
55
|
+
* to the task's forwarder.
|
|
56
|
+
* - Apple: the task VM holds NET_ADMIN from its creation (`--cap-add
|
|
57
|
+
* CAP_NET_ADMIN`), so a root exec has it (the provider refuses
|
|
58
|
+
* `privileged`); to the task's forwarder.
|
|
59
|
+
* - A Uai Cloud machine's own task (`self`): the sidecar is root on the
|
|
60
|
+
* machine and runs the steps itself. The chain sends only the task user's
|
|
61
|
+
* traffic, to the sidecar's own gateway, where a loopback TLS connection
|
|
62
|
+
* is its one task's. The names are pinned only in the task's own view of
|
|
63
|
+
* the hosts, which every task process gets bind-mounted over /etc/hosts:
|
|
64
|
+
* the machine's /etc/hosts, and so every root process the sidecar runs
|
|
65
|
+
* (its SSM refresh, its S3 mounts, its own updates), never sees them.
|
|
66
|
+
* A machine an owned runner drives over ssh is not among them: its proxied
|
|
67
|
+
* keys stay withheld.
|
|
68
|
+
*/
|
|
69
|
+
const RUNTIMES: ReadonlyMap<string, ProxyRuntime> = new Map<string, ProxyRuntime>([
|
|
70
|
+
["docker-compose", { netAdmin: "per-exec", listenPort: () => TASK_FORWARDER_LISTEN_PORT }],
|
|
71
|
+
["apple-container", { netAdmin: "from-creation", listenPort: () => TASK_FORWARDER_LISTEN_PORT }],
|
|
72
|
+
[
|
|
73
|
+
SELF_TASK_ENVIRONMENT_PROVIDER,
|
|
74
|
+
{
|
|
75
|
+
netAdmin: "root",
|
|
76
|
+
listenPort: () => MCP_GATEWAY_PORT,
|
|
77
|
+
uidOwner: SELF_TASK_USER,
|
|
78
|
+
hostsView: { path: TASK_HOSTS_VIEW_PATH, from: "/etc/hosts", proven: taskViewProven },
|
|
79
|
+
},
|
|
80
|
+
],
|
|
81
|
+
]);
|
|
82
|
+
|
|
83
|
+
/** Whether this runner puts tasks of `provider` behind the proxy. */
|
|
84
|
+
export function isProxiedRuntime(provider: string | null | undefined): boolean {
|
|
85
|
+
return provider !== null && provider !== undefined && RUNTIMES.has(provider);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** A task as the runner's root steps use it: its identity and `exec`. */
|
|
89
|
+
export type TaskRootExec = Pick<TaskEnvironmentHandle<unknown>, "exec" | "descriptor">;
|
|
90
|
+
type Exec = TaskRootExec;
|
|
91
|
+
|
|
92
|
+
function takesTrust(environment: Exec): boolean {
|
|
93
|
+
return isProxiedRuntime(environment.descriptor.locator.provider);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
// --- one mutation queue per task -------------------------------------------
|
|
97
|
+
|
|
98
|
+
const queues = new Map<string, Promise<unknown>>();
|
|
99
|
+
let quiescing = false;
|
|
100
|
+
|
|
101
|
+
/** Bumped at a stop, a deletion and a new start: every write queued or under
|
|
102
|
+
* way before is void from then on (it runs no further exec and records
|
|
103
|
+
* nothing), so none can land over, or recreate readiness for, what follows. */
|
|
104
|
+
const epochs = new Map<string, number>();
|
|
105
|
+
const epochOf = (taskId: string): number => epochs.get(taskId) ?? 0;
|
|
106
|
+
|
|
107
|
+
export function invalidateTaskWrites(taskId: string): void {
|
|
108
|
+
epochs.set(taskId, epochOf(taskId) + 1);
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Whether the runner is shutting down: nothing root may start in a task. */
|
|
112
|
+
export function taskWritesQuiescing(): boolean {
|
|
113
|
+
return quiescing;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** True while the operation may still act: the runner is not shutting down,
|
|
117
|
+
* no stop, deletion or start has voided it, and its caller's own check (a
|
|
118
|
+
* recovery's generation) still holds. */
|
|
119
|
+
type Live = () => boolean;
|
|
120
|
+
|
|
121
|
+
/** Why an operation that is no longer live stops. */
|
|
122
|
+
function refusal(): Error {
|
|
123
|
+
return new Error(quiescing ? "the runner is shutting down" : "this write was superseded");
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Runs `step` after every earlier write of the task has ended. Refused once
|
|
127
|
+
* shutdown has begun: a write that started after its runner's last moment
|
|
128
|
+
* could land over the replacement's. */
|
|
129
|
+
function mutate<T>(taskId: string, step: (live: Live) => Promise<T>, isCurrent?: () => boolean): Promise<T> {
|
|
130
|
+
if (quiescing) return Promise.reject(new Error("the runner is shutting down"));
|
|
131
|
+
const epoch = epochOf(taskId);
|
|
132
|
+
const live: Live = () => !quiescing && epochOf(taskId) === epoch && (isCurrent === undefined || isCurrent());
|
|
133
|
+
const previous = queues.get(taskId) ?? Promise.resolve();
|
|
134
|
+
const next = previous
|
|
135
|
+
.catch(() => undefined)
|
|
136
|
+
.then(() => {
|
|
137
|
+
if (!live()) throw refusal();
|
|
138
|
+
return step(live);
|
|
139
|
+
});
|
|
140
|
+
queues.set(taskId, next);
|
|
141
|
+
const settled = (): void => {
|
|
142
|
+
if (queues.get(taskId) === next) queues.delete(taskId);
|
|
143
|
+
};
|
|
144
|
+
next.then(settled, settled);
|
|
145
|
+
return next;
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
/** Every exec of a queued step goes through this: once the operation is not
|
|
149
|
+
* live (shutdown, a stop, a deletion, a new start, a stale recovery), no
|
|
150
|
+
* further exec starts, even inside an operation already under way, and
|
|
151
|
+
* not even one already asked for whose provider is still looking up where
|
|
152
|
+
* to run it (the request's `admit`). The
|
|
153
|
+
* steps are ordered so stopping between any two leaves nothing unsafe (the
|
|
154
|
+
* chain before the names when pinning, the names first when unpinning), and
|
|
155
|
+
* whatever writes next reconciles what is left. */
|
|
156
|
+
function guarded(environment: Exec, live: Live): Exec {
|
|
157
|
+
return {
|
|
158
|
+
descriptor: environment.descriptor,
|
|
159
|
+
exec: (request) => {
|
|
160
|
+
if (!live()) return Promise.reject(refusal());
|
|
161
|
+
// And again where the provider starts the process: a machine looks
|
|
162
|
+
// up its address first, which can take as long as a cloud API call.
|
|
163
|
+
const admit = (): void => {
|
|
164
|
+
request.admit?.();
|
|
165
|
+
if (!live()) throw refusal();
|
|
166
|
+
};
|
|
167
|
+
return environment.exec({ ...request, admit });
|
|
168
|
+
},
|
|
169
|
+
};
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Shutdown: refuses every new write and exec, and resolves once those under
|
|
173
|
+
* way (a recovery's CA install, a re-pin, a forwarder launch) have ended. */
|
|
174
|
+
export async function quiesceTaskProxyMutations(): Promise<void> {
|
|
175
|
+
quiescing = true;
|
|
176
|
+
await Promise.all([...queues.values()].map((queued) => queued.catch(() => undefined)));
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Any other root step a lifecycle takes in a task (the recovery's forwarder
|
|
181
|
+
* launch), run in the same queue and under the same fence: never once
|
|
182
|
+
* shutdown has begun, a stop, deletion or start has voided it, or
|
|
183
|
+
* `isCurrent` says the operation is stale; joined by shutdown. The step
|
|
184
|
+
* reaches the task only through the `environment` it is given, whose every
|
|
185
|
+
* exec is fenced when it is made, not only when the step begins: the
|
|
186
|
+
* launch awaits the gateway's address, and on a machine installs its
|
|
187
|
+
* assets, before the exec that starts the forwarder.
|
|
188
|
+
*/
|
|
189
|
+
export function runTaskRootStep<T>(
|
|
190
|
+
environment: TaskRootExec,
|
|
191
|
+
step: (environment: TaskRootExec, live: () => boolean) => Promise<T>,
|
|
192
|
+
isCurrent?: () => boolean,
|
|
193
|
+
): Promise<T> {
|
|
194
|
+
return mutate(environment.descriptor.taskId, (live) => step(guarded(environment, live), live), isCurrent);
|
|
195
|
+
}
|
|
196
|
+
|
|
197
|
+
/**
|
|
198
|
+
* A start's exclusive hold on the task's writes, from before its root steps
|
|
199
|
+
* until its result is recorded. Earlier writers are voided (they finish at
|
|
200
|
+
* most the exec under way) and waited for; none runs until the returned
|
|
201
|
+
* release. Rejects once shutdown has begun.
|
|
202
|
+
*/
|
|
203
|
+
function holdTaskWritesForStart(taskId: string): Promise<() => void> {
|
|
204
|
+
invalidateTaskWrites(taskId);
|
|
205
|
+
return new Promise((resolve, reject) => {
|
|
206
|
+
mutate(
|
|
207
|
+
taskId,
|
|
208
|
+
() =>
|
|
209
|
+
new Promise<void>((release) => {
|
|
210
|
+
resolve(release);
|
|
211
|
+
}),
|
|
212
|
+
).catch(reject);
|
|
213
|
+
});
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** The holds of the starts under way, each ended once (finish or close). */
|
|
217
|
+
const startHolds = new Map<string, () => void>();
|
|
218
|
+
|
|
219
|
+
function endStartHold(taskId: string): void {
|
|
220
|
+
const release = startHolds.get(taskId);
|
|
221
|
+
startHolds.delete(taskId);
|
|
222
|
+
release?.();
|
|
223
|
+
}
|
|
224
|
+
|
|
225
|
+
// --- the steps, run only inside a task's queue --------------------------------
|
|
226
|
+
|
|
227
|
+
async function installStep(environment: Exec, live: Live, bundle: string): Promise<void> {
|
|
228
|
+
const { taskId } = environment.descriptor;
|
|
229
|
+
try {
|
|
230
|
+
await installTaskTrust(environment, bundle);
|
|
231
|
+
} catch (error) {
|
|
232
|
+
if (live()) recordTaskProxyReadiness(taskId, { trust: "failed", bundle: null });
|
|
233
|
+
throw error;
|
|
234
|
+
}
|
|
235
|
+
if (live()) recordTaskProxyReadiness(taskId, { trust: "installed", bundle });
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/** Unpinned tasks, while the proxy is off: their pins are known to be gone. */
|
|
239
|
+
const unpinned = new Set<string>();
|
|
240
|
+
|
|
241
|
+
/** With the proxy off: the pins out, once (recorded until the task pins
|
|
242
|
+
* again); a failure is not recorded, so the next sweep tries again. */
|
|
243
|
+
async function unpinStep(environment: Exec, live: Live): Promise<void> {
|
|
244
|
+
const { taskId } = environment.descriptor;
|
|
245
|
+
if (unpinned.has(taskId)) return;
|
|
246
|
+
await pinStep(environment, live, []);
|
|
247
|
+
if (live()) {
|
|
248
|
+
forgetTaskProxyReadiness(taskId);
|
|
249
|
+
unpinned.add(taskId);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
async function pinStep(environment: Exec, live: Live, domains: readonly string[]): Promise<void> {
|
|
254
|
+
const { taskId } = environment.descriptor;
|
|
255
|
+
try {
|
|
256
|
+
const proxied = RUNTIMES.get(environment.descriptor.locator.provider);
|
|
257
|
+
if (proxied === undefined) throw new Error("the proxy does not run on this task's runtime");
|
|
258
|
+
await reconcileTaskPins(environment, {
|
|
259
|
+
domains,
|
|
260
|
+
listenPort: proxied.listenPort(),
|
|
261
|
+
netAdmin: proxied.netAdmin,
|
|
262
|
+
...(proxied.uidOwner === undefined ? {} : { uidOwner: proxied.uidOwner }),
|
|
263
|
+
...(proxied.hostsView === undefined
|
|
264
|
+
? {}
|
|
265
|
+
: {
|
|
266
|
+
hostsView: {
|
|
267
|
+
path: proxied.hostsView.path,
|
|
268
|
+
from: proxied.hostsView.from,
|
|
269
|
+
proven: () => proxied.hostsView?.proven(taskId) === true,
|
|
270
|
+
},
|
|
271
|
+
}),
|
|
272
|
+
});
|
|
273
|
+
} catch (error) {
|
|
274
|
+
// Whatever was pinned may now be half-rewritten: nothing counts as pinned.
|
|
275
|
+
if (live()) recordTaskProxyReadiness(taskId, { pins: "failed", pinnedDomains: [] });
|
|
276
|
+
unpinned.delete(taskId);
|
|
277
|
+
throw error;
|
|
278
|
+
}
|
|
279
|
+
if (live()) {
|
|
280
|
+
recordTaskProxyReadiness(taskId, { pins: domains.length > 0 ? "installed" : "skipped", pinnedDomains: [...domains] });
|
|
281
|
+
}
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
function sameDomains(left: readonly string[], right: readonly string[]): boolean {
|
|
285
|
+
return left.length === right.length && left.every((domain, index) => domain === right[index]);
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
// --- task-up ---------------------------------------------------------------
|
|
289
|
+
|
|
290
|
+
function stepsDir(dataDir: string, taskId: string): string {
|
|
291
|
+
if (!isSafeHostTaskId(taskId)) throw new Error("invalid host task identity");
|
|
292
|
+
return join(dataDir, "secrets-proxy", "task-up", taskId);
|
|
293
|
+
}
|
|
294
|
+
|
|
295
|
+
/**
|
|
296
|
+
* Begins a task-up behind the proxy. First the start takes the task's writes
|
|
297
|
+
* (holdTaskWritesForStart), so no older writer (a recovery, a re-pin) lands
|
|
298
|
+
* over its steps and task-up is the task's only writer until its result is
|
|
299
|
+
* recorded. Then it writes what task-up.sh runs as root inside the
|
|
300
|
+
* container: the CA bundle, its install script, and the chain and pin
|
|
301
|
+
* scripts for the task's current domains, with the order to run them in.
|
|
302
|
+
* Public material only (certificates and fixed scripts). task-up writes each
|
|
303
|
+
* step's outcome back into the same directory as it goes.
|
|
304
|
+
*
|
|
305
|
+
* Returns the directory, passed to task-up.sh as UAI_SECRETS_PROXY_DIR, or
|
|
306
|
+
* null once the runner is shutting down: the task then starts without the
|
|
307
|
+
* proxy's steps, its proxied keys withheld, and the next runner reconciles
|
|
308
|
+
* it. finishTaskUpTrustSteps ends the hold; closeTaskUpTrustSteps ends it
|
|
309
|
+
* for a start that never got that far.
|
|
310
|
+
*/
|
|
311
|
+
export async function writeTaskUpTrustSteps(
|
|
312
|
+
dataDir: string,
|
|
313
|
+
taskId: string,
|
|
314
|
+
runtime: SecretsProxyRuntime,
|
|
315
|
+
): Promise<string | null> {
|
|
316
|
+
const dir = stepsDir(dataDir, taskId);
|
|
317
|
+
let release: () => void;
|
|
318
|
+
try {
|
|
319
|
+
release = await holdTaskWritesForStart(taskId);
|
|
320
|
+
} catch {
|
|
321
|
+
forgetTaskProxyReadiness(taskId);
|
|
322
|
+
return null;
|
|
323
|
+
}
|
|
324
|
+
startHolds.set(taskId, release);
|
|
325
|
+
try {
|
|
326
|
+
rmSync(dir, { recursive: true, force: true });
|
|
327
|
+
mkdirSync(dir, { recursive: true, mode: 0o700 });
|
|
328
|
+
chmodSync(dir, 0o700);
|
|
329
|
+
const bundle = runtime.caBundlePem();
|
|
330
|
+
const domains = runtime.domainsFor(taskId);
|
|
331
|
+
const files: Record<string, string> = {
|
|
332
|
+
"ca-bundle.pem": bundle,
|
|
333
|
+
"ca-install.sh": caInstallScript(),
|
|
334
|
+
"pins.sh": hostsReconcileScript(domains),
|
|
335
|
+
"chain.sh": redirectChainScript({ toPort: TASK_FORWARDER_LISTEN_PORT, enabled: domains.length > 0 }),
|
|
336
|
+
// As for a running task: the chain before the names when pinning, the
|
|
337
|
+
// names first when there is nothing to pin.
|
|
338
|
+
"routing-order": domains.length > 0 ? "pin\n" : "unpin\n",
|
|
339
|
+
};
|
|
340
|
+
for (const [name, body] of Object.entries(files)) writeFileSync(join(dir, name), body, { mode: 0o600 });
|
|
341
|
+
unpinned.delete(taskId);
|
|
342
|
+
beginTaskStart(taskId, { dir, bundle, domains });
|
|
343
|
+
} catch (error) {
|
|
344
|
+
forgetTaskProxyReadiness(taskId);
|
|
345
|
+
endStartHold(taskId);
|
|
346
|
+
throw error;
|
|
347
|
+
}
|
|
348
|
+
return dir;
|
|
349
|
+
}
|
|
350
|
+
|
|
351
|
+
/** Ends a task-up: what it reported becomes the task's readiness (nothing,
|
|
352
|
+
* when it failed), its directory goes, and its hold on the task's writes
|
|
353
|
+
* ends, so a writer queued meanwhile reconciles from what it recorded. */
|
|
354
|
+
export function finishTaskUpTrustSteps(
|
|
355
|
+
dataDir: string,
|
|
356
|
+
taskId: string,
|
|
357
|
+
result: { secretsProxyTrust?: ProxyStepOutcome; secretsProxyPins?: ProxyStepOutcome } | null,
|
|
358
|
+
): void {
|
|
359
|
+
try {
|
|
360
|
+
const pending = endTaskStart(taskId);
|
|
361
|
+
if (isSafeHostTaskId(taskId)) rmSync(stepsDir(dataDir, taskId), { recursive: true, force: true });
|
|
362
|
+
if (pending === undefined || result === null) {
|
|
363
|
+
forgetTaskProxyReadiness(taskId);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const trust = result.secretsProxyTrust ?? "skipped";
|
|
367
|
+
const pins = result.secretsProxyPins ?? "skipped";
|
|
368
|
+
recordTaskProxyReadiness(taskId, {
|
|
369
|
+
trust,
|
|
370
|
+
bundle: trust === "installed" ? pending.bundle : null,
|
|
371
|
+
pins,
|
|
372
|
+
pinnedDomains: pins === "installed" ? pending.domains : [],
|
|
373
|
+
// task-up fails outright unless its forwarder started.
|
|
374
|
+
forwarder: true,
|
|
375
|
+
});
|
|
376
|
+
} finally {
|
|
377
|
+
endStartHold(taskId);
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** The end of every start, whatever happened: one that never reported
|
|
382
|
+
* counts as failed (and its hold ends). Nothing once it was finished. */
|
|
383
|
+
export function closeTaskUpTrustSteps(dataDir: string, taskId: string): void {
|
|
384
|
+
if (isTaskStarting(taskId)) finishTaskUpTrustSteps(dataDir, taskId, null);
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// --- reconciling a running task -----------------------------------------------
|
|
388
|
+
|
|
389
|
+
/**
|
|
390
|
+
* Brings one running Docker or Apple task to where it should be, doing only
|
|
391
|
+
* what differs from its recorded readiness, in its queue:
|
|
392
|
+
* - proxy on: the current CA bundle, then pins for its current domains;
|
|
393
|
+
* - proxy off (`runtime` null): no pins, so formerly proxied names go
|
|
394
|
+
* direct again.
|
|
395
|
+
* Any other runtime is left untouched. Level-triggered, so every path that
|
|
396
|
+
* may have missed something (the end of a start, a change of policy, the
|
|
397
|
+
* trust watcher's retries, the proxy-off sweep) simply calls it again.
|
|
398
|
+
* Throws if a step failed; what did land is recorded.
|
|
399
|
+
*/
|
|
400
|
+
export function reconcileTaskProxyState(
|
|
401
|
+
target: Exec,
|
|
402
|
+
runtime: SecretsProxyRuntime | null,
|
|
403
|
+
isCurrent?: () => boolean,
|
|
404
|
+
): Promise<void> {
|
|
405
|
+
if (!takesTrust(target)) return Promise.resolve();
|
|
406
|
+
const { taskId } = target.descriptor;
|
|
407
|
+
return mutate(
|
|
408
|
+
taskId,
|
|
409
|
+
async (live) => {
|
|
410
|
+
const environment = guarded(target, live);
|
|
411
|
+
if (runtime === null) {
|
|
412
|
+
await unpinStep(environment, live);
|
|
413
|
+
if (!live()) throw refusal();
|
|
414
|
+
return;
|
|
415
|
+
}
|
|
416
|
+
unpinned.delete(taskId);
|
|
417
|
+
const bundle = runtime.caBundlePem();
|
|
418
|
+
const ready = taskProxyReadiness(taskId);
|
|
419
|
+
if (ready.trust !== "installed" || ready.bundle !== bundle) await installStep(environment, live, bundle);
|
|
420
|
+
// Voided while its CA exec ran (a stop, a recovery beginning): it ends
|
|
421
|
+
// there, and what it read before counts for nothing.
|
|
422
|
+
if (!live()) throw refusal();
|
|
423
|
+
const domains = runtime.domainsFor(taskId);
|
|
424
|
+
const after = taskProxyReadiness(taskId);
|
|
425
|
+
const pinned = domains.length > 0 ? after.pins === "installed" : after.pins === "skipped";
|
|
426
|
+
if (!pinned || !sameDomains(after.pinnedDomains, domains)) {
|
|
427
|
+
// Never a name pinned with nothing listening behind it: until a
|
|
428
|
+
// start or a recovery has the task's forwarder running, the pins
|
|
429
|
+
// wait for it (a recovery pins in the same slot as its launch).
|
|
430
|
+
// Taking pins out needs no forwarder.
|
|
431
|
+
if (domains.length > 0 && !after.forwarder) {
|
|
432
|
+
throw new Error("the task's forwarder is not known to run; its next recovery pins it");
|
|
433
|
+
}
|
|
434
|
+
await pinStep(environment, live, domains);
|
|
435
|
+
// Its last exec may have been the one under way when it was voided:
|
|
436
|
+
// landed, but recorded nowhere, so it does not count as done.
|
|
437
|
+
if (!live()) throw refusal();
|
|
438
|
+
}
|
|
439
|
+
},
|
|
440
|
+
isCurrent,
|
|
441
|
+
);
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
/** A proxy step that failed during a recovery: the task comes up without
|
|
445
|
+
* the proxy, its proxied keys withheld, and the trust watcher retries. */
|
|
446
|
+
export interface RecoveryProxyFailure {
|
|
447
|
+
readonly error: unknown;
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
/**
|
|
451
|
+
* A task's root steps in the order a start takes them (ADR-153 "Order"), in
|
|
452
|
+
* one slot of its queue so no other writer lands between them: for a
|
|
453
|
+
* recovery, and for a machine sidecar's start and resume, which run the
|
|
454
|
+
* steps themselves rather than through task-up.sh. Each step runs again
|
|
455
|
+
* whatever is recorded: a restarted runner records nothing, and a restarted
|
|
456
|
+
* VM or machine keeps none of it.
|
|
457
|
+
* - Proxy on: the CA, then `launch` (the task's forwarder; nothing on a
|
|
458
|
+
* machine, whose sidecar serves the task from its own gateway), then the
|
|
459
|
+
* pins for its current domains.
|
|
460
|
+
* - Proxy off: the pins out, then `launch`.
|
|
461
|
+
* No name is pinned before its forwarder runs. A launch that fails pins
|
|
462
|
+
* nothing, takes out the pins the task still holds, and rejects, and the
|
|
463
|
+
* caller keeps consumers out. A failed proxy
|
|
464
|
+
* step does not stop the launch; it is returned, and after a failed CA
|
|
465
|
+
* nothing is pinned either. `proxy` null (a baked task), or a runtime the
|
|
466
|
+
* runner does not put behind the proxy, runs `launch` alone. A stop,
|
|
467
|
+
* deletion, start, stale recovery or shutdown rejects the slot at whichever
|
|
468
|
+
* step it meets.
|
|
469
|
+
*/
|
|
470
|
+
export function applyTaskRootSteps(
|
|
471
|
+
target: Exec,
|
|
472
|
+
proxy: { readonly runtime: SecretsProxyRuntime | null } | null,
|
|
473
|
+
launch: (environment: TaskRootExec) => Promise<void>,
|
|
474
|
+
isCurrent?: () => boolean,
|
|
475
|
+
): Promise<RecoveryProxyFailure | null> {
|
|
476
|
+
const { taskId } = target.descriptor;
|
|
477
|
+
const runtime = proxy?.runtime ?? null;
|
|
478
|
+
const proxied = proxy !== null && takesTrust(target);
|
|
479
|
+
return runTaskRootStep(
|
|
480
|
+
target,
|
|
481
|
+
async (environment, live) => {
|
|
482
|
+
let failure: RecoveryProxyFailure | null = null;
|
|
483
|
+
// A step's failure is the proxy's only while the slot is live; one
|
|
484
|
+
// that met a stop, a stale recovery or shutdown ends the slot.
|
|
485
|
+
const attempt = async (step: () => Promise<void>): Promise<void> => {
|
|
486
|
+
try {
|
|
487
|
+
await step();
|
|
488
|
+
} catch (error) {
|
|
489
|
+
if (!live()) throw error;
|
|
490
|
+
failure = { error };
|
|
491
|
+
return;
|
|
492
|
+
}
|
|
493
|
+
// Its exec may have been the one under way when the slot stopped.
|
|
494
|
+
if (!live()) throw refusal();
|
|
495
|
+
};
|
|
496
|
+
if (proxied) {
|
|
497
|
+
// Replayed because a restart may have taken all of it: nothing
|
|
498
|
+
// recorded before counts until a step here lands it again, so a step
|
|
499
|
+
// that fails leaves the task undeliverable, never ready on the
|
|
500
|
+
// strength of a VM that is gone.
|
|
501
|
+
forgetTaskProxyReadiness(taskId);
|
|
502
|
+
if (runtime === null) {
|
|
503
|
+
await attempt(() => unpinStep(environment, live));
|
|
504
|
+
} else {
|
|
505
|
+
unpinned.delete(taskId);
|
|
506
|
+
await attempt(() => installStep(environment, live, runtime.caBundlePem()));
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
if (!live()) throw refusal();
|
|
510
|
+
try {
|
|
511
|
+
await launch(environment);
|
|
512
|
+
} catch (error) {
|
|
513
|
+
// No forwarder, so no name may point at one. Pins already in the task
|
|
514
|
+
// (the earlier VM's, or the last exec of a reconcile voided while it
|
|
515
|
+
// ran, which no fence can call back) come out, as far as the task
|
|
516
|
+
// lets them.
|
|
517
|
+
if (proxied && runtime !== null && live()) await pinStep(environment, live, []).catch(() => undefined);
|
|
518
|
+
throw error;
|
|
519
|
+
}
|
|
520
|
+
if (!live()) throw refusal();
|
|
521
|
+
if (proxied) recordTaskProxyReadiness(taskId, { forwarder: true });
|
|
522
|
+
if (proxied && runtime !== null && failure === null) {
|
|
523
|
+
await attempt(() => pinStep(environment, live, runtime.domainsFor(taskId)));
|
|
524
|
+
}
|
|
525
|
+
return failure;
|
|
526
|
+
},
|
|
527
|
+
isCurrent,
|
|
528
|
+
);
|
|
529
|
+
}
|
|
530
|
+
|
|
531
|
+
/**
|
|
532
|
+
* A machine sidecar's own task, at its start and whenever the sidecar comes
|
|
533
|
+
* back: the proxy's steps in a start's order, the CA and then the pins (the
|
|
534
|
+
* sidecar's gateway, in this process, is the task's listener, so there is
|
|
535
|
+
* nothing to launch); with the proxy off, the pins out. A start first voids
|
|
536
|
+
* any write queued before it. Never fails the task: a failed step leaves it
|
|
537
|
+
* up without the proxy, its keys withheld, and asks the trust watcher to
|
|
538
|
+
* retry. Returns what failed, for the caller's log, or null.
|
|
539
|
+
*/
|
|
540
|
+
export async function reconcileSidecarTaskProxy(
|
|
541
|
+
environment: TaskRootExec,
|
|
542
|
+
runtime: SecretsProxyRuntime | null,
|
|
543
|
+
options: { start: boolean },
|
|
544
|
+
): Promise<string | null> {
|
|
545
|
+
if (!takesTrust(environment)) return null;
|
|
546
|
+
// A start voids every write queued or under way before it. Whether the
|
|
547
|
+
// task's hosts view counts is not its to say: task-up admits a task with
|
|
548
|
+
// no row before recording it, and every other task stands as recorded.
|
|
549
|
+
if (options.start) invalidateTaskWrites(environment.descriptor.taskId);
|
|
550
|
+
let failure: unknown = null;
|
|
551
|
+
try {
|
|
552
|
+
failure = (await applyTaskRootSteps(environment, { runtime }, async () => undefined))?.error ?? null;
|
|
553
|
+
} catch (error) {
|
|
554
|
+
failure = error;
|
|
555
|
+
}
|
|
556
|
+
if (failure === null) return null;
|
|
557
|
+
runtime?.requestTrustDelivery();
|
|
558
|
+
return `secrets proxy: ${failure instanceof Error ? failure.message : String(failure)}`;
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
/**
|
|
562
|
+
* The live path for a change of the task's proxied domains. During a
|
|
563
|
+
* task-up, task-up is the only routing writer: its end reconciles the then
|
|
564
|
+
* current domains instead (reconcileTaskProxyState after the start).
|
|
565
|
+
*/
|
|
566
|
+
export function reconcileTaskRouting(environment: Exec, runtime: SecretsProxyRuntime): Promise<void> {
|
|
567
|
+
if (isTaskStarting(environment.descriptor.taskId)) return Promise.resolve();
|
|
568
|
+
return reconcileTaskProxyState(environment, runtime);
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
/** With the proxy off: unpins the task (once; recorded until it pins again). */
|
|
572
|
+
export function unpinTask(environment: Exec, isCurrent?: () => boolean): Promise<void> {
|
|
573
|
+
return reconcileTaskProxyState(environment, null, isCurrent);
|
|
574
|
+
}
|
|
@@ -0,0 +1,80 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The runner's trust watcher (ADR-153 §3 "The CA"): when the CA bundle
|
|
3
|
+
* changes (a successor is minted, or the signer retires), put it into every
|
|
4
|
+
* running loader task the proxy covers (Docker, Apple, a machine's own), so
|
|
5
|
+
* the processes they start from then on trust both. Runs as the
|
|
6
|
+
* SecretsProxyRuntime.watchTrust listener, which retries a delivery that
|
|
7
|
+
* throws.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { activeLoaderHostTasks } from "../runtime-state";
|
|
11
|
+
import { reconstructPersistedTaskEnvironment } from "../task-environment/index";
|
|
12
|
+
import type { TaskEnvironmentHandle } from "../task-environment/types";
|
|
13
|
+
import type { SecretsProxyRuntime } from "./runtime";
|
|
14
|
+
import { isProxiedRuntime, reconcileTaskProxyState } from "./task-trust";
|
|
15
|
+
|
|
16
|
+
export type HostTask = ReturnType<typeof activeLoaderHostTasks>[number];
|
|
17
|
+
|
|
18
|
+
export interface TrustWatchDeps {
|
|
19
|
+
tasks(): readonly HostTask[];
|
|
20
|
+
environmentOf(task: HostTask): Promise<Pick<TaskEnvironmentHandle<unknown>, "exec" | "descriptor"> | null>;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
const defaultDeps: TrustWatchDeps = {
|
|
24
|
+
tasks: activeLoaderHostTasks,
|
|
25
|
+
environmentOf: reconstructPersistedTaskEnvironment,
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Brings every running loader task the proxy covers (Docker, Apple, a
|
|
30
|
+
* machine's own) to its desired proxy state (reconcileTaskProxyState: the
|
|
31
|
+
* current CA bundle, the pins for its current domains; or, with `runtime`
|
|
32
|
+
* null, no pins), one at a time, checking the signal before each. Each task
|
|
33
|
+
* does only what it lacks. Throws if any task did not get there, or could
|
|
34
|
+
* not yet (still starting), so the caller retries.
|
|
35
|
+
*/
|
|
36
|
+
async function reconcileRunningTasks(
|
|
37
|
+
runtime: SecretsProxyRuntime | null,
|
|
38
|
+
signal: AbortSignal | null,
|
|
39
|
+
deps: TrustWatchDeps,
|
|
40
|
+
): Promise<void> {
|
|
41
|
+
const failed: string[] = [];
|
|
42
|
+
let starting = 0;
|
|
43
|
+
for (const task of deps.tasks()) {
|
|
44
|
+
if (signal?.aborted === true) return;
|
|
45
|
+
if (!isProxiedRuntime(task.environmentProvider)) continue;
|
|
46
|
+
if (task.statusMirror !== "running") {
|
|
47
|
+
starting += 1;
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
try {
|
|
51
|
+
const environment = await deps.environmentOf(task);
|
|
52
|
+
if (environment === null) throw new Error("its environment could not be reconstructed");
|
|
53
|
+
await reconcileTaskProxyState(environment, runtime);
|
|
54
|
+
} catch (error) {
|
|
55
|
+
failed.push(`${task.taskId} (${error instanceof Error ? error.message : String(error)})`);
|
|
56
|
+
}
|
|
57
|
+
}
|
|
58
|
+
if (failed.length > 0 || starting > 0) {
|
|
59
|
+
throw new Error(
|
|
60
|
+
`the secrets proxy is not in place in every task yet: ${failed.length} failed${failed.length > 0 ? ` (${failed.join("; ")})` : ""}, ` +
|
|
61
|
+
`${starting} still starting`,
|
|
62
|
+
);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
/** The SecretsProxyRuntime.watchTrust listener: run when the CA bundle
|
|
67
|
+
* changes, at subscription, and as its retries (hourly while it throws). */
|
|
68
|
+
export function reinstallTrustInRunningTasks(
|
|
69
|
+
runtime: SecretsProxyRuntime,
|
|
70
|
+
signal: AbortSignal,
|
|
71
|
+
deps: TrustWatchDeps = defaultDeps,
|
|
72
|
+
): Promise<void> {
|
|
73
|
+
return reconcileRunningTasks(runtime, signal, deps);
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
/** With the proxy off: unpins every running Docker task not yet known to
|
|
77
|
+
* be unpinned. Run after recovery and hourly while anything is left. */
|
|
78
|
+
export function unpinRunningTasks(deps: TrustWatchDeps = defaultDeps): Promise<void> {
|
|
79
|
+
return reconcileRunningTasks(null, null, deps);
|
|
80
|
+
}
|
package/lib/shared-files-s3.ts
CHANGED
|
@@ -40,6 +40,7 @@ export async function resolveTaskUserIds(
|
|
|
40
40
|
const fallback = { uid: SHARED_FILES_FALLBACK_UID, gid: SHARED_FILES_FALLBACK_GID };
|
|
41
41
|
try {
|
|
42
42
|
const result = await handle.exec({
|
|
43
|
+
projectEnvProfile: "maintenance",
|
|
43
44
|
argv: ["/bin/sh", "-c", 'id -u "$1" && id -g "$1"', "ids", user],
|
|
44
45
|
user: "root",
|
|
45
46
|
timeoutMs: 10_000,
|
|
@@ -172,6 +173,7 @@ async function mountSharedFilesFromLeaseNow(
|
|
|
172
173
|
const owner = await resolveTaskUserIds(handle);
|
|
173
174
|
for (const plan of sharedFilesMountPlans(workspacePath, mode, lease)) {
|
|
174
175
|
const result = await handle.exec({
|
|
176
|
+
projectEnvProfile: "maintenance",
|
|
175
177
|
argv: [
|
|
176
178
|
"/bin/sh",
|
|
177
179
|
"-c",
|
|
@@ -224,6 +226,7 @@ export async function unmountSharedFiles(
|
|
|
224
226
|
await handle
|
|
225
227
|
.exec({
|
|
226
228
|
argv: ["/bin/sh", "-c", 'mountpoint -q "$1" && umount "$1" || true', "umount", directory],
|
|
229
|
+
projectEnvProfile: "maintenance",
|
|
227
230
|
user: "root",
|
|
228
231
|
timeoutMs: 30_000,
|
|
229
232
|
maxOutputBytes: 4 * 1024,
|