@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/mcp-connections.ts
CHANGED
|
@@ -15,6 +15,7 @@ import { createHash, randomBytes } from "node:crypto";
|
|
|
15
15
|
import { eq } from "drizzle-orm";
|
|
16
16
|
|
|
17
17
|
import { configuredCredentialStore, queueStoreWrite, userParameterName } from "./credential-store";
|
|
18
|
+
import { LeftOwnCredentialsError, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
18
19
|
import { getDb, schema } from "./db";
|
|
19
20
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
20
21
|
import { signInBoxMode } from "./signin-box";
|
|
@@ -627,7 +628,13 @@ export async function authHeaderFor(
|
|
|
627
628
|
forceRefresh ||
|
|
628
629
|
(secret.expiresAt && secret.expiresAt - REFRESH_LEAD_MS < Date.now())
|
|
629
630
|
) {
|
|
630
|
-
|
|
631
|
+
// ADR-154 §7: a refresh only while the runner is on its own
|
|
632
|
+
// credentials, counted whole (the rotated secret written, its store
|
|
633
|
+
// mirror queued): leaving them waits for it, and none begins after.
|
|
634
|
+
const current = secret;
|
|
635
|
+
const refreshing = whileOnOwnCredentials(() => refreshTokens(row, current));
|
|
636
|
+
if (refreshing === null) throw new LeftOwnCredentialsError();
|
|
637
|
+
secret = await refreshing;
|
|
631
638
|
}
|
|
632
639
|
return { name: "authorization", value: `Bearer ${secret.accessToken}` };
|
|
633
640
|
}
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
* VERIFY-ON-MAC: OrbStack forwards host.docker.internal to loopback-bound
|
|
16
16
|
* host services; if not, set UAI_MCP_GATEWAY_BIND=0.0.0.0 (the unguessable
|
|
17
17
|
* per-task token stays the auth).
|
|
18
|
+
*
|
|
19
|
+
* ADR-154 §11: a task on cloud credentials is served from its lease instead
|
|
20
|
+
* (credential-consumers/mcp.ts): the header comes from memory, every request
|
|
21
|
+
* goes through the SSRF rule, and an expired or refused OAuth token is
|
|
22
|
+
* refreshed by the cloud's broker, never here. On a runner past its
|
|
23
|
+
* credentials floor nothing falls back to the local store.
|
|
18
24
|
*/
|
|
19
25
|
|
|
20
26
|
import {
|
|
@@ -56,7 +62,16 @@ import {
|
|
|
56
62
|
runtimeAuthorityDockerExecArgs,
|
|
57
63
|
} from "./runtime-authority";
|
|
58
64
|
import type { TaskEnvironmentHandle } from "./task-environment/types";
|
|
65
|
+
import { LeftOwnCredentialsError, onOwnCredentials, whileOnOwnCredentials } from "./cloud-credential-host";
|
|
59
66
|
import { authHeaderFor, getConnection } from "./mcp-connections";
|
|
67
|
+
import {
|
|
68
|
+
forgetLeasedMcp,
|
|
69
|
+
holdLeasedMcp,
|
|
70
|
+
leasedMcpFor,
|
|
71
|
+
type McpRequestHold,
|
|
72
|
+
type LeasedMcpConnection,
|
|
73
|
+
} from "./credential-consumers/mcp";
|
|
74
|
+
import { safeRequest, type SafeHttpDeps, type SafeHttpResponse } from "./safe-http";
|
|
60
75
|
import { agentClisReady } from "./standard-image";
|
|
61
76
|
import { isSafeHostTaskId, assertSafeHostTaskId } from "./task-identity";
|
|
62
77
|
import type { McpGatewayCapability } from "../src/protocol";
|
|
@@ -224,6 +239,7 @@ export function clearTaskGatewayAcl(taskId: string): void {
|
|
|
224
239
|
// like `../container-runtime-provider` must never resolve outside the ACL
|
|
225
240
|
// directory, so an unsafe id is a no-op here rather than a deletion.
|
|
226
241
|
if (!isSafeHostTaskId(taskId)) return;
|
|
242
|
+
forgetLeasedMcp(taskId);
|
|
227
243
|
try {
|
|
228
244
|
rmSync(aclPath(taskId));
|
|
229
245
|
} catch {
|
|
@@ -263,16 +279,28 @@ function deny(res: ServerResponse, status: number, message: string): void {
|
|
|
263
279
|
res.end(JSON.stringify({ error: message }));
|
|
264
280
|
}
|
|
265
281
|
|
|
266
|
-
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
282
|
+
/** The request's body, or null past MAX_BODY_BYTES. With `signal`, an abort
|
|
283
|
+
* ends the request where it is (the read rejects): a task that stops
|
|
284
|
+
* sending mid-body never holds what waits for its request to end. */
|
|
285
|
+
async function readBody(req: IncomingMessage, signal?: AbortSignal): Promise<Buffer | null> {
|
|
286
|
+
const stop = (): void => {
|
|
287
|
+
req.destroy(new Error("request ended"));
|
|
288
|
+
};
|
|
289
|
+
if (signal?.aborted) stop();
|
|
290
|
+
else signal?.addEventListener("abort", stop, { once: true });
|
|
291
|
+
try {
|
|
292
|
+
const chunks: Buffer[] = [];
|
|
293
|
+
let size = 0;
|
|
294
|
+
for await (const chunk of req) {
|
|
295
|
+
const buf = chunk as Buffer;
|
|
296
|
+
size += buf.length;
|
|
297
|
+
if (size > MAX_BODY_BYTES) return null;
|
|
298
|
+
chunks.push(buf);
|
|
299
|
+
}
|
|
300
|
+
return Buffer.concat(chunks);
|
|
301
|
+
} finally {
|
|
302
|
+
signal?.removeEventListener("abort", stop);
|
|
274
303
|
}
|
|
275
|
-
return Buffer.concat(chunks);
|
|
276
304
|
}
|
|
277
305
|
|
|
278
306
|
/** JSON-RPC method (+ tool name for tools/call) out of a request body. */
|
|
@@ -405,6 +433,20 @@ async function handleMcpRequest(
|
|
|
405
433
|
res.setHeader("connection", "close");
|
|
406
434
|
return deny(res, 429, "task connection limit reached");
|
|
407
435
|
}
|
|
436
|
+
// ADR-154 §11: a task on cloud credentials is served from its lease.
|
|
437
|
+
const lease = leasedMcpFor(taskId);
|
|
438
|
+
if (lease || leaseOnly) {
|
|
439
|
+
if (!lease?.open) return deny(res, 503, "credentials unavailable");
|
|
440
|
+
const leased = lease.connections.get(slug);
|
|
441
|
+
if (!leased) return deny(res, 404, `no connection "${slug}" for this task`);
|
|
442
|
+
// Named but unreadable: unavailable, never "no such connection".
|
|
443
|
+
const hold = "unavailable" in leased ? null : holdLeasedMcp(taskId, slug);
|
|
444
|
+
if (!hold) return deny(res, 503, "credentials unavailable");
|
|
445
|
+
return serveLeasedConnection(req, res, taskId, hold);
|
|
446
|
+
}
|
|
447
|
+
// ADR-154: the runner's own connections only while it is on its own
|
|
448
|
+
// credentials; from the moment it begins leaving them, none is read.
|
|
449
|
+
if (!onOwnCredentials()) return deny(res, 503, "credentials unavailable");
|
|
408
450
|
const entry = acl.connections.find((c) => c.slug === slug);
|
|
409
451
|
if (!entry) return deny(res, 404, `no connection "${slug}" for this task`);
|
|
410
452
|
const conn = getConnection(entry.id);
|
|
@@ -432,7 +474,12 @@ async function handleMcpRequest(
|
|
|
432
474
|
res.on("close", () => controller.abort());
|
|
433
475
|
|
|
434
476
|
const attempt = async (forceRefresh: boolean): Promise<Response> => {
|
|
435
|
-
|
|
477
|
+
// Counted (leaving waits for a refresh under way), and never a local
|
|
478
|
+
// header sent once leaving has begun, however long the read took.
|
|
479
|
+
const reading = whileOnOwnCredentials(() => authHeaderFor(conn, forceRefresh));
|
|
480
|
+
if (reading === null) throw new LeftOwnCredentialsError();
|
|
481
|
+
const auth = await reading;
|
|
482
|
+
if (!onOwnCredentials()) throw new LeftOwnCredentialsError();
|
|
436
483
|
if (auth) headers[auth.name] = auth.value;
|
|
437
484
|
const connectTimer = setTimeout(
|
|
438
485
|
() => controller.abort(),
|
|
@@ -457,6 +504,7 @@ async function handleMcpRequest(
|
|
|
457
504
|
upstream = await attempt(true);
|
|
458
505
|
}
|
|
459
506
|
} catch (err) {
|
|
507
|
+
if (err instanceof LeftOwnCredentialsError) return deny(res, 503, "credentials unavailable");
|
|
460
508
|
audit({ taskId, slug, method: req.method, error: String(err) });
|
|
461
509
|
return deny(res, 502, "upstream unreachable");
|
|
462
510
|
}
|
|
@@ -495,6 +543,116 @@ async function handleMcpRequest(
|
|
|
495
543
|
}
|
|
496
544
|
}
|
|
497
545
|
|
|
546
|
+
/** ADR-154 §13: a runner past its credentials floor serves MCP only from
|
|
547
|
+
* leases; a task without one is answered 503, never from the local store. */
|
|
548
|
+
let leaseOnly = false;
|
|
549
|
+
|
|
550
|
+
export function setMcpGatewayLeaseOnly(on: boolean): void {
|
|
551
|
+
leaseOnly = on;
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
/** Tests only: the SSRF rule's injectable resolver and trust roots. */
|
|
555
|
+
let leasedSafeHttpDeps: SafeHttpDeps = {};
|
|
556
|
+
export function setLeasedMcpSafeHttpDepsForTests(deps: SafeHttpDeps): void {
|
|
557
|
+
leasedSafeHttpDeps = deps;
|
|
558
|
+
}
|
|
559
|
+
|
|
560
|
+
const LEASED_METHODS = new Set(["GET", "POST", "DELETE"]);
|
|
561
|
+
|
|
562
|
+
/**
|
|
563
|
+
* One request for a leased connection: attach its header, send it under the
|
|
564
|
+
* SSRF rule, and stream the answer back. The request is held on its task: the
|
|
565
|
+
* gate closing, a cleanup, or the task hanging up ends it wherever it is,
|
|
566
|
+
* connecting, waiting for headers or streaming. An OAuth token past its
|
|
567
|
+
* expiry is refreshed first and never sent; a 401 is retried once, only with
|
|
568
|
+
* a newer token for the very connection the request began with.
|
|
569
|
+
*/
|
|
570
|
+
async function serveLeasedConnection(
|
|
571
|
+
req: IncomingMessage,
|
|
572
|
+
res: ServerResponse,
|
|
573
|
+
taskId: string,
|
|
574
|
+
hold: McpRequestHold,
|
|
575
|
+
): Promise<void> {
|
|
576
|
+
const slug = hold.connection.slug;
|
|
577
|
+
// From the start: a hang-up ends the request before headers too.
|
|
578
|
+
const client = new AbortController();
|
|
579
|
+
res.once("close", () => {
|
|
580
|
+
client.abort();
|
|
581
|
+
hold.end();
|
|
582
|
+
});
|
|
583
|
+
const signal = AbortSignal.any([hold.signal, client.signal]);
|
|
584
|
+
const method = req.method ?? "GET";
|
|
585
|
+
if (!LEASED_METHODS.has(method)) return deny(res, 405, "method not allowed");
|
|
586
|
+
let body: Buffer | null = null;
|
|
587
|
+
if (method === "POST") {
|
|
588
|
+
try {
|
|
589
|
+
body = await readBody(req, signal);
|
|
590
|
+
} catch {
|
|
591
|
+
// Ended before its body arrived: the gate closed, or the task went.
|
|
592
|
+
if (!res.destroyed && !res.headersSent) deny(res, 503, "credentials unavailable");
|
|
593
|
+
return;
|
|
594
|
+
}
|
|
595
|
+
if (body === null) return deny(res, 413, "request body too large");
|
|
596
|
+
}
|
|
597
|
+
const forwarded: Record<string, string> = {};
|
|
598
|
+
for (const name of FORWARD_REQ_HEADERS) {
|
|
599
|
+
const v = req.headers[name];
|
|
600
|
+
if (typeof v === "string") forwarded[name] = v;
|
|
601
|
+
}
|
|
602
|
+
|
|
603
|
+
let connection = hold.current();
|
|
604
|
+
if (!connection) return deny(res, 503, "credentials unavailable");
|
|
605
|
+
if (connection.authKind === "oauth" && connection.expiresAt !== null && connection.expiresAt <= Date.now()) {
|
|
606
|
+
connection = await hold.refresh(connection);
|
|
607
|
+
if (!connection) return deny(res, 503, "credentials unavailable");
|
|
608
|
+
}
|
|
609
|
+
|
|
610
|
+
const attempt = (current: LeasedMcpConnection): Promise<SafeHttpResponse> =>
|
|
611
|
+
safeRequest(
|
|
612
|
+
{
|
|
613
|
+
url: current.url,
|
|
614
|
+
method: method as "GET" | "POST" | "DELETE",
|
|
615
|
+
headers: current.header ? { ...forwarded, [current.header.name]: current.header.value } : forwarded,
|
|
616
|
+
...(body ? { body } : {}),
|
|
617
|
+
signal,
|
|
618
|
+
},
|
|
619
|
+
undefined,
|
|
620
|
+
leasedSafeHttpDeps,
|
|
621
|
+
);
|
|
622
|
+
|
|
623
|
+
let upstream: SafeHttpResponse;
|
|
624
|
+
try {
|
|
625
|
+
upstream = await attempt(connection);
|
|
626
|
+
if (upstream.status === 401 && connection.authKind === "oauth") {
|
|
627
|
+
upstream.body.destroy();
|
|
628
|
+
const refreshed = await hold.refresh(connection);
|
|
629
|
+
if (!refreshed) return deny(res, 401, `connection "${slug}" needs a new sign-in`);
|
|
630
|
+
connection = refreshed;
|
|
631
|
+
upstream = await attempt(connection);
|
|
632
|
+
}
|
|
633
|
+
} catch (err) {
|
|
634
|
+
if (signal.aborted) return deny(res, 503, "credentials unavailable");
|
|
635
|
+
audit({ taskId, slug, method, error: err instanceof Error ? err.message : String(err) });
|
|
636
|
+
return deny(res, 502, "upstream unreachable");
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
const summary = body ? rpcSummary(body) : {};
|
|
640
|
+
audit({ taskId, slug, method, rpc: summary.method, tool: summary.tool, status: upstream.status });
|
|
641
|
+
const resHeaders: Record<string, string> = {};
|
|
642
|
+
for (const name of FORWARD_RES_HEADERS) {
|
|
643
|
+
const v = upstream.headers[name];
|
|
644
|
+
if (typeof v === "string") resHeaders[name] = v;
|
|
645
|
+
}
|
|
646
|
+
res.writeHead(upstream.status, resHeaders);
|
|
647
|
+
// A stream ends with its task's gate or its client: the signal destroys the
|
|
648
|
+
// upstream body, and the pipeline tears down both ends.
|
|
649
|
+
pipeline(upstream.body, res, (err) => {
|
|
650
|
+
if (err && err.name !== "AbortError" && !signal.aborted) {
|
|
651
|
+
audit({ taskId, slug, stream: "ended", error: err.name });
|
|
652
|
+
}
|
|
653
|
+
});
|
|
654
|
+
}
|
|
655
|
+
|
|
498
656
|
async function handleGatewayRequest(
|
|
499
657
|
req: IncomingMessage,
|
|
500
658
|
res: ServerResponse,
|
package/lib/obs.ts
CHANGED
|
@@ -41,6 +41,7 @@ import { join } from "node:path";
|
|
|
41
41
|
import * as Sentry from "@sentry/node";
|
|
42
42
|
|
|
43
43
|
import { env } from "./env";
|
|
44
|
+
import { isDevelopmentLaunch } from "./launch-mode";
|
|
44
45
|
|
|
45
46
|
/**
|
|
46
47
|
* The uai-host Sentry project's DSN, baked into the shipped package (npm +
|
|
@@ -79,12 +80,7 @@ export function resolveHostDsn(env: {
|
|
|
79
80
|
// filesystem position (review finding: real dev launches — root
|
|
80
81
|
// `pnpm host-agent`, desktop non-packaged — set none of the explicit
|
|
81
82
|
// flags). The explicit flags remain as overrides/belt.
|
|
82
|
-
const repoOrDev =
|
|
83
|
-
env.NODE_ENV === "development" ||
|
|
84
|
-
env.NODE_ENV === "test" ||
|
|
85
|
-
env.UAI_AGENT_FROM_REPO === "1" ||
|
|
86
|
-
env.UAI_AGENT_FROM_REPO === "true" ||
|
|
87
|
-
env.UAI_LAUNCHED_FROM_REPO === "1";
|
|
83
|
+
const repoOrDev = isDevelopmentLaunch(env);
|
|
88
84
|
if (DEFAULT_HOST_DSN && !repoOrDev) {
|
|
89
85
|
return { dsn: DEFAULT_HOST_DSN, source: "default" };
|
|
90
86
|
}
|