@runuai/host 0.9.152 → 0.9.154

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.
@@ -201,6 +201,7 @@ import {
201
201
  listLeasedEngineRuntimes,
202
202
  rememberLeasedEngineRuntime,
203
203
  } from "./credential-cleanup-inventory";
204
+ import { leasedMcpAclEntries } from "./credential-consumers/mcp";
204
205
  import { ENGINE_CREDENTIAL_FILES, ENGINE_ENV_CREDENTIAL_KEYS } from "../src/cloud-credentials";
205
206
  import { clearTaskGatewayAcl, setupMcpTaskConfig } from "./mcp-gateway";
206
207
  import {
@@ -389,6 +390,9 @@ interface Channel {
389
390
  sharedFiles: string;
390
391
  /** ADR-057: the owner's MCP connections exposed through the host gateway. */
391
392
  mcpConnections: Array<{ id: string; slug: string }>;
393
+ /** Last normalized membership published by the cloud composer. Non-null is
394
+ * sticky: never fall back to the independently delivered channel snapshot. */
395
+ mcpLeaseConnections: Array<{ id: string; slug: string }> | null;
392
396
  /** Last connection set written into the container (skip repeat execs —
393
397
  * ensure runs on every message). Live sessions read MCP config at spawn,
394
398
  * so a mid-task write takes effect on the next (re)spawn. */
@@ -479,6 +483,31 @@ interface Channel {
479
483
  leasedLaunches: Map<string, LeasedLaunchSlot>;
480
484
  }
481
485
 
486
+ /** MCP configs expose only a slug routed to a credential id. Manifest order
487
+ * is not observable, so normalize it before deciding to write or recycle. */
488
+ function normalizeMcpConnections(
489
+ connections: readonly { id: string; slug: string }[],
490
+ ): Array<{ id: string; slug: string }> {
491
+ return connections
492
+ .map(({ id, slug }) => ({ id, slug }))
493
+ .sort(
494
+ (left, right) =>
495
+ left.slug.localeCompare(right.slug) || left.id.localeCompare(right.id),
496
+ );
497
+ }
498
+
499
+ /** Credential ids select gateway material but never enter an agent config:
500
+ * the stable task URL exposes only the named slug. */
501
+ function mcpAgentMembershipFingerprint(
502
+ connections: readonly { id: string; slug: string }[],
503
+ ): string {
504
+ return JSON.stringify(
505
+ connections
506
+ .map(({ slug }) => slug)
507
+ .sort((left, right) => left.localeCompare(right)),
508
+ );
509
+ }
510
+
482
511
  interface AcceptedPrompt {
483
512
  text: string;
484
513
  }
@@ -753,6 +782,44 @@ export class Orchestrator {
753
782
  return () => this.hostSubscribers.delete(fn);
754
783
  }
755
784
 
785
+ /**
786
+ * The cloud credential composer changed the MCP view it serves for a task.
787
+ * Its committed lease view, not the independently delivered channel spec,
788
+ * is authoritative once this fires. Re-publish config after an initial
789
+ * start already under way, then recycle idle sessions so long-lived MCP
790
+ * clients reload a changed connection set. Busy sessions recycle at their
791
+ * ordinary turn boundary through the existing stale-session path.
792
+ */
793
+ mcpServedChanged(taskId: string): void {
794
+ const channel = this.channels.get(taskId);
795
+ if (!channel || !this.isActiveChannel(channel)) return;
796
+ const next = normalizeMcpConnections(leasedMcpAclEntries(taskId));
797
+ const unchanged = channel.mcpLeaseConnections !== null &&
798
+ mcpAgentMembershipFingerprint(channel.mcpLeaseConnections) ===
799
+ mcpAgentMembershipFingerprint(next);
800
+ // Retain the latest ids even when the agent-visible names did not move;
801
+ // they are useful if a later engine-roster change writes the whole ACL.
802
+ channel.mcpLeaseConnections = next;
803
+ if (unchanged) return;
804
+ channel.mcpConfigDirtyEpoch += 1;
805
+ const starting = channel.sessionsReady;
806
+ if (!starting) return;
807
+ void starting
808
+ .then(async () => {
809
+ if (!this.isActiveChannel(channel)) return;
810
+ await this.ensureMcpConfig(channel);
811
+ if (!this.isActiveChannel(channel)) return;
812
+ await this.reconcileIfStarted(channel);
813
+ })
814
+ .catch((err: unknown) => {
815
+ console.warn(
816
+ `[orchestrator] ${taskId}: rewriting leased MCP config failed: ${
817
+ err instanceof Error ? err.message : String(err)
818
+ }`,
819
+ );
820
+ });
821
+ }
822
+
756
823
  /** Run one task lifecycle mutation in arrival order. Different task ids
757
824
  * remain fully concurrent. The queue tail never rejects, so one failed
758
825
  * command cannot poison later recovery. */
@@ -846,7 +913,10 @@ export class Orchestrator {
846
913
  const browserWasEnabled = channel.browserTesting;
847
914
  const previousRoster = channel.roster;
848
915
  const previousMcpShape = JSON.stringify({
849
- connections: channel.mcpConnections,
916
+ connections:
917
+ channel.mcpLeaseConnections === null
918
+ ? normalizeMcpConnections(channel.mcpConnections)
919
+ : mcpAgentMembershipFingerprint(channel.mcpLeaseConnections),
850
920
  kinds: [...new Set(previousRoster.map((agent) => agent.kind))].sort(),
851
921
  });
852
922
  const previousById = new Map(previousRoster.map((agent) => [agent.id, agent]));
@@ -947,7 +1017,10 @@ export class Orchestrator {
947
1017
  channel.mcpConnections = spec.mcpConnections ?? [];
948
1018
  if (spec.engineAccountScope) channel.engineAccountScope = spec.engineAccountScope;
949
1019
  const nextMcpShape = JSON.stringify({
950
- connections: channel.mcpConnections,
1020
+ connections:
1021
+ channel.mcpLeaseConnections === null
1022
+ ? normalizeMcpConnections(channel.mcpConnections)
1023
+ : mcpAgentMembershipFingerprint(channel.mcpLeaseConnections),
951
1024
  kinds: [...new Set(channel.roster.map((agent) => agent.kind))].sort(),
952
1025
  });
953
1026
  if (nextMcpShape !== previousMcpShape) {
@@ -1266,6 +1339,7 @@ export class Orchestrator {
1266
1339
  browserTesting: spec.browserTesting === true,
1267
1340
  sharedFiles: spec.sharedFiles ?? "ro",
1268
1341
  mcpConnections: spec.mcpConnections ?? [],
1342
+ mcpLeaseConnections: null,
1269
1343
  mcpConfigWrite: null,
1270
1344
  mcpConfigDirtyEpoch: 1,
1271
1345
  mcpConfigAppliedEpoch: 0,
@@ -2037,10 +2111,14 @@ export class Orchestrator {
2037
2111
 
2038
2112
  private async writeMcpConfigPass(channel: Channel): Promise<void> {
2039
2113
  if (!this.isActiveChannel(channel)) return;
2040
- const connections = channel.mcpConnections.map((connection) => ({
2041
- id: connection.id,
2042
- slug: connection.slug,
2043
- }));
2114
+ let connections = channel.mcpLeaseConnections;
2115
+ if (connections === null && this.isLeasedChannel(channel)) {
2116
+ connections = normalizeMcpConnections(
2117
+ leasedMcpAclEntries(channel.taskId),
2118
+ );
2119
+ channel.mcpLeaseConnections = connections;
2120
+ }
2121
+ connections ??= normalizeMcpConnections(channel.mcpConnections);
2044
2122
  const engineKinds = [
2045
2123
  ...new Set(channel.roster.map((agent) => agent.kind)),
2046
2124
  ].sort();
@@ -2048,8 +2126,11 @@ export class Orchestrator {
2048
2126
  ? this.codexHomes(true, channel)
2049
2127
  : [];
2050
2128
  const grokHomes = this.grokHomes(engineKinds.includes("grok"), channel);
2129
+ const fingerprintConnections = channel.mcpLeaseConnections === null
2130
+ ? connections
2131
+ : connections.map(({ slug }) => ({ slug }));
2051
2132
  const fingerprint = JSON.stringify({
2052
- connections,
2133
+ connections: fingerprintConnections,
2053
2134
  engineKinds,
2054
2135
  codexHomes,
2055
2136
  ...(grokHomes.length > 0 ? { grokHomes } : {}),
@@ -2070,7 +2151,7 @@ export class Orchestrator {
2070
2151
  const hadPrevious = channel.mcpConfigFingerprint !== undefined;
2071
2152
  let previous:
2072
2153
  | {
2073
- connections: Array<{ id: string; slug: string }>;
2154
+ connections: Array<{ id?: string; slug: string }>;
2074
2155
  engineKinds: string[];
2075
2156
  codexHomes?: string[];
2076
2157
  }
@@ -2083,7 +2164,8 @@ export class Orchestrator {
2083
2164
  previous = undefined;
2084
2165
  }
2085
2166
  const connectionsChanged =
2086
- JSON.stringify(previous?.connections) !== JSON.stringify(connections);
2167
+ JSON.stringify(previous?.connections) !==
2168
+ JSON.stringify(fingerprintConnections);
2087
2169
  const addedKinds = new Set(
2088
2170
  engineKinds.filter((kind) => !previous?.engineKinds?.includes(kind)),
2089
2171
  );
@@ -6,6 +6,7 @@ import { newId } from "./ulid";
6
6
  import type { HostTask } from "../db/schema";
7
7
  import type { TaskLaunchInput, TaskUpResult } from "../src/protocol";
8
8
  import {
9
+ parseTaskEnvironmentLocator,
9
10
  serializeTaskEnvironmentLocator,
10
11
  type ProjectEnvMode,
11
12
  type TaskEnvironmentLocator,
@@ -705,6 +706,93 @@ export function hostTaskEnvMode(task: {
705
706
  throw new Error(`unsupported task project env mode: ${task.envMode}`);
706
707
  }
707
708
 
709
+ export type TaskSharedFilesMode = "off" | "ro" | "rw";
710
+
711
+ /**
712
+ * Normalize the trusted task-launch setting before it is persisted. The wire
713
+ * default has always been read-only; recovery records that decision rather
714
+ * than consulting the task-writable `.uai/files-mounted` marker.
715
+ */
716
+ export function normalizeTaskSharedFilesMode(
717
+ value: string | undefined,
718
+ ): TaskSharedFilesMode {
719
+ if (value === undefined) return "ro";
720
+ if (value === "off" || value === "ro" || value === "rw") return value;
721
+ throw new Error(`unsupported task shared-files mode: ${value}`);
722
+ }
723
+
724
+ /** Persist the normalized mode while the authoritative task launch exists. */
725
+ export function recordTaskSharedFilesMode(
726
+ taskId: string,
727
+ task: { sharedFiles?: string },
728
+ db: Db = getDb(),
729
+ ): TaskSharedFilesMode {
730
+ const mode = normalizeTaskSharedFilesMode(task.sharedFiles);
731
+ upsertHostTaskIn(db, taskId, { sharedFilesMode: mode });
732
+ return mode;
733
+ }
734
+
735
+ /**
736
+ * Read the trusted recovery authority. Null identifies a pre-Files-v1 row;
737
+ * malformed or newer values fail closed instead of becoming read-only.
738
+ */
739
+ export function hostTaskSharedFilesMode(task: {
740
+ sharedFilesMode?: string | null;
741
+ }): TaskSharedFilesMode | null {
742
+ const mode = task.sharedFilesMode;
743
+ if (mode === null || mode === undefined) return null;
744
+ if (mode === "off" || mode === "ro" || mode === "rw") return mode;
745
+ throw new Error(`unsupported persisted task shared-files mode: ${mode}`);
746
+ }
747
+
748
+ export interface TaskFilesRecoveryBinding {
749
+ locator: TaskEnvironmentLocator;
750
+ workspacePath: string;
751
+ mode: TaskSharedFilesMode;
752
+ }
753
+
754
+ /** Files may be staged before a recovered grant opens only from an exact,
755
+ * established v1 runtime row. A fresh/starting/different run has no binding;
756
+ * an exact running row with incomplete or non-self metadata is corruption and
757
+ * fails closed instead of letting root operate on a guessed workspace. */
758
+ export function taskFilesRecoveryBinding(
759
+ task: Pick<
760
+ HostTask,
761
+ | "statusMirror"
762
+ | "runEpoch"
763
+ | "environmentLocator"
764
+ | "worktreePath"
765
+ | "sharedFilesMode"
766
+ > | null,
767
+ assignment: { taskId: string; runEpoch: number },
768
+ ): TaskFilesRecoveryBinding | null {
769
+ if (
770
+ task === null ||
771
+ task.statusMirror !== "running" ||
772
+ hostTaskRunEpoch(task) !== assignment.runEpoch
773
+ ) {
774
+ return null;
775
+ }
776
+ if (task.environmentLocator === null || task.worktreePath === null) {
777
+ throw new Error("the recovered task has no complete runtime locator");
778
+ }
779
+ const mode = hostTaskSharedFilesMode(task);
780
+ if (mode === null) throw new Error("the recovered task predates Files v1");
781
+ const locator = parseTaskEnvironmentLocator(task.environmentLocator);
782
+ const value = locator.value;
783
+ if (
784
+ locator.provider !== "self" ||
785
+ typeof value !== "object" ||
786
+ value === null ||
787
+ Array.isArray(value) ||
788
+ (value as Record<string, unknown>).workspacePath !== "/workspace" ||
789
+ (value as Record<string, unknown>).taskId !== assignment.taskId
790
+ ) {
791
+ throw new Error("the recovered task does not have the canonical Files-v1 runtime");
792
+ }
793
+ return { locator, workspacePath: "/workspace", mode };
794
+ }
795
+
708
796
  export interface TaskProjectEnvSpec {
709
797
  schemaVersion: 1;
710
798
  projects: Array<{