@runuai/host 0.9.150 → 0.9.152

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 (107) hide show
  1. package/db/migrations/0027_cloud_credential_foundation.sql +51 -0
  2. package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
  3. package/db/migrations/meta/_journal.json +14 -0
  4. package/db/schema.ts +188 -0
  5. package/lib/agent-cli.ts +11 -13
  6. package/lib/agent.ts +177 -7
  7. package/lib/agents/durable-proc.ts +56 -44
  8. package/lib/agents/registry.ts +19 -3
  9. package/lib/agents/session-state.ts +23 -10
  10. package/lib/agents/transport.ts +101 -69
  11. package/lib/apple-one-off.ts +277 -0
  12. package/lib/apple-runtime-recycle.ts +4 -3
  13. package/lib/attachments.ts +19 -17
  14. package/lib/capture-flow.ts +319 -0
  15. package/lib/cloud-credential-cleanup-adapter.ts +123 -0
  16. package/lib/cloud-credential-host.ts +512 -0
  17. package/lib/cloud-credential-leases.ts +2544 -0
  18. package/lib/cloud-credential-runtime.ts +1035 -0
  19. package/lib/codex-auth.ts +21 -1
  20. package/lib/container-runtime.ts +76 -0
  21. package/lib/credential-cleanup-inventory.ts +405 -0
  22. package/lib/credential-consumers/engine-anchor.ts +1880 -0
  23. package/lib/credential-consumers/engines.ts +773 -0
  24. package/lib/credential-consumers/github.ts +594 -0
  25. package/lib/credential-consumers/mcp.ts +384 -0
  26. package/lib/credential-consumers/task-files.ts +127 -0
  27. package/lib/credential-store-pickup.ts +15 -2
  28. package/lib/credential-store.ts +361 -33
  29. package/lib/credentials-activation-authority.ts +546 -0
  30. package/lib/credentials-purge.ts +1006 -0
  31. package/lib/db.ts +11 -0
  32. package/lib/docker-exec.ts +3 -1
  33. package/lib/engine-accounts.ts +42 -32
  34. package/lib/engine-connect.ts +47 -2
  35. package/lib/engine-credential-env.ts +50 -0
  36. package/lib/engine-login.ts +83 -1
  37. package/lib/env-file.ts +62 -8
  38. package/lib/env.ts +5 -0
  39. package/lib/fs-aliases.ts +49 -0
  40. package/lib/git-clone-credentials.ts +334 -0
  41. package/lib/git-diff.ts +219 -177
  42. package/lib/git-identity.ts +59 -14
  43. package/lib/github-origin.ts +28 -0
  44. package/lib/github-tokens.ts +56 -21
  45. package/lib/host-env.ts +17 -4
  46. package/lib/launch-mode.ts +25 -0
  47. package/lib/legacy-runtime-retirement.ts +388 -0
  48. package/lib/machine-exec.ts +14 -3
  49. package/lib/machine-provider-aws.ts +50 -8
  50. package/lib/machine-provider-local.ts +4 -0
  51. package/lib/machine-provider.ts +4 -0
  52. package/lib/managed-operation-drain.ts +8 -0
  53. package/lib/managed-runtime.ts +371 -7
  54. package/lib/mcp-capture.ts +860 -0
  55. package/lib/mcp-connections.ts +8 -1
  56. package/lib/mcp-gateway.ts +168 -10
  57. package/lib/obs.ts +2 -6
  58. package/lib/orchestrator.ts +1837 -224
  59. package/lib/person-capture.ts +327 -0
  60. package/lib/project-env-legacy-tags.ts +130 -0
  61. package/lib/project-env-task-replica.ts +1752 -0
  62. package/lib/runner-boot.ts +10 -0
  63. package/lib/runtime-activation-tail.ts +50 -3
  64. package/lib/runtime-archive.ts +68 -1
  65. package/lib/runtime-reconnect.ts +86 -0
  66. package/lib/runtime-state.ts +429 -13
  67. package/lib/safe-http.ts +364 -0
  68. package/lib/secrets-proxy/task-trust.ts +34 -3
  69. package/lib/sidecar-resume.ts +18 -5
  70. package/lib/skills.ts +47 -8
  71. package/lib/standard-image.ts +157 -103
  72. package/lib/stopped-task-status-queue.ts +68 -10
  73. package/lib/task-credential-cleanup.ts +525 -0
  74. package/lib/task-diff-sandbox.ts +464 -0
  75. package/lib/task-diff.ts +66 -163
  76. package/lib/task-env-source.ts +68 -14
  77. package/lib/task-environment/anchor.ts +31 -0
  78. package/lib/task-environment/apple-container.ts +69 -28
  79. package/lib/task-environment/docker.ts +101 -20
  80. package/lib/task-environment/machine-environment.ts +61 -0
  81. package/lib/task-environment/machine-task-up.ts +459 -96
  82. package/lib/task-environment/machine.ts +101 -11
  83. package/lib/task-environment/self.ts +108 -50
  84. package/lib/task-environment/types.ts +89 -5
  85. package/lib/task-environment/workspace-files.ts +650 -25
  86. package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
  87. package/lib/task-gateway-recovery.ts +4 -1
  88. package/lib/task-gateway-state.ts +15 -0
  89. package/lib/task-machine-swap-safety.ts +729 -0
  90. package/lib/token-broker-adopt.ts +344 -0
  91. package/lib/token-broker-lambda.ts +56 -2
  92. package/lib/token-broker-local.ts +28 -4
  93. package/lib/token-broker-revision.ts +310 -0
  94. package/lib/token-broker-ssm.ts +368 -28
  95. package/lib/token-lanes/openai-oauth.ts +7 -3
  96. package/lib/token-lanes/xai-oauth.ts +7 -3
  97. package/lib/transcript.ts +9 -5
  98. package/package.json +6 -2
  99. package/scripts/agent/task-up.sh +242 -100
  100. package/src/cloud-credentials.ts +2399 -0
  101. package/src/event-outbox.ts +8 -6
  102. package/src/index.ts +1386 -875
  103. package/src/main.ts +1061 -139
  104. package/src/outbound-frame.ts +31 -0
  105. package/src/project-env-policy.ts +125 -10
  106. package/src/protocol.ts +699 -23
  107. package/src/ui/server.ts +48 -10
@@ -0,0 +1,364 @@
1
+ /**
2
+ * ADR-154 §11: the one SSRF rule for every request to an MCP server, or to an
3
+ * endpoint discovered from it, whoever sends it — the sign-in box (probe,
4
+ * discovery, registration, code exchange), the broker (refresh) and the
5
+ * runner's gateway (every call a task makes).
6
+ *
7
+ * - The URL is `https`, with no userinfo.
8
+ * - The name is resolved immediately before the request is sent, and the
9
+ * request is refused if any A or AAAA answer is not a global unicast
10
+ * address (`isPublicAddress`): one private answer rejects the whole
11
+ * answer, so a rebinding record cannot win by mixing one in.
12
+ * - It connects to the vetted address while keeping the hostname for SNI and
13
+ * certificate checks, so nothing can re-resolve the name between the check
14
+ * and the connect.
15
+ * - It follows no redirect: a 3xx is the answer, for the caller to refuse.
16
+ * - It bounds the connect and header time, the request body, an idle gap in
17
+ * the response and the number of connections in flight — never a stream's
18
+ * total life or bytes, since MCP streams over SSE and streamable HTTP. A
19
+ * caller that buffers a body (discovery, token responses) bounds its size
20
+ * and, through an abort signal, its total time.
21
+ */
22
+
23
+ import type { LookupAddress } from "node:dns";
24
+ import { lookup as dnsLookup } from "node:dns/promises";
25
+ import { request as httpsRequest, type RequestOptions } from "node:https";
26
+ import type { IncomingMessage } from "node:http";
27
+ import { isIP, type LookupFunction } from "node:net";
28
+
29
+ import { isPublicAddress } from "./secrets-proxy/address";
30
+
31
+ export type SafeHttpRefusalReason =
32
+ | "url"
33
+ | "resolve_failed"
34
+ | "non_public_address"
35
+ | "too_many_connections"
36
+ | "request_too_large"
37
+ | "response_too_large"
38
+ | "timeout"
39
+ | "connection_failed";
40
+
41
+ /** A request the rule refused, or one that failed before an answer. */
42
+ export class SafeHttpRefusal extends Error {
43
+ constructor(
44
+ readonly reason: SafeHttpRefusalReason,
45
+ message: string,
46
+ ) {
47
+ super(message);
48
+ this.name = "SafeHttpRefusal";
49
+ }
50
+ }
51
+
52
+ export interface SafeHttpLimits {
53
+ /** Resolve, TCP and TLS, together. */
54
+ connectMs: number;
55
+ /** From the request sent to the response's headers. */
56
+ headersMs: number;
57
+ /** The longest gap between two pieces of the response body. */
58
+ idleMs: number;
59
+ maxRequestBodyBytes: number;
60
+ }
61
+
62
+ export const DEFAULT_SAFE_HTTP_LIMITS: SafeHttpLimits = {
63
+ connectMs: 10_000,
64
+ headersMs: 60_000,
65
+ idleMs: 5 * 60_000,
66
+ maxRequestBodyBytes: 4 * 1024 * 1024,
67
+ };
68
+
69
+ /** The most requests one process has in flight under this rule. */
70
+ export const MAX_SAFE_HTTP_CONNECTIONS = 64;
71
+
72
+ export interface SafeHttpDeps {
73
+ /** Every address the name resolves to. */
74
+ resolveHost?: (hostname: string) => Promise<string[]>;
75
+ /** Tests only: which addresses count as public. */
76
+ isPublic?: (address: string) => boolean;
77
+ /** Tests only: extra trusted roots, PEM. */
78
+ ca?: string[];
79
+ }
80
+
81
+ export interface SafeHttpRequest {
82
+ url: string;
83
+ method?: "GET" | "POST" | "DELETE";
84
+ headers?: Record<string, string>;
85
+ body?: string | Uint8Array;
86
+ /** Ends the exchange, whatever phase it is in (a buffered call's total
87
+ * deadline). A stream's own reader may leave it unset. */
88
+ signal?: AbortSignal;
89
+ }
90
+
91
+ /** The response as it arrives: headers now, the body as a stream. */
92
+ export interface SafeHttpResponse {
93
+ status: number;
94
+ headers: IncomingMessage["headers"];
95
+ body: IncomingMessage;
96
+ }
97
+
98
+ let inFlight = 0;
99
+
100
+ async function defaultResolve(hostname: string): Promise<string[]> {
101
+ const answers: LookupAddress[] = await dnsLookup(hostname, { all: true, verbatim: true });
102
+ return answers.map((answer) => answer.address);
103
+ }
104
+
105
+ /** The URL, if the rule accepts its shape: https, a host, no userinfo. */
106
+ export function parseSafeUrl(raw: string): URL | null {
107
+ let url: URL;
108
+ try {
109
+ url = new URL(raw);
110
+ } catch {
111
+ return null;
112
+ }
113
+ if (url.protocol !== "https:" || url.username !== "" || url.password !== "" || url.hostname === "") {
114
+ return null;
115
+ }
116
+ return url;
117
+ }
118
+
119
+ function tooLong(): SafeHttpRefusal {
120
+ return new SafeHttpRefusal("timeout", "the exchange took too long");
121
+ }
122
+
123
+ async function vet(
124
+ hostname: string,
125
+ limits: SafeHttpLimits,
126
+ deps: SafeHttpDeps,
127
+ signal?: AbortSignal,
128
+ ): Promise<{ address: string; family: 4 | 6 }> {
129
+ const isPublic = deps.isPublic ?? isPublicAddress;
130
+ // A bracketed IPv6 literal arrives with its brackets.
131
+ const bare = hostname.startsWith("[") && hostname.endsWith("]") ? hostname.slice(1, -1) : hostname;
132
+ if (signal?.aborted) throw tooLong();
133
+ let answers: string[];
134
+ if (isIP(bare) !== 0) {
135
+ answers = [bare];
136
+ } else {
137
+ let timer: NodeJS.Timeout | undefined;
138
+ let onAbort: (() => void) | undefined;
139
+ try {
140
+ answers = await Promise.race([
141
+ (deps.resolveHost ?? defaultResolve)(bare),
142
+ new Promise<never>((_resolve, reject) => {
143
+ timer = setTimeout(() => reject(new Error("resolve timed out")), limits.connectMs);
144
+ timer.unref();
145
+ }),
146
+ // The caller's deadline ends the lookup too, not only what follows.
147
+ new Promise<never>((_resolve, reject) => {
148
+ if (!signal) return;
149
+ onAbort = () => reject(tooLong());
150
+ signal.addEventListener("abort", onAbort, { once: true });
151
+ if (signal.aborted) onAbort();
152
+ }),
153
+ ]);
154
+ } catch {
155
+ if (signal?.aborted) throw tooLong();
156
+ throw new SafeHttpRefusal("resolve_failed", `could not resolve ${bare}`);
157
+ } finally {
158
+ clearTimeout(timer);
159
+ if (onAbort) signal?.removeEventListener("abort", onAbort);
160
+ }
161
+ }
162
+ if (signal?.aborted) throw tooLong();
163
+ if (!Array.isArray(answers) || answers.length === 0) {
164
+ throw new SafeHttpRefusal("resolve_failed", `${bare} has no address`);
165
+ }
166
+ for (const address of answers) {
167
+ if (typeof address !== "string" || !isPublic(address)) {
168
+ throw new SafeHttpRefusal("non_public_address", `${bare} resolves to an address that is not public`);
169
+ }
170
+ }
171
+ const address = answers[0]!;
172
+ return { address, family: isIP(address) === 6 ? 6 : 4 };
173
+ }
174
+
175
+ /**
176
+ * The rule for a URL nothing here will fetch (an authorization page handed
177
+ * to the browser): https, no userinfo, and every address its name resolves
178
+ * to public. Throws `SafeHttpRefusal` otherwise.
179
+ */
180
+ export async function vetPublicUrl(
181
+ raw: string,
182
+ limits: SafeHttpLimits = DEFAULT_SAFE_HTTP_LIMITS,
183
+ deps: SafeHttpDeps = {},
184
+ signal?: AbortSignal,
185
+ ): Promise<void> {
186
+ const url = parseSafeUrl(raw);
187
+ if (!url) throw new SafeHttpRefusal("url", "only https URLs without userinfo are allowed");
188
+ await vet(url.hostname, limits, deps, signal);
189
+ }
190
+
191
+ /**
192
+ * Send one request under the rule. Resolves with the response once its
193
+ * headers arrive; the body streams, with an idle bound but no total one.
194
+ * Throws `SafeHttpRefusal` for anything the rule refuses or that fails first.
195
+ */
196
+ export async function safeRequest(
197
+ request: SafeHttpRequest,
198
+ limits: SafeHttpLimits = DEFAULT_SAFE_HTTP_LIMITS,
199
+ deps: SafeHttpDeps = {},
200
+ ): Promise<SafeHttpResponse> {
201
+ const url = parseSafeUrl(request.url);
202
+ if (!url) throw new SafeHttpRefusal("url", "only https URLs without userinfo are allowed");
203
+ if (request.signal?.aborted) throw tooLong();
204
+ const body =
205
+ request.body === undefined
206
+ ? undefined
207
+ : typeof request.body === "string"
208
+ ? Buffer.from(request.body, "utf8")
209
+ : Buffer.from(request.body);
210
+ if (body && body.byteLength > limits.maxRequestBodyBytes) {
211
+ throw new SafeHttpRefusal("request_too_large", "the request body is too large");
212
+ }
213
+ if (inFlight >= MAX_SAFE_HTTP_CONNECTIONS) {
214
+ throw new SafeHttpRefusal("too_many_connections", "too many requests in flight");
215
+ }
216
+ inFlight += 1;
217
+ let released = false;
218
+ const release = (): void => {
219
+ if (released) return;
220
+ released = true;
221
+ inFlight -= 1;
222
+ };
223
+ try {
224
+ const target = await vet(url.hostname, limits, deps, request.signal);
225
+ const lookup: LookupFunction = (_hostname, options, callback) => {
226
+ if (options.all === true) callback(null, [{ address: target.address, family: target.family }]);
227
+ else callback(null, target.address, target.family);
228
+ };
229
+ const servername = isIP(url.hostname.replace(/^\[|\]$/g, "")) === 0 ? url.hostname : undefined;
230
+ const options: RequestOptions = {
231
+ host: url.hostname,
232
+ port: url.port === "" ? 443 : Number(url.port),
233
+ path: `${url.pathname}${url.search}`,
234
+ method: request.method ?? (body ? "POST" : "GET"),
235
+ headers: {
236
+ ...request.headers,
237
+ ...(body ? { "content-length": String(body.byteLength) } : {}),
238
+ },
239
+ lookup,
240
+ ...(servername ? { servername } : {}),
241
+ // A connection per request: nothing pooled, nothing re-resolved.
242
+ agent: false,
243
+ rejectUnauthorized: true,
244
+ ...(deps.ca ? { ca: deps.ca } : {}),
245
+ };
246
+ return await new Promise<SafeHttpResponse>((resolve, reject) => {
247
+ const req = httpsRequest(options);
248
+ let settled = false;
249
+ const onAbort = (): void => fail(tooLong());
250
+ const fail = (error: SafeHttpRefusal): void => {
251
+ if (settled) return;
252
+ settled = true;
253
+ clearTimeout(connectTimer);
254
+ request.signal?.removeEventListener("abort", onAbort);
255
+ req.destroy();
256
+ release();
257
+ reject(error);
258
+ };
259
+ const connectTimer = setTimeout(
260
+ () => fail(new SafeHttpRefusal("timeout", "the connection took too long")),
261
+ limits.connectMs,
262
+ );
263
+ connectTimer.unref();
264
+ request.signal?.addEventListener("abort", onAbort, { once: true });
265
+ let headersTimer: NodeJS.Timeout | undefined;
266
+ req.once("socket", (socket) => {
267
+ socket.once("secureConnect", () => {
268
+ clearTimeout(connectTimer);
269
+ headersTimer = setTimeout(
270
+ () => fail(new SafeHttpRefusal("timeout", "the server took too long to answer")),
271
+ limits.headersMs,
272
+ );
273
+ headersTimer.unref();
274
+ });
275
+ });
276
+ req.once("error", (error) => {
277
+ clearTimeout(connectTimer);
278
+ clearTimeout(headersTimer);
279
+ fail(new SafeHttpRefusal("connection_failed", error.message));
280
+ });
281
+ req.once("response", (response) => {
282
+ clearTimeout(connectTimer);
283
+ clearTimeout(headersTimer);
284
+ if (settled) {
285
+ response.destroy();
286
+ return;
287
+ }
288
+ settled = true;
289
+ request.signal?.removeEventListener("abort", onAbort);
290
+ // Idle, never total: a stream may run as long as it keeps talking.
291
+ response.socket.setTimeout(limits.idleMs, () => {
292
+ response.destroy(new SafeHttpRefusal("timeout", "the response went idle"));
293
+ });
294
+ // A caller's deadline still ends the body it is reading.
295
+ request.signal?.addEventListener("abort", () => response.destroy(tooLong()), { once: true });
296
+ response.once("close", release);
297
+ resolve({ status: response.statusCode ?? 0, headers: response.headers, body: response });
298
+ });
299
+ // A listener added after the signal fired never runs: check it too,
300
+ // once every handler is in place (a destroyed request still errors).
301
+ if (request.signal?.aborted) {
302
+ onAbort();
303
+ return;
304
+ }
305
+ if (body) req.end(body);
306
+ else req.end();
307
+ });
308
+ } catch (error) {
309
+ release();
310
+ if (error instanceof SafeHttpRefusal) throw error;
311
+ throw new SafeHttpRefusal("connection_failed", error instanceof Error ? error.message : String(error));
312
+ }
313
+ }
314
+
315
+ /** Read a whole response body, refusing one larger than `maxBytes`. */
316
+ export async function readSafeBody(response: SafeHttpResponse, maxBytes: number): Promise<Buffer> {
317
+ const chunks: Buffer[] = [];
318
+ let size = 0;
319
+ for await (const chunk of response.body) {
320
+ const piece = Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk as Uint8Array);
321
+ size += piece.byteLength;
322
+ if (size > maxBytes) {
323
+ response.body.destroy();
324
+ throw new SafeHttpRefusal("response_too_large", "the response is too large");
325
+ }
326
+ chunks.push(piece);
327
+ }
328
+ return Buffer.concat(chunks);
329
+ }
330
+
331
+ /** A buffered JSON exchange (discovery, registration, token endpoints):
332
+ * the status and the parsed body, or null when it is not JSON. */
333
+ export async function safeJson(
334
+ request: SafeHttpRequest,
335
+ options: {
336
+ maxResponseBytes?: number;
337
+ limits?: SafeHttpLimits;
338
+ deps?: SafeHttpDeps;
339
+ /** The whole exchange, connect to last byte. */
340
+ deadlineMs?: number;
341
+ } = {},
342
+ ): Promise<{ status: number; json: unknown }> {
343
+ const signal =
344
+ request.signal ?? (options.deadlineMs !== undefined ? AbortSignal.timeout(Math.max(1, options.deadlineMs)) : undefined);
345
+ const response = await safeRequest(
346
+ { ...request, headers: { accept: "application/json", ...request.headers }, ...(signal ? { signal } : {}) },
347
+ options.limits,
348
+ options.deps,
349
+ );
350
+ let raw: Buffer;
351
+ try {
352
+ raw = await readSafeBody(response, options.maxResponseBytes ?? 256 * 1024);
353
+ } catch (error) {
354
+ if (error instanceof SafeHttpRefusal) throw error;
355
+ throw new SafeHttpRefusal(signal?.aborted ? "timeout" : "connection_failed", "the response did not complete");
356
+ }
357
+ let json: unknown = null;
358
+ try {
359
+ json = JSON.parse(raw.toString("utf8"));
360
+ } catch {
361
+ json = null;
362
+ }
363
+ return { status: response.status, json };
364
+ }
@@ -103,11 +103,35 @@ let quiescing = false;
103
103
  * nothing), so none can land over, or recreate readiness for, what follows. */
104
104
  const epochs = new Map<string, number>();
105
105
  const epochOf = (taskId: string): number => epochs.get(taskId) ?? 0;
106
+ /** A credential cleanup closes this fence before it drains existing writers.
107
+ * Unlike an epoch bump, the seal also refuses writers that arrive while the
108
+ * cleanup is in progress. It is reopened only after a safe assignment/grant
109
+ * has been installed. */
110
+ const sealedTaskWrites = new Set<string>();
106
111
 
107
112
  export function invalidateTaskWrites(taskId: string): void {
108
113
  epochs.set(taskId, epochOf(taskId) + 1);
109
114
  }
110
115
 
116
+ export function sealTaskWrites(taskId: string): void {
117
+ sealedTaskWrites.add(taskId);
118
+ invalidateTaskWrites(taskId);
119
+ }
120
+
121
+ export function openTaskWrites(taskId: string): void {
122
+ if (!sealedTaskWrites.delete(taskId)) return;
123
+ // Writers which observed the closed assignment must not become live merely
124
+ // because a later assignment opens the task again.
125
+ invalidateTaskWrites(taskId);
126
+ }
127
+
128
+ /** Join every root mutation already admitted for one task after its epoch has
129
+ * been invalidated. Callers close the task's other producers before awaiting
130
+ * this; a rejected superseded writer counts as drained. */
131
+ export async function drainTaskWrites(taskId: string): Promise<void> {
132
+ await (queues.get(taskId) ?? Promise.resolve()).catch(() => undefined);
133
+ }
134
+
111
135
  /** Whether the runner is shutting down: nothing root may start in a task. */
112
136
  export function taskWritesQuiescing(): boolean {
113
137
  return quiescing;
@@ -128,8 +152,15 @@ function refusal(): Error {
128
152
  * could land over the replacement's. */
129
153
  function mutate<T>(taskId: string, step: (live: Live) => Promise<T>, isCurrent?: () => boolean): Promise<T> {
130
154
  if (quiescing) return Promise.reject(new Error("the runner is shutting down"));
155
+ if (sealedTaskWrites.has(taskId)) {
156
+ return Promise.reject(new Error("this task's credential gate is closed"));
157
+ }
131
158
  const epoch = epochOf(taskId);
132
- const live: Live = () => !quiescing && epochOf(taskId) === epoch && (isCurrent === undefined || isCurrent());
159
+ const live: Live = () =>
160
+ !quiescing &&
161
+ !sealedTaskWrites.has(taskId) &&
162
+ epochOf(taskId) === epoch &&
163
+ (isCurrent === undefined || isCurrent());
133
164
  const previous = queues.get(taskId) ?? Promise.resolve();
134
165
  const next = previous
135
166
  .catch(() => undefined)
@@ -540,7 +571,7 @@ export function applyTaskRootSteps(
540
571
  export async function reconcileSidecarTaskProxy(
541
572
  environment: TaskRootExec,
542
573
  runtime: SecretsProxyRuntime | null,
543
- options: { start: boolean },
574
+ options: { start: boolean; isCurrent?: () => boolean },
544
575
  ): Promise<string | null> {
545
576
  if (!takesTrust(environment)) return null;
546
577
  // A start voids every write queued or under way before it. Whether the
@@ -549,7 +580,7 @@ export async function reconcileSidecarTaskProxy(
549
580
  if (options.start) invalidateTaskWrites(environment.descriptor.taskId);
550
581
  let failure: unknown = null;
551
582
  try {
552
- failure = (await applyTaskRootSteps(environment, { runtime }, async () => undefined))?.error ?? null;
583
+ failure = (await applyTaskRootSteps(environment, { runtime }, async () => undefined, options.isCurrent))?.error ?? null;
553
584
  } catch (error) {
554
585
  failure = error;
555
586
  }
@@ -40,6 +40,13 @@ export interface SidecarResumeDeps {
40
40
  loader?: (handle: TaskEnvironmentHandle<unknown>) => Promise<void>;
41
41
  editor?: (handle: TaskEnvironmentHandle<unknown>, workspacePath: string) => Promise<void>;
42
42
  proxy?: typeof reconcileSidecarTaskProxy;
43
+ /** Remount the shared files from the v0 lease (the default). ADR-154: a
44
+ * task on cloud credentials remounts them from its own run's Files
45
+ * session instead, never from the runner's process-wide v0 lease. */
46
+ remountFiles?: boolean;
47
+ /** ADR-154: aborted when the run this resume is for ends (Stop, teardown,
48
+ * a newer run, shutdown): no step starts after. */
49
+ signal?: AbortSignal;
43
50
  }
44
51
 
45
52
  export interface SidecarResumeResult {
@@ -48,7 +55,7 @@ export interface SidecarResumeResult {
48
55
  * task-up does the work). `resumed`: the
49
56
  * task was up before this runner started; its mounts and editor were
50
57
  * re-asserted, with any per-step warning listed. */
51
- outcome: "no-task" | "not-up" | "resumed";
58
+ outcome: "no-task" | "not-up" | "resumed" | "interrupted";
52
59
  warnings: string[];
53
60
  }
54
61
 
@@ -90,8 +97,11 @@ export async function resumeSidecarTask(
90
97
  if (!handle) return { outcome: "not-up", warnings: [] };
91
98
  const workspacePath = handle.descriptor.workspacePath;
92
99
  const warnings: string[] = [];
100
+ const ended = (): boolean => deps.signal?.aborted === true;
101
+ const interrupted = (): SidecarResumeResult => ({ outcome: "interrupted", warnings });
93
102
 
94
103
  if (task.envMode === "loader") {
104
+ if (ended()) return interrupted();
95
105
  try {
96
106
  await (deps.loader ?? installMachineProjectEnvLoader)(handle);
97
107
  } catch (error) {
@@ -104,17 +114,19 @@ export async function resumeSidecarTask(
104
114
  }
105
115
  // ADR-153: the proxy's CA and pins again (a reboot keeps neither the
106
116
  // chain nor this runner's record of any of it).
117
+ if (ended()) return interrupted();
107
118
  const proxyWarning = await (deps.proxy ?? reconcileSidecarTaskProxy)(
108
119
  handle,
109
120
  activeSecretsProxy()?.runtime ?? null,
110
- { start: false },
121
+ { start: false, ...(deps.signal ? { isCurrent: () => !ended() } : {}) },
111
122
  );
112
123
  if (proxyWarning !== null) warnings.push(proxyWarning);
113
124
  }
114
125
 
115
- const marker = await handle
116
- .readWorkspaceFile(`${workspacePath}/.uai/files-mounted`)
117
- .catch(() => null);
126
+ const marker =
127
+ deps.remountFiles === false
128
+ ? null
129
+ : await handle.readWorkspaceFile(`${workspacePath}/.uai/files-mounted`).catch(() => null);
118
130
  const mode = mountedModeFromMarker(marker);
119
131
  if (mode) {
120
132
  try {
@@ -129,6 +141,7 @@ export async function resumeSidecarTask(
129
141
  }
130
142
  }
131
143
 
144
+ if (ended()) return interrupted();
132
145
  try {
133
146
  await (deps.editor ?? defaultEditor)(handle, workspacePath);
134
147
  } catch (error) {
package/lib/skills.ts CHANGED
@@ -18,8 +18,6 @@
18
18
  * skills are Claude-only and task-level (shared by the container's agents),
19
19
  * NOT written into the flat per-agent SKILL.md.
20
20
  */
21
- import { mkdirSync, rmSync, writeFileSync } from "node:fs";
22
- import { resolve } from "node:path";
23
21
 
24
22
  import { taskWorkspaceDir } from "./env";
25
23
  import { dockerCli } from "./docker-exec";
@@ -29,6 +27,14 @@ import {
29
27
  } from "./task-container-cli";
30
28
  import { runtimeAuthorityDockerExecArgs } from "./runtime-authority";
31
29
  import type { RosterAgent, RosterSkill } from "./agents/types";
30
+ import {
31
+ makeContainedDirectories,
32
+ makeContainedDirectoriesSync,
33
+ removeContainedEntry,
34
+ WorkspaceDirectoryMissingError,
35
+ writeContainedFileSync,
36
+ type RuntimeNamespace,
37
+ } from "./task-environment/workspace-files";
32
38
 
33
39
  /** Skills that go into the flat per-agent SKILL.md — link/document only. The
34
40
  * ADR-047 `package` skills install into the container's skills dir instead. */
@@ -65,16 +71,49 @@ export function renderSkillMd(agent: RosterAgent): string {
65
71
  * when written, else null. Best-effort — a write failure is swallowed so a
66
72
  * skills problem never blocks the agent from running.
67
73
  */
68
- export function writeAgentSkills(taskId: string, agent: RosterAgent): string | null {
69
- const dir = resolve(taskWorkspaceDir(taskId), ".uai", "agents", agent.id);
70
- const file = resolve(dir, "SKILL.md");
74
+ export function writeAgentSkills(
75
+ taskId: string,
76
+ agent: RosterAgent,
77
+ runtime?: () => Promise<RuntimeNamespace | null>,
78
+ ): string | null {
79
+ // Into the task's own .uai, never through a link the task put there
80
+ // (#cb3j0z). Where only the task's runtime can make the agent's directory
81
+ // (macOS), it is made there once the runtime is reachable, then the file
82
+ // written; the agent reads it when it consults its skills.
83
+ const workspace = taskWorkspaceDir(taskId);
84
+ const directory = [".uai", "agents", agent.id];
85
+ const file = [...directory, "SKILL.md"];
71
86
  try {
72
87
  if (flatSkills(agent).length === 0) {
73
- rmSync(file, { force: true });
88
+ void (async () => {
89
+ try {
90
+ await removeContainedEntry(workspace, file);
91
+ } catch (error) {
92
+ // Where only the runtime can remove it (macOS).
93
+ const reached = await runtime?.();
94
+ if (!reached) throw error;
95
+ await removeContainedEntry(workspace, file, reached);
96
+ }
97
+ })().catch(() => {});
74
98
  return null;
75
99
  }
76
- mkdirSync(dir, { recursive: true });
77
- writeFileSync(file, renderSkillMd(agent));
100
+ const content = Buffer.from(renderSkillMd(agent));
101
+ if (!makeContainedDirectoriesSync(workspace, directory)) {
102
+ try {
103
+ writeContainedFileSync(workspace, file, content);
104
+ return containerSkillFile(agent.id);
105
+ } catch (error) {
106
+ if (!(error instanceof WorkspaceDirectoryMissingError) || !runtime) throw error;
107
+ }
108
+ void (async () => {
109
+ const reached = await runtime();
110
+ if (!reached) return;
111
+ await makeContainedDirectories(workspace, directory, reached);
112
+ writeContainedFileSync(workspace, file, content);
113
+ })().catch(() => {});
114
+ return containerSkillFile(agent.id);
115
+ }
116
+ writeContainedFileSync(workspace, file, content);
78
117
  return containerSkillFile(agent.id);
79
118
  } catch {
80
119
  return null;