@runuai/host 0.9.150 → 0.9.151
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/0027_cloud_credential_foundation.sql +51 -0
- package/db/migrations/0028_leased_engine_runtimes.sql +11 -0
- package/db/migrations/meta/_journal.json +14 -0
- package/db/schema.ts +188 -0
- package/lib/agent-cli.ts +11 -13
- package/lib/agent.ts +177 -7
- package/lib/agents/durable-proc.ts +56 -44
- package/lib/agents/registry.ts +19 -3
- package/lib/agents/session-state.ts +23 -10
- package/lib/agents/transport.ts +101 -69
- package/lib/apple-one-off.ts +277 -0
- package/lib/apple-runtime-recycle.ts +4 -3
- package/lib/attachments.ts +19 -17
- package/lib/capture-flow.ts +319 -0
- package/lib/cloud-credential-cleanup-adapter.ts +123 -0
- package/lib/cloud-credential-host.ts +512 -0
- package/lib/cloud-credential-leases.ts +2544 -0
- package/lib/cloud-credential-runtime.ts +1035 -0
- package/lib/codex-auth.ts +21 -1
- package/lib/container-runtime.ts +76 -0
- package/lib/credential-cleanup-inventory.ts +405 -0
- package/lib/credential-consumers/engine-anchor.ts +1876 -0
- package/lib/credential-consumers/engines.ts +773 -0
- package/lib/credential-consumers/github.ts +594 -0
- package/lib/credential-consumers/mcp.ts +384 -0
- package/lib/credential-consumers/task-files.ts +127 -0
- package/lib/credential-store-pickup.ts +15 -2
- package/lib/credential-store.ts +361 -33
- package/lib/credentials-activation-authority.ts +546 -0
- package/lib/credentials-purge.ts +1006 -0
- package/lib/db.ts +11 -0
- package/lib/docker-exec.ts +3 -1
- package/lib/engine-accounts.ts +42 -32
- package/lib/engine-connect.ts +47 -2
- package/lib/engine-credential-env.ts +50 -0
- package/lib/engine-login.ts +83 -1
- package/lib/env-file.ts +62 -8
- package/lib/env.ts +5 -0
- package/lib/fs-aliases.ts +49 -0
- package/lib/git-clone-credentials.ts +334 -0
- package/lib/git-diff.ts +219 -177
- package/lib/git-identity.ts +59 -14
- package/lib/github-origin.ts +28 -0
- package/lib/github-tokens.ts +56 -21
- package/lib/host-env.ts +17 -4
- package/lib/launch-mode.ts +25 -0
- package/lib/legacy-runtime-retirement.ts +388 -0
- package/lib/machine-exec.ts +14 -3
- package/lib/machine-provider-aws.ts +50 -8
- package/lib/machine-provider-local.ts +4 -0
- package/lib/machine-provider.ts +4 -0
- package/lib/managed-operation-drain.ts +8 -0
- package/lib/managed-runtime.ts +371 -7
- package/lib/mcp-capture.ts +860 -0
- package/lib/mcp-connections.ts +8 -1
- package/lib/mcp-gateway.ts +168 -10
- package/lib/obs.ts +2 -6
- package/lib/orchestrator.ts +1830 -224
- package/lib/person-capture.ts +327 -0
- package/lib/project-env-legacy-tags.ts +130 -0
- package/lib/project-env-task-replica.ts +1752 -0
- package/lib/runner-boot.ts +10 -0
- package/lib/runtime-activation-tail.ts +50 -3
- package/lib/runtime-archive.ts +68 -1
- package/lib/runtime-reconnect.ts +86 -0
- package/lib/runtime-state.ts +429 -13
- package/lib/safe-http.ts +364 -0
- package/lib/secrets-proxy/task-trust.ts +34 -3
- package/lib/sidecar-resume.ts +18 -5
- package/lib/skills.ts +47 -8
- package/lib/standard-image.ts +157 -103
- package/lib/stopped-task-status-queue.ts +68 -10
- package/lib/task-credential-cleanup.ts +525 -0
- package/lib/task-diff-sandbox.ts +464 -0
- package/lib/task-diff.ts +66 -163
- package/lib/task-env-source.ts +68 -14
- package/lib/task-environment/anchor.ts +31 -0
- package/lib/task-environment/apple-container.ts +69 -28
- package/lib/task-environment/docker.ts +101 -20
- package/lib/task-environment/machine-environment.ts +61 -0
- package/lib/task-environment/machine-task-up.ts +459 -96
- package/lib/task-environment/machine.ts +101 -11
- package/lib/task-environment/self.ts +108 -50
- package/lib/task-environment/types.ts +89 -5
- package/lib/task-environment/workspace-files.ts +623 -25
- package/lib/task-environment/workspace-runtime.fixture.ts +50 -0
- package/lib/task-gateway-recovery.ts +4 -1
- package/lib/task-gateway-state.ts +15 -0
- package/lib/task-machine-swap-safety.ts +729 -0
- package/lib/token-broker-adopt.ts +344 -0
- package/lib/token-broker-lambda.ts +56 -2
- package/lib/token-broker-local.ts +28 -4
- package/lib/token-broker-revision.ts +310 -0
- package/lib/token-broker-ssm.ts +368 -28
- package/lib/token-lanes/openai-oauth.ts +7 -3
- package/lib/token-lanes/xai-oauth.ts +7 -3
- package/lib/transcript.ts +9 -5
- package/package.json +6 -2
- package/scripts/agent/task-up.sh +242 -100
- package/src/cloud-credentials.ts +2399 -0
- package/src/event-outbox.ts +8 -6
- package/src/index.ts +1386 -875
- package/src/main.ts +1061 -139
- package/src/outbound-frame.ts +31 -0
- package/src/project-env-policy.ts +125 -10
- package/src/protocol.ts +699 -23
- package/src/ui/server.ts +48 -10
package/lib/safe-http.ts
ADDED
|
@@ -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 = () =>
|
|
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
|
}
|
package/lib/sidecar-resume.ts
CHANGED
|
@@ -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 =
|
|
116
|
-
.
|
|
117
|
-
|
|
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(
|
|
69
|
-
|
|
70
|
-
|
|
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
|
-
|
|
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
|
-
|
|
77
|
-
|
|
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;
|