@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.
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 +1876 -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 +1830 -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 +623 -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
@@ -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
- secret = await refreshTokens(row, secret);
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
  }
@@ -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
- async function readBody(req: IncomingMessage): Promise<Buffer | null> {
267
- const chunks: Buffer[] = [];
268
- let size = 0;
269
- for await (const chunk of req) {
270
- const buf = chunk as Buffer;
271
- size += buf.length;
272
- if (size > MAX_BODY_BYTES) return null;
273
- chunks.push(buf);
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
- const auth = await authHeaderFor(conn, forceRefresh);
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
  }