@runuai/host 0.9.152 → 0.9.153
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/0029_task_shared_files_mode.sql +5 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +10 -0
- package/lib/agent.ts +4 -0
- package/lib/browser-testing.ts +8 -3
- package/lib/cloud-credential-leases.ts +15 -4
- package/lib/cloud-credential-runtime.ts +142 -3
- package/lib/credential-cleanup-inventory.ts +2 -0
- package/lib/credential-consumers/files.ts +934 -0
- package/lib/credential-consumers/mcp.ts +6 -1
- package/lib/mcp-gateway.ts +576 -122
- package/lib/orchestrator.ts +91 -9
- package/lib/runtime-state.ts +88 -0
- package/lib/shared-files-s3.ts +743 -3
- package/lib/sidecar-resume.ts +9 -0
- package/lib/task-credential-cleanup.ts +36 -5
- package/lib/task-environment/machine-task-up.ts +35 -19
- package/lib/task-files-machine-retirement.ts +88 -0
- package/lib/task-files-readiness.ts +127 -0
- package/package.json +1 -1
- package/src/cloud-credentials.ts +10 -0
- package/src/index.ts +38 -2
- package/src/main.ts +47 -2
package/lib/orchestrator.ts
CHANGED
|
@@ -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:
|
|
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:
|
|
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
|
-
|
|
2041
|
-
|
|
2042
|
-
|
|
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
|
|
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) !==
|
|
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
|
);
|
package/lib/runtime-state.ts
CHANGED
|
@@ -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<{
|