@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
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §11 on the runner: the MCP gateway serves a task's connections from
|
|
3
|
+
* its lease, inject-only.
|
|
4
|
+
*
|
|
5
|
+
* - The gateway serves exactly what the registry committed: the connections
|
|
6
|
+
* of the view its gate opened on (`openMcpGate`), never a grant's candidate
|
|
7
|
+
* before that, open gate or not. Closing the gate (`closeMcpGate`) stops
|
|
8
|
+
* every request under way for the task, streams and bodies still uploading
|
|
9
|
+
* included, and the gateway answers 503 until the gate opens again, never
|
|
10
|
+
* falling back to anything else.
|
|
11
|
+
* - A credential change within the run updates a connection that is
|
|
12
|
+
* otherwise the same (credential, revision, URL and kind): a refreshed
|
|
13
|
+
* token, or its material becoming readable again or no longer readable.
|
|
14
|
+
* Anything else waits for the gate.
|
|
15
|
+
* - Every request under way stays bound to the exact connection it began on:
|
|
16
|
+
* when what is served changes (a credential change, or the gate opening on
|
|
17
|
+
* a committed view), a request whose connection is no longer served as it
|
|
18
|
+
* was, readable, is stopped at once, streams included. A refreshed token of
|
|
19
|
+
* the same connection keeps it.
|
|
20
|
+
* - Every request is bound to its task's run, its connection's credential,
|
|
21
|
+
* revision and URL when it begins: a request that finds any of them
|
|
22
|
+
* changed after a refresh is refused, never sent under another run's or
|
|
23
|
+
* another account's credential to another server.
|
|
24
|
+
* - The gateway attaches the connection's header (a static key, or an OAuth
|
|
25
|
+
* access token as a bearer). It never calls a token endpoint and never sees
|
|
26
|
+
* a refresh token or a client secret: on a 401, or once the token's expiry
|
|
27
|
+
* has passed, it asks the registry for a reactive refresh (§7) and tries
|
|
28
|
+
* once more only when that installed a newer token.
|
|
29
|
+
* - A cleanup closes the task's gate, waits for every request under way to
|
|
30
|
+
* end, and drops what it names.
|
|
31
|
+
*/
|
|
32
|
+
|
|
33
|
+
import type {
|
|
34
|
+
CredentialCleanupRequest,
|
|
35
|
+
CredentialInstallRequest,
|
|
36
|
+
TaskAssignment,
|
|
37
|
+
TaskCredentialLeaseRegistry,
|
|
38
|
+
TaskCredentialView,
|
|
39
|
+
TaskMcpConnection,
|
|
40
|
+
} from "../cloud-credential-leases";
|
|
41
|
+
|
|
42
|
+
/** The registry's typed refresh answer (`credential.refresh.result`). */
|
|
43
|
+
type RefreshResult = Awaited<ReturnType<TaskCredentialLeaseRegistry["requestCredentialRefresh"]>>;
|
|
44
|
+
|
|
45
|
+
export interface LeasedMcpConnection {
|
|
46
|
+
credentialId: string;
|
|
47
|
+
slug: string;
|
|
48
|
+
url: string;
|
|
49
|
+
authKind: "none" | "token" | "oauth";
|
|
50
|
+
revision: string | null;
|
|
51
|
+
/** What the gateway attaches; null for a connection that needs nothing. */
|
|
52
|
+
header: { name: string; value: string } | null;
|
|
53
|
+
/** An OAuth access token's expiry, when known. */
|
|
54
|
+
expiresAt: number | null;
|
|
55
|
+
/** The lease parameter's version this material was read at. */
|
|
56
|
+
version: number | null;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
/**
|
|
60
|
+
* A connection the manifest names but whose material could not be read is
|
|
61
|
+
* kept as unavailable, under the identity it was named with: the gateway
|
|
62
|
+
* answers 503 for it, never "no such connection", and never calls it
|
|
63
|
+
* unauthenticated.
|
|
64
|
+
*/
|
|
65
|
+
export type ServedMcpConnection =
|
|
66
|
+
| LeasedMcpConnection
|
|
67
|
+
| {
|
|
68
|
+
slug: string;
|
|
69
|
+
credentialId: string;
|
|
70
|
+
url: string;
|
|
71
|
+
authKind: "token" | "oauth";
|
|
72
|
+
revision: string | null;
|
|
73
|
+
unavailable: true;
|
|
74
|
+
};
|
|
75
|
+
|
|
76
|
+
export interface TaskMcpLease {
|
|
77
|
+
assignment: TaskAssignment;
|
|
78
|
+
/** False while the registry's gate is closed: serve nothing. */
|
|
79
|
+
open: boolean;
|
|
80
|
+
connections: ReadonlyMap<string, ServedMcpConnection>;
|
|
81
|
+
}
|
|
82
|
+
|
|
83
|
+
/** The registry's answer to a reactive refresh (the frozen contract). */
|
|
84
|
+
export type McpCredentialRefresher = (
|
|
85
|
+
assignment: TaskAssignment,
|
|
86
|
+
credentialId: string,
|
|
87
|
+
revision: string,
|
|
88
|
+
) => Promise<Pick<RefreshResult, "outcome" | "version" | "expiresAt">>;
|
|
89
|
+
|
|
90
|
+
interface TaskMcpState {
|
|
91
|
+
assignment: TaskAssignment;
|
|
92
|
+
/** Null while the gate is closed. */
|
|
93
|
+
served: Map<string, ServedMcpConnection> | null;
|
|
94
|
+
/** Requests under way, each with the connection it began on: aborted when
|
|
95
|
+
* the gate closes, or once that connection is no longer served as it
|
|
96
|
+
* was, readable. */
|
|
97
|
+
requests: Map<AbortController, LeasedMcpConnection>;
|
|
98
|
+
drained: Array<() => void>;
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const tasks = new Map<string, TaskMcpState>();
|
|
102
|
+
let refresher: McpCredentialRefresher | null = null;
|
|
103
|
+
|
|
104
|
+
function connectionOf(connection: TaskMcpConnection): ServedMcpConnection {
|
|
105
|
+
const { manifest, material } = connection;
|
|
106
|
+
if (manifest.authKind === "none") {
|
|
107
|
+
return {
|
|
108
|
+
credentialId: manifest.credentialId,
|
|
109
|
+
slug: manifest.slug,
|
|
110
|
+
url: manifest.url,
|
|
111
|
+
authKind: "none",
|
|
112
|
+
revision: null,
|
|
113
|
+
header: null,
|
|
114
|
+
expiresAt: null,
|
|
115
|
+
version: null,
|
|
116
|
+
};
|
|
117
|
+
}
|
|
118
|
+
if (connection.availability !== "available" || !material) {
|
|
119
|
+
return {
|
|
120
|
+
slug: manifest.slug,
|
|
121
|
+
credentialId: manifest.credentialId,
|
|
122
|
+
url: manifest.url,
|
|
123
|
+
authKind: manifest.authKind,
|
|
124
|
+
revision: manifest.revision,
|
|
125
|
+
unavailable: true,
|
|
126
|
+
};
|
|
127
|
+
}
|
|
128
|
+
return {
|
|
129
|
+
credentialId: manifest.credentialId,
|
|
130
|
+
slug: manifest.slug,
|
|
131
|
+
url: manifest.url,
|
|
132
|
+
authKind: manifest.authKind,
|
|
133
|
+
revision: manifest.revision,
|
|
134
|
+
header:
|
|
135
|
+
material.authKind === "token"
|
|
136
|
+
? { name: material.header.name, value: material.header.value }
|
|
137
|
+
: { name: "Authorization", value: `Bearer ${material.accessToken}` },
|
|
138
|
+
expiresAt: material.authKind === "oauth" ? material.expiresAt : null,
|
|
139
|
+
version: connection.observedVersion,
|
|
140
|
+
};
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
function connectionsOf(view: TaskCredentialView): Map<string, ServedMcpConnection> {
|
|
144
|
+
return new Map(view.mcp.map((connection) => [connection.manifest.slug, connectionOf(connection)]));
|
|
145
|
+
}
|
|
146
|
+
|
|
147
|
+
function isLeased(connection: ServedMcpConnection | undefined): connection is LeasedMcpConnection {
|
|
148
|
+
return connection !== undefined && !("unavailable" in connection);
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
/** The same connection, all but its material (or whether it could be read):
|
|
152
|
+
* a credential change. */
|
|
153
|
+
function sameConnection(a: ServedMcpConnection, b: ServedMcpConnection): boolean {
|
|
154
|
+
return a.credentialId === b.credentialId && a.revision === b.revision && a.url === b.url && a.authKind === b.authKind;
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
function stateFor(assignment: TaskAssignment): TaskMcpState {
|
|
158
|
+
const current = tasks.get(assignment.taskId);
|
|
159
|
+
if (current && sameAssignment(current.assignment, assignment)) return current;
|
|
160
|
+
// Another run: whatever the last one had is closed and stopped first.
|
|
161
|
+
if (current) stopRequests(current);
|
|
162
|
+
const fresh: TaskMcpState = { assignment: { ...assignment }, served: null, requests: new Map(), drained: [] };
|
|
163
|
+
tasks.set(assignment.taskId, fresh);
|
|
164
|
+
return fresh;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* A grant's install is a candidate: nothing of it is served until the
|
|
169
|
+
* registry commits it and opens its gate on it (`openMcpGate`), even with the
|
|
170
|
+
* gate open on the run's previous grant. A credential change of this run
|
|
171
|
+
* takes effect now, on a connection already served that is otherwise the
|
|
172
|
+
* same (credential, revision, URL, kind): a refreshed token, or its material
|
|
173
|
+
* becoming readable again, or no longer readable, which stops the requests
|
|
174
|
+
* under way on it.
|
|
175
|
+
*/
|
|
176
|
+
export function installMcpConnections(request: CredentialInstallRequest): void {
|
|
177
|
+
const current = tasks.get(request.assignment.taskId);
|
|
178
|
+
// An install for an older run, or a change for another run than the one
|
|
179
|
+
// served, changes nothing.
|
|
180
|
+
if (current && current.assignment.runEpoch > request.assignment.runEpoch) return;
|
|
181
|
+
if (request.reason === "credential-change" && current && !sameAssignment(current.assignment, request.assignment)) {
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
// On cloud credentials from its first install, gate closed until opened:
|
|
185
|
+
// never the runner's own store.
|
|
186
|
+
const state = stateFor(request.assignment);
|
|
187
|
+
if (request.reason !== "credential-change" || !state.served) return;
|
|
188
|
+
const next = connectionsOf(request.next);
|
|
189
|
+
for (const [slug, served] of state.served) {
|
|
190
|
+
const proposed = next.get(slug);
|
|
191
|
+
if (!proposed) {
|
|
192
|
+
// Gone from the view: never served past it, gate or not.
|
|
193
|
+
state.served.delete(slug);
|
|
194
|
+
} else if (sameConnection(served, proposed)) {
|
|
195
|
+
state.served.set(slug, proposed);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
stopUnserved(state);
|
|
199
|
+
}
|
|
200
|
+
|
|
201
|
+
/** The registry opened its gate on this committed view: serve exactly it,
|
|
202
|
+
* and stop what is under way on a connection it no longer serves as it
|
|
203
|
+
* was. An older run's opening changes nothing. */
|
|
204
|
+
export function openMcpGate(assignment: TaskAssignment, view: TaskCredentialView): void {
|
|
205
|
+
const current = tasks.get(assignment.taskId);
|
|
206
|
+
if (current && current.assignment.runEpoch > assignment.runEpoch) return;
|
|
207
|
+
const state = stateFor(assignment);
|
|
208
|
+
state.served = connectionsOf(view);
|
|
209
|
+
stopUnserved(state);
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
/** The registry closed its gate: serve nothing, and stop what is under way. */
|
|
213
|
+
export function closeMcpGate(assignment: TaskAssignment): void {
|
|
214
|
+
const state = tasks.get(assignment.taskId);
|
|
215
|
+
if (!state || !sameAssignment(state.assignment, assignment)) return;
|
|
216
|
+
state.served = null;
|
|
217
|
+
stopRequests(state);
|
|
218
|
+
}
|
|
219
|
+
|
|
220
|
+
function stopRequests(state: TaskMcpState): void {
|
|
221
|
+
for (const controller of state.requests.keys()) controller.abort();
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/** Stop every request under way whose connection the task no longer serves
|
|
225
|
+
* as it began, readable: gone, unreadable, or another credential, revision,
|
|
226
|
+
* URL or kind. A refreshed token of the same connection keeps it. */
|
|
227
|
+
function stopUnserved(state: TaskMcpState): void {
|
|
228
|
+
for (const [controller, began] of state.requests) {
|
|
229
|
+
const now = state.served?.get(began.slug);
|
|
230
|
+
if (!isLeased(now) || !sameConnection(now, began)) controller.abort();
|
|
231
|
+
}
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
/** The promise's outcome, or a rejection once `signal` aborts first. */
|
|
235
|
+
function untilAborted<T>(promise: Promise<T>, signal: AbortSignal): Promise<T> {
|
|
236
|
+
if (signal.aborted) return Promise.reject(new Error("aborted"));
|
|
237
|
+
return new Promise<T>((resolve, reject) => {
|
|
238
|
+
const onAbort = (): void => reject(new Error("aborted"));
|
|
239
|
+
signal.addEventListener("abort", onAbort, { once: true });
|
|
240
|
+
promise.then(
|
|
241
|
+
(value) => {
|
|
242
|
+
signal.removeEventListener("abort", onAbort);
|
|
243
|
+
resolve(value);
|
|
244
|
+
},
|
|
245
|
+
(error: unknown) => {
|
|
246
|
+
signal.removeEventListener("abort", onAbort);
|
|
247
|
+
reject(error);
|
|
248
|
+
},
|
|
249
|
+
);
|
|
250
|
+
});
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
/** A cleanup: the gate closes, every request under way ends, and the task's
|
|
254
|
+
* record stays, closed (a full cleanup) until a new grant opens it. */
|
|
255
|
+
export async function cleanupMcpConnections(request: CredentialCleanupRequest): Promise<void> {
|
|
256
|
+
const state = tasks.get(request.assignment.taskId);
|
|
257
|
+
if (!state || !sameAssignment(state.assignment, request.assignment)) {
|
|
258
|
+
if (request.mode === "full") stateFor(request.assignment);
|
|
259
|
+
return;
|
|
260
|
+
}
|
|
261
|
+
state.served = null;
|
|
262
|
+
stopRequests(state);
|
|
263
|
+
if (state.requests.size > 0) await new Promise<void>((resolve) => state.drained.push(resolve));
|
|
264
|
+
}
|
|
265
|
+
|
|
266
|
+
/** The task is gone (teardown): forget its record entirely. */
|
|
267
|
+
export function forgetLeasedMcp(taskId: string): void {
|
|
268
|
+
const state = tasks.get(taskId);
|
|
269
|
+
if (state) stopRequests(state);
|
|
270
|
+
tasks.delete(taskId);
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
/** The task's leased MCP connections, or undefined for a task that is not on
|
|
274
|
+
* cloud credentials. */
|
|
275
|
+
export function leasedMcpFor(taskId: string): TaskMcpLease | undefined {
|
|
276
|
+
const state = tasks.get(taskId);
|
|
277
|
+
if (!state) return undefined;
|
|
278
|
+
return { assignment: { ...state.assignment }, open: state.served !== null, connections: state.served ?? new Map() };
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/** The (id, slug) pairs the task's gateway ACL and MCP configs list. */
|
|
282
|
+
export function leasedMcpAclEntries(taskId: string): Array<{ id: string; slug: string }> {
|
|
283
|
+
const served = tasks.get(taskId)?.served;
|
|
284
|
+
if (!served) return [];
|
|
285
|
+
return [...served.values()].filter(isLeased).map((connection) => ({ id: connection.credentialId, slug: connection.slug }));
|
|
286
|
+
}
|
|
287
|
+
|
|
288
|
+
export function setMcpCredentialRefresher(next: McpCredentialRefresher | null): void {
|
|
289
|
+
refresher = next;
|
|
290
|
+
}
|
|
291
|
+
|
|
292
|
+
/**
|
|
293
|
+
* One gateway request's hold on its task: bound to the run and the
|
|
294
|
+
* connection it began with, aborted when the gate closes, counted until it
|
|
295
|
+
* ends (a cleanup waits for it).
|
|
296
|
+
*/
|
|
297
|
+
export interface McpRequestHold {
|
|
298
|
+
signal: AbortSignal;
|
|
299
|
+
/** The connection this request began with. */
|
|
300
|
+
connection: LeasedMcpConnection;
|
|
301
|
+
/** The same connection as now served, with whatever material it has now;
|
|
302
|
+
* null when it changed (another run, credential, revision or URL), the
|
|
303
|
+
* gate closed, or the request ended. */
|
|
304
|
+
current(): LeasedMcpConnection | null;
|
|
305
|
+
/** Ask for a newer token after `used` failed: the connection as served
|
|
306
|
+
* then, only when it carries a newer token than `used` that is not past
|
|
307
|
+
* its expiry; null otherwise (busy, unavailable, needs a new sign-in,
|
|
308
|
+
* expired, or the connection moved). */
|
|
309
|
+
refresh(used: LeasedMcpConnection): Promise<LeasedMcpConnection | null>;
|
|
310
|
+
end(): void;
|
|
311
|
+
}
|
|
312
|
+
|
|
313
|
+
/** Hold a served connection for one request; null when the gate is closed
|
|
314
|
+
* or the task serves no such leased connection now. */
|
|
315
|
+
export function holdLeasedMcp(taskId: string, slug: string): McpRequestHold | null {
|
|
316
|
+
const state = tasks.get(taskId);
|
|
317
|
+
const connection = state?.served?.get(slug);
|
|
318
|
+
if (!state || !isLeased(connection)) return null;
|
|
319
|
+
const controller = new AbortController();
|
|
320
|
+
state.requests.set(controller, connection);
|
|
321
|
+
const assignment = { ...state.assignment };
|
|
322
|
+
let ended = false;
|
|
323
|
+
const current = (): LeasedMcpConnection | null => {
|
|
324
|
+
const live = tasks.get(taskId);
|
|
325
|
+
if (ended || controller.signal.aborted || live !== state || !sameAssignment(live.assignment, assignment)) return null;
|
|
326
|
+
const now = live.served?.get(slug);
|
|
327
|
+
return isLeased(now) && sameConnection(now, connection) ? now : null;
|
|
328
|
+
};
|
|
329
|
+
return {
|
|
330
|
+
signal: controller.signal,
|
|
331
|
+
connection,
|
|
332
|
+
current,
|
|
333
|
+
async refresh(used) {
|
|
334
|
+
if (!refresher || used.authKind !== "oauth" || used.revision === null) return null;
|
|
335
|
+
let outcome: Awaited<ReturnType<McpCredentialRefresher>>["outcome"];
|
|
336
|
+
try {
|
|
337
|
+
// A gate that closes meanwhile ends the wait: the registry's refresh
|
|
338
|
+
// carries on for the next request, this one does not hold a cleanup.
|
|
339
|
+
({ outcome } = await untilAborted(refresher(assignment, used.credentialId, used.revision), controller.signal));
|
|
340
|
+
} catch {
|
|
341
|
+
return null;
|
|
342
|
+
}
|
|
343
|
+
if (outcome !== "refreshed" && outcome !== "fresh") return null;
|
|
344
|
+
const now = current();
|
|
345
|
+
// Only a newer token is worth sending again, and never one already
|
|
346
|
+
// past its expiry.
|
|
347
|
+
if (!now || (now.version ?? 0) <= (used.version ?? 0)) return null;
|
|
348
|
+
if (now.authKind === "oauth" && now.expiresAt !== null && now.expiresAt <= Date.now()) return null;
|
|
349
|
+
return now;
|
|
350
|
+
},
|
|
351
|
+
end() {
|
|
352
|
+
if (ended) return;
|
|
353
|
+
ended = true;
|
|
354
|
+
state.requests.delete(controller);
|
|
355
|
+
if (state.requests.size === 0) for (const resolve of state.drained.splice(0)) resolve();
|
|
356
|
+
},
|
|
357
|
+
};
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
/** Process shutdown: every request under way, for any task, ended. A closed
|
|
361
|
+
* gate has aborted them; each ends as its request does. */
|
|
362
|
+
export function drainLeasedMcpRequests(): Promise<void> {
|
|
363
|
+
return Promise.all(
|
|
364
|
+
[...tasks.values()].map((state) =>
|
|
365
|
+
state.requests.size === 0 ? undefined : new Promise<void>((resolve) => state.drained.push(resolve)),
|
|
366
|
+
),
|
|
367
|
+
).then(() => undefined);
|
|
368
|
+
}
|
|
369
|
+
|
|
370
|
+
/** Tests only. */
|
|
371
|
+
export function resetLeasedMcpForTests(): void {
|
|
372
|
+
for (const state of tasks.values()) stopRequests(state);
|
|
373
|
+
tasks.clear();
|
|
374
|
+
refresher = null;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
function sameAssignment(a: TaskAssignment, b: TaskAssignment): boolean {
|
|
378
|
+
return a.taskId === b.taskId && a.runEpoch === b.runEpoch;
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
/** Tests only: requests under way for a task. */
|
|
382
|
+
export function leasedMcpRequestsForTests(taskId: string): number {
|
|
383
|
+
return tasks.get(taskId)?.requests.size ?? 0;
|
|
384
|
+
}
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-154 §6: how a credential file gets into a task. Shared by the
|
|
3
|
+
* consumers that write one (GitHub and SSH, engine accounts).
|
|
4
|
+
*
|
|
5
|
+
* - Only by streaming the content over an exec's stdin, never staged on the
|
|
6
|
+
* runner's disk, in a clean environment (`env -i`, absolute paths, the
|
|
7
|
+
* sanitized overlay for anything a provider adds before it).
|
|
8
|
+
* - Under the task's home only, through the no-follow writer: each directory
|
|
9
|
+
* opened from its parent's descriptor, the content written to an
|
|
10
|
+
* intermediate created exclusively and renamed into place, so the file
|
|
11
|
+
* never lands anywhere the cleanup inventory does not name.
|
|
12
|
+
* - Each exec is admitted at the provider's last moment by the caller's
|
|
13
|
+
* `admit`, against the task's live state.
|
|
14
|
+
* - A failure is a fixed message: nothing a subprocess printed rides it.
|
|
15
|
+
*/
|
|
16
|
+
|
|
17
|
+
import { CLEAN_CONTAINER_ENV, SANITIZED_CONTAINER_ENV } from "../github-tokens";
|
|
18
|
+
import { RUNTIME_AUTHORITY_ENV } from "../runtime-authority";
|
|
19
|
+
import type { TaskEnvironmentHandle } from "../task-environment/types";
|
|
20
|
+
|
|
21
|
+
export const TASK_HOME = "/home/node";
|
|
22
|
+
/** Where a file is written before it is renamed into place. */
|
|
23
|
+
export const STAGED_SUFFIX = ".uai-tmp";
|
|
24
|
+
|
|
25
|
+
const EXEC_TIMEOUT_MS = 30_000;
|
|
26
|
+
const MAX_OUTPUT_BYTES = 64 * 1024;
|
|
27
|
+
|
|
28
|
+
export type TaskWriteEnvironment = Pick<TaskEnvironmentHandle, "exec" | "descriptor">;
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Run `argv` in the task with nothing inherited (`env -i`, absolute paths,
|
|
32
|
+
* the sanitized overlay for anything a provider adds before it), admitted
|
|
33
|
+
* at the provider's last moment by `admit`. A failure is a fixed message:
|
|
34
|
+
* nothing a subprocess printed rides the error.
|
|
35
|
+
*/
|
|
36
|
+
export async function runInTask(
|
|
37
|
+
environment: TaskWriteEnvironment,
|
|
38
|
+
argv: readonly [string, ...string[]],
|
|
39
|
+
stdin: string | null,
|
|
40
|
+
what: string,
|
|
41
|
+
admit: () => void,
|
|
42
|
+
user: "node" | "root" = "node",
|
|
43
|
+
): Promise<Uint8Array> {
|
|
44
|
+
const [first, ...rest] = [...CLEAN_CONTAINER_ENV, ...argv];
|
|
45
|
+
const result = await environment.exec({
|
|
46
|
+
argv: [first!, ...rest],
|
|
47
|
+
user,
|
|
48
|
+
env: { ...SANITIZED_CONTAINER_ENV, ...RUNTIME_AUTHORITY_ENV },
|
|
49
|
+
projectEnvProfile: "maintenance",
|
|
50
|
+
...(stdin !== null ? { stdin: Buffer.from(stdin, "utf8") } : {}),
|
|
51
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
52
|
+
maxOutputBytes: MAX_OUTPUT_BYTES,
|
|
53
|
+
admit,
|
|
54
|
+
});
|
|
55
|
+
if (result.exitCode !== 0 || result.stdoutTruncated) {
|
|
56
|
+
throw new Error(`${what} failed in the task (exit ${result.exitCode ?? result.signal ?? "unknown"})`);
|
|
57
|
+
}
|
|
58
|
+
return result.stdout;
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Write stdin to one file under the task's home without following anything
|
|
63
|
+
* the task could have put in the way: each directory is opened from its
|
|
64
|
+
* parent's descriptor with O_NOFOLLOW (created owner-only when missing), the
|
|
65
|
+
* content goes to `<name>.uai-tmp` created exclusively, and a rename relative
|
|
66
|
+
* to that same directory puts it in place. The rename replaces whatever entry
|
|
67
|
+
* is there, a symlink included, and fails on a directory: the file never
|
|
68
|
+
* lands anywhere the inventory does not name.
|
|
69
|
+
*/
|
|
70
|
+
export const TASK_FILE_WRITE_HELPER = String.raw`
|
|
71
|
+
import os
|
|
72
|
+
import sys
|
|
73
|
+
|
|
74
|
+
root, relative, mode = sys.argv[1], sys.argv[2], int(sys.argv[3], 8)
|
|
75
|
+
parts = relative.split("/")
|
|
76
|
+
if any(part in ("", ".", "..") for part in parts):
|
|
77
|
+
raise SystemExit(2)
|
|
78
|
+
data = sys.stdin.buffer.read()
|
|
79
|
+
directory = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
80
|
+
fd = os.open(root, directory)
|
|
81
|
+
try:
|
|
82
|
+
for part in parts[:-1]:
|
|
83
|
+
try:
|
|
84
|
+
os.mkdir(part, 0o700, dir_fd=fd)
|
|
85
|
+
except FileExistsError:
|
|
86
|
+
pass
|
|
87
|
+
child = os.open(part, directory, dir_fd=fd)
|
|
88
|
+
os.close(fd)
|
|
89
|
+
fd = child
|
|
90
|
+
name = parts[-1]
|
|
91
|
+
staged = name + ".uai-tmp"
|
|
92
|
+
try:
|
|
93
|
+
os.unlink(staged, dir_fd=fd)
|
|
94
|
+
except FileNotFoundError:
|
|
95
|
+
pass
|
|
96
|
+
out = os.open(staged, os.O_WRONLY | os.O_CREAT | os.O_EXCL | os.O_NOFOLLOW | os.O_CLOEXEC, 0o600, dir_fd=fd)
|
|
97
|
+
try:
|
|
98
|
+
os.fchmod(out, mode)
|
|
99
|
+
view = memoryview(data)
|
|
100
|
+
while view:
|
|
101
|
+
view = view[os.write(out, view):]
|
|
102
|
+
os.fsync(out)
|
|
103
|
+
finally:
|
|
104
|
+
os.close(out)
|
|
105
|
+
os.rename(staged, name, src_dir_fd=fd, dst_dir_fd=fd)
|
|
106
|
+
finally:
|
|
107
|
+
os.close(fd)
|
|
108
|
+
`;
|
|
109
|
+
|
|
110
|
+
/** The exec that writes `path` from stdin (`TASK_FILE_WRITE_HELPER`). */
|
|
111
|
+
export function writeTaskFileArgv(path: string, mode: "600" | "644"): [string, ...string[]] {
|
|
112
|
+
if (!path.startsWith(`${TASK_HOME}/`)) throw new Error("a task credential path is outside the task home");
|
|
113
|
+
return ["/usr/bin/python3", "-I", "-c", TASK_FILE_WRITE_HELPER, TASK_HOME, path.slice(TASK_HOME.length + 1), mode];
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** A credential is written only into the runtime of the task it belongs to:
|
|
117
|
+
* the cleanup that must delete it refuses any other task's runtime. */
|
|
118
|
+
export function assertTaskRuntime(environment: TaskWriteEnvironment, taskId: string): void {
|
|
119
|
+
if (environment.descriptor.taskId !== taskId) {
|
|
120
|
+
throw new Error("a task credential would be written into another task's runtime");
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** A path and the intermediate it is written through. */
|
|
125
|
+
export function withStaged(...paths: string[]): string[] {
|
|
126
|
+
return paths.flatMap((path) => [path, `${path}${STAGED_SUFFIX}`]);
|
|
127
|
+
}
|
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
* refresher. Owned hosts never run this (no store).
|
|
10
10
|
*/
|
|
11
11
|
|
|
12
|
+
import { onOwnCredentials } from "./cloud-credential-host";
|
|
12
13
|
import { configuredCredentialStore, credentialStorePrefix } from "./credential-store";
|
|
13
14
|
import { engineAccountListPaths } from "./credential-store-scope";
|
|
14
15
|
import {
|
|
@@ -101,15 +102,18 @@ export async function pickupChangedCredentials(): Promise<PickupSummary> {
|
|
|
101
102
|
|
|
102
103
|
async function pickupSerialized(): Promise<PickupSummary> {
|
|
103
104
|
if (inflight) return inflight;
|
|
105
|
+
// ADR-154: no copy picked up once the runner leaves its own credentials.
|
|
106
|
+
if (!onOwnCredentials()) return { skipped: true, listed: 0, changed: 0, applied: 0, errors: 0 };
|
|
104
107
|
inflight = pickupChangedCredentials().finally(() => {
|
|
105
108
|
inflight = null;
|
|
106
109
|
});
|
|
107
110
|
return inflight;
|
|
108
111
|
}
|
|
109
112
|
|
|
110
|
-
/** Uai-operated hosts only: baseline now, then
|
|
113
|
+
/** Uai-operated hosts on their own credentials only: baseline now, then
|
|
114
|
+
* poll every 15 minutes. */
|
|
111
115
|
export function startCredentialStorePickup(hooks: { repush: (kind: string) => Promise<void> }): void {
|
|
112
|
-
if (timer || !configuredCredentialStore()) return;
|
|
116
|
+
if (timer || !configuredCredentialStore() || !onOwnCredentials()) return;
|
|
113
117
|
repushHook = hooks.repush;
|
|
114
118
|
setPickupNow(async () => {
|
|
115
119
|
await pickupSerialized();
|
|
@@ -125,6 +129,15 @@ export function startCredentialStorePickup(hooks: { repush: (kind: string) => Pr
|
|
|
125
129
|
timer.unref?.();
|
|
126
130
|
}
|
|
127
131
|
|
|
132
|
+
/** Stop polling (ADR-154: the runner leaves its own credentials); resolves
|
|
133
|
+
* once a pickup under way has ended. */
|
|
134
|
+
export async function stopCredentialStorePickup(): Promise<void> {
|
|
135
|
+
if (timer) clearInterval(timer);
|
|
136
|
+
timer = null;
|
|
137
|
+
setPickupNow(null);
|
|
138
|
+
await inflight?.catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
|
|
128
141
|
/** Tests. */
|
|
129
142
|
export function resetCredentialStorePickup(): void {
|
|
130
143
|
if (timer) clearInterval(timer);
|