@runuai/host 0.9.148 → 0.9.149
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/0024_task_env_mode.sql +9 -0
- package/db/migrations/0025_project_env_authority.sql +147 -0
- package/db/migrations/0026_project_env_replica.sql +150 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +312 -2
- package/images/standard/Dockerfile +44 -6
- package/images/standard/container/uai-init +85 -2
- package/images/standard/container/uai-task-forwarder.mjs +303 -0
- package/lib/agent-cli.ts +18 -1
- package/lib/agent.ts +99 -34
- package/lib/agents/codex.ts +9 -1
- package/lib/agents/cursor.ts +15 -1
- package/lib/agents/grok.ts +8 -0
- package/lib/agents/kimi.ts +1 -0
- package/lib/agents/machine-durable.ts +2 -0
- package/lib/agents/opencode.ts +1 -0
- package/lib/agents/proc.ts +6 -1
- package/lib/agents/transport.ts +9 -5
- package/lib/browser-testing.ts +6 -0
- package/lib/codex-auth.ts +2 -0
- package/lib/credential-store.ts +141 -6
- package/lib/engine-accounts.ts +1 -0
- package/lib/env-file.ts +7 -0
- package/lib/env-schema.ts +841 -51
- package/lib/git-diff.ts +1 -0
- package/lib/git-identity.ts +2 -0
- package/lib/github-tokens.ts +1 -0
- package/lib/host-env.ts +98 -18
- package/lib/lease.ts +11 -3
- package/lib/machine-gateway-tunnel.ts +195 -17
- package/lib/machine-project-env-loader.ts +152 -0
- package/lib/managed-runtime.ts +270 -51
- package/lib/mcp-gateway.ts +777 -26
- package/lib/orchestrator.ts +391 -69
- package/lib/project-env-authority.ts +1319 -0
- package/lib/project-env-feature.ts +74 -0
- package/lib/project-env-loader.ts +237 -0
- package/lib/project-env-replica.ts +2094 -0
- package/lib/project-env-schema-capture.ts +342 -0
- package/lib/project-env-staging.ts +148 -0
- package/lib/project-env-task-policy.ts +176 -0
- package/lib/runtime-archive.ts +79 -16
- package/lib/runtime-authority.ts +44 -0
- package/lib/runtime-state.ts +175 -10
- package/lib/secrets-proxy/address.ts +72 -0
- package/lib/secrets-proxy/audit.ts +30 -0
- package/lib/secrets-proxy/ca-store.ts +200 -0
- package/lib/secrets-proxy/ca.ts +470 -0
- package/lib/secrets-proxy/container-steps.ts +138 -0
- package/lib/secrets-proxy/der.ts +273 -0
- package/lib/secrets-proxy/engine.ts +1633 -0
- package/lib/secrets-proxy/host.ts +93 -0
- package/lib/secrets-proxy/hostname.ts +60 -0
- package/lib/secrets-proxy/placeholders.ts +196 -0
- package/lib/secrets-proxy/policy.ts +141 -0
- package/lib/secrets-proxy/readiness.ts +148 -0
- package/lib/secrets-proxy/runtime.ts +582 -0
- package/lib/secrets-proxy/scrub.ts +559 -0
- package/lib/secrets-proxy/sidecar-view.ts +139 -0
- package/lib/secrets-proxy/task-scripts.ts +262 -0
- package/lib/secrets-proxy/task-trust.ts +574 -0
- package/lib/secrets-proxy/trust-watch.ts +80 -0
- package/lib/shared-files-s3.ts +3 -0
- package/lib/sidecar-guard.ts +58 -0
- package/lib/sidecar-resume.ts +51 -3
- package/lib/standard-image.ts +50 -12
- package/lib/task-env-forwarder.ts +228 -0
- package/lib/task-env-source.ts +193 -0
- package/lib/task-environment/apple-container.ts +45 -11
- package/lib/task-environment/docker.ts +51 -12
- package/lib/task-environment/index.ts +6 -2
- package/lib/task-environment/machine-task-up.ts +258 -25
- package/lib/task-environment/machine.ts +80 -22
- package/lib/task-environment/registry.ts +3 -2
- package/lib/task-environment/self-identity.ts +20 -0
- package/lib/task-environment/self.ts +92 -16
- package/lib/task-environment/types.ts +63 -0
- package/lib/task-gateway-port.ts +22 -0
- package/lib/task-gateway-protocol.ts +190 -0
- package/lib/task-gateway-recovery.ts +44 -0
- package/lib/task-gateway-state.ts +497 -0
- package/lib/transcript.ts +1 -0
- package/package.json +13 -1
- package/scripts/agent/env-hook.sh +626 -0
- package/scripts/agent/task-up.sh +476 -27
- package/scripts/agent/uai-env-exec.sh +26 -0
- package/src/cli.ts +26 -3
- package/src/index.ts +273 -19
- package/src/main.ts +939 -48
- package/src/project-env-latest.ts +130 -0
- package/src/project-env-policy.ts +281 -0
- package/src/project-env-wording.ts +47 -0
- package/src/protocol.ts +2219 -15
package/lib/git-diff.ts
CHANGED
|
@@ -196,6 +196,7 @@ async function runEnvironmentGitCommand(
|
|
|
196
196
|
maxOutputBytes: number,
|
|
197
197
|
): Promise<string> {
|
|
198
198
|
const result = await environment.exec({
|
|
199
|
+
projectEnvProfile: "maintenance",
|
|
199
200
|
argv: ["/usr/bin/git", ...args],
|
|
200
201
|
cwd,
|
|
201
202
|
env: { ...RUNTIME_AUTHORITY_ENV, ...NON_INTERACTIVE_GIT_ENV },
|
package/lib/git-identity.ts
CHANGED
|
@@ -73,6 +73,7 @@ async function environmentStep(
|
|
|
73
73
|
stdin?: Uint8Array,
|
|
74
74
|
): Promise<{ status: number | null; stdout: string; stderr: string }> {
|
|
75
75
|
const result = await environment.exec({
|
|
76
|
+
projectEnvProfile: "maintenance",
|
|
76
77
|
argv,
|
|
77
78
|
user: "node",
|
|
78
79
|
env: RUNTIME_AUTHORITY_ENV,
|
|
@@ -113,6 +114,7 @@ export async function setupTaskGitIdentity(
|
|
|
113
114
|
];
|
|
114
115
|
const res = environment
|
|
115
116
|
? await environment.exec({
|
|
117
|
+
projectEnvProfile: "maintenance",
|
|
116
118
|
argv: command,
|
|
117
119
|
user: "node",
|
|
118
120
|
env: RUNTIME_AUTHORITY_ENV,
|
package/lib/github-tokens.ts
CHANGED
|
@@ -1736,6 +1736,7 @@ export async function injectIntoContainer(
|
|
|
1736
1736
|
? async (command, input) => {
|
|
1737
1737
|
const [first, ...rest] = [...CLEAN_CONTAINER_ENV, ...command];
|
|
1738
1738
|
const result = await environment.exec({
|
|
1739
|
+
projectEnvProfile: "maintenance",
|
|
1739
1740
|
argv: [first!, ...rest],
|
|
1740
1741
|
user: "node",
|
|
1741
1742
|
env: { ...SANITIZED_CONTAINER_ENV, ...RUNTIME_AUTHORITY_ENV },
|
package/lib/host-env.ts
CHANGED
|
@@ -27,14 +27,75 @@ import { getDb, schema } from "./db";
|
|
|
27
27
|
import { sealAesGcm, openAesGcm } from "./secrets";
|
|
28
28
|
import { signInBoxMode } from "./signin-box";
|
|
29
29
|
|
|
30
|
+
// Process-local epochs are enough for loader snapshots: every value mutation
|
|
31
|
+
// also happens in this process, and a host restart rebuilds both the epoch map
|
|
32
|
+
// and every task resolver. Keeping the counter per project lets a task ignore
|
|
33
|
+
// unrelated projects without retaining decrypted values in a shared cache.
|
|
34
|
+
const projectEnvRevisions = new Map<string, number>();
|
|
35
|
+
|
|
36
|
+
// ADR-153: what a task's view decides moves with more than values: a
|
|
37
|
+
// binding, and the store's gate opening or closing (a value write outside
|
|
38
|
+
// the records closes it too). One process-local counter covers them all.
|
|
39
|
+
let projectEnvAuthorityRevisionCount = 0;
|
|
40
|
+
|
|
41
|
+
export function noteProjectEnvValueMutation(projectId: string): void {
|
|
42
|
+
projectEnvRevisions.set(
|
|
43
|
+
projectId,
|
|
44
|
+
(projectEnvRevisions.get(projectId) ?? 0) + 1,
|
|
45
|
+
);
|
|
46
|
+
// On an activated store, a value written outside its records (an old
|
|
47
|
+
// frame, the credential store) closes the gate by trigger, which every
|
|
48
|
+
// task's view depends on, not only this project's.
|
|
49
|
+
projectEnvAuthorityRevisionCount += 1;
|
|
50
|
+
emitProjectEnvChange();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
const projectEnvChangeListeners = new Set<() => void>();
|
|
54
|
+
|
|
55
|
+
/** Calls `listener` after any change to project env values, records,
|
|
56
|
+
* bindings or the store's gate; returns its removal. A listener that throws
|
|
57
|
+
* is skipped, never the change. */
|
|
58
|
+
export function onProjectEnvChange(listener: () => void): () => void {
|
|
59
|
+
projectEnvChangeListeners.add(listener);
|
|
60
|
+
return () => {
|
|
61
|
+
projectEnvChangeListeners.delete(listener);
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function emitProjectEnvChange(): void {
|
|
66
|
+
for (const listener of projectEnvChangeListeners) {
|
|
67
|
+
try {
|
|
68
|
+
listener();
|
|
69
|
+
} catch {
|
|
70
|
+
// A listener's failure is its own.
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** Current process-local value revision for one project's env. */
|
|
76
|
+
export function projectEnvRevision(projectId: string): number {
|
|
77
|
+
return projectEnvRevisions.get(projectId) ?? 0;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
export function noteProjectEnvAuthorityChange(): void {
|
|
82
|
+
projectEnvAuthorityRevisionCount += 1;
|
|
83
|
+
emitProjectEnvChange();
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/** Current process-local revision of the store's records and gate. */
|
|
87
|
+
export function projectEnvAuthorityRevision(): number {
|
|
88
|
+
return projectEnvAuthorityRevisionCount;
|
|
89
|
+
}
|
|
90
|
+
|
|
30
91
|
/** Pack a sealed value (ct + nonce) into the single TEXT column. */
|
|
31
|
-
function
|
|
92
|
+
export function sealProjectEnvValueForStorage(value: string): string {
|
|
32
93
|
const sealed = sealAesGcm(value);
|
|
33
94
|
return `${sealed.ct.toString("base64")}.${sealed.nonce.toString("base64")}`;
|
|
34
95
|
}
|
|
35
96
|
|
|
36
|
-
/** Reverse {@link
|
|
37
|
-
function
|
|
97
|
+
/** Reverse {@link sealProjectEnvValueForStorage}; throws if malformed. */
|
|
98
|
+
export function openProjectEnvValueFromStorage(valueEnc: string): string {
|
|
38
99
|
const dot = valueEnc.indexOf(".");
|
|
39
100
|
if (dot === -1) throw new Error("malformed value_enc");
|
|
40
101
|
const ct = Buffer.from(valueEnc.slice(0, dot), "base64");
|
|
@@ -56,15 +117,16 @@ export function setProjectEnvVar(
|
|
|
56
117
|
.values({
|
|
57
118
|
projectId,
|
|
58
119
|
key,
|
|
59
|
-
valueEnc:
|
|
120
|
+
valueEnc: sealProjectEnvValueForStorage(value),
|
|
60
121
|
updatedAt: now,
|
|
61
122
|
...(orgId ? { orgId } : {}),
|
|
62
123
|
})
|
|
63
124
|
.onConflictDoUpdate({
|
|
64
125
|
target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key],
|
|
65
|
-
set: { valueEnc:
|
|
126
|
+
set: { valueEnc: sealProjectEnvValueForStorage(value), updatedAt: now, ...(orgId ? { orgId } : {}) },
|
|
66
127
|
})
|
|
67
128
|
.run();
|
|
129
|
+
noteProjectEnvValueMutation(projectId);
|
|
68
130
|
if (orgId) noteProjectOrg(projectId, orgId, { except: key });
|
|
69
131
|
mirrorProjectEnvToStore(projectId, key);
|
|
70
132
|
}
|
|
@@ -159,8 +221,21 @@ export function deleteProjectEnvVar(projectId: string, key: string): void {
|
|
|
159
221
|
)
|
|
160
222
|
.run();
|
|
161
223
|
if (result.changes > 0) {
|
|
162
|
-
|
|
163
|
-
|
|
224
|
+
noteProjectEnvValueMutation(projectId);
|
|
225
|
+
mirrorProjectEnvDeletionToStore(projectId, key);
|
|
226
|
+
}
|
|
227
|
+
}
|
|
228
|
+
|
|
229
|
+
/** Queue only the credential-store side of a committed local deletion. */
|
|
230
|
+
export function mirrorProjectEnvDeletionToStore(
|
|
231
|
+
projectId: string,
|
|
232
|
+
key: string,
|
|
233
|
+
): void {
|
|
234
|
+
const name = projectEnvParameterName(projectId, key);
|
|
235
|
+
if (name) {
|
|
236
|
+
queueStoreWrite(`delete project env ${projectId}/${key}`, (store) =>
|
|
237
|
+
store.delete(name),
|
|
238
|
+
);
|
|
164
239
|
}
|
|
165
240
|
}
|
|
166
241
|
|
|
@@ -202,7 +277,7 @@ export function projectEnvStoreValue(projectId: string, key: string): StoredProj
|
|
|
202
277
|
kind: "project-env",
|
|
203
278
|
projectId,
|
|
204
279
|
key,
|
|
205
|
-
value:
|
|
280
|
+
value: openProjectEnvValueFromStorage(row.valueEnc),
|
|
206
281
|
updatedAt: row.updatedAt,
|
|
207
282
|
...(row.orgId ? { orgId: row.orgId } : {}),
|
|
208
283
|
};
|
|
@@ -249,22 +324,24 @@ export function importProjectEnvFromStore(value: StoredProjectEnv, storeRef: str
|
|
|
249
324
|
.values({
|
|
250
325
|
projectId: value.projectId,
|
|
251
326
|
key: value.key,
|
|
252
|
-
valueEnc:
|
|
327
|
+
valueEnc: sealProjectEnvValueForStorage(value.value),
|
|
253
328
|
updatedAt: value.updatedAt,
|
|
254
329
|
storeRef,
|
|
255
330
|
...(value.orgId ? { orgId: value.orgId } : {}),
|
|
256
331
|
})
|
|
257
332
|
.onConflictDoNothing({ target: [schema.hostProjectEnv.projectId, schema.hostProjectEnv.key] })
|
|
258
333
|
.run();
|
|
334
|
+
if (result.changes > 0) noteProjectEnvValueMutation(value.projectId);
|
|
259
335
|
return result.changes > 0;
|
|
260
336
|
}
|
|
261
337
|
|
|
262
338
|
/** Reconcile: the row's parameter vanished elsewhere; drop the row only. */
|
|
263
339
|
export function deleteProjectEnvLocal(projectId: string, key: string): void {
|
|
264
|
-
getDb()
|
|
340
|
+
const result = getDb()
|
|
265
341
|
.delete(schema.hostProjectEnv)
|
|
266
342
|
.where(and(eq(schema.hostProjectEnv.projectId, projectId), eq(schema.hostProjectEnv.key, key)))
|
|
267
343
|
.run();
|
|
344
|
+
if (result.changes > 0) noteProjectEnvValueMutation(projectId);
|
|
268
345
|
}
|
|
269
346
|
|
|
270
347
|
/** Sorted list of a project's env var KEY NAMES (never the values). */
|
|
@@ -297,7 +374,7 @@ export function getDecryptedForProject(projectId: string): Record<string, string
|
|
|
297
374
|
const out: Record<string, string> = {};
|
|
298
375
|
for (const row of rows) {
|
|
299
376
|
try {
|
|
300
|
-
out[row.key] =
|
|
377
|
+
out[row.key] = openProjectEnvValueFromStorage(row.valueEnc);
|
|
301
378
|
} catch (err) {
|
|
302
379
|
console.warn(
|
|
303
380
|
`[host-env] skipping ${projectId}/${row.key}: decrypt failed (${
|
|
@@ -322,7 +399,9 @@ const RESERVED_TASK_ENV_EXACT = new Set([
|
|
|
322
399
|
"GIT_ASKPASS", "SSH_ASKPASS", "GIT_SSH", "GIT_SSH_COMMAND",
|
|
323
400
|
"HOME", "XDG_CONFIG_HOME", "XDG_DATA_HOME", "XDG_CACHE_HOME",
|
|
324
401
|
"PATH", "LD_PRELOAD", "LD_AUDIT", "LD_LIBRARY_PATH", "NODE_OPTIONS",
|
|
325
|
-
"NODE_PATH", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
|
|
402
|
+
"NODE_PATH", "SHELL", "SHELLOPTS", "BASHOPTS", "PS4", "BASH_ENV", "ENV",
|
|
403
|
+
"NODE_EXTRA_CA_CERTS", "SSL_CERT_FILE", "REQUESTS_CA_BUNDLE",
|
|
404
|
+
"__CURSOR_SANDBOX_ENV_RESTORE", "GROK_LOGIN_ENV",
|
|
326
405
|
"CLAUDE_CODE_OAUTH_TOKEN", "ANTHROPIC_API_KEY", "ANTHROPIC_AUTH_TOKEN",
|
|
327
406
|
"XAI_API_KEY", "PLAYWRIGHT_BROWSERS_PATH",
|
|
328
407
|
]);
|
|
@@ -332,16 +411,17 @@ export function isReservedTaskEnvName(name: string): boolean {
|
|
|
332
411
|
if (RESERVED_TASK_ENV_EXACT.has(name)) return true;
|
|
333
412
|
if (name.startsWith("GIT_CONFIG") || name.startsWith("ASDF_")) return true;
|
|
334
413
|
if (name.startsWith("COREPACK_") || name.startsWith("UAI_")) return true;
|
|
414
|
+
if (name.startsWith("__UAI_ENV_")) return true;
|
|
335
415
|
return RESERVED_TASK_ENV_NPM.test(name);
|
|
336
416
|
}
|
|
337
417
|
|
|
338
418
|
/**
|
|
339
|
-
*
|
|
340
|
-
*
|
|
341
|
-
* key collision, minus the reserved names above
|
|
342
|
-
*
|
|
343
|
-
* had the Infisical identity in the store and the host, and the agent
|
|
344
|
-
* saw it, because only the compose path injected project env.
|
|
419
|
+
* Rollout-only project env for an ADR-121 machine task whose persisted mode
|
|
420
|
+
* is still `baked`: every project's decrypted values, lead project winning a
|
|
421
|
+
* key collision, minus the reserved names above. Loader tasks must use the
|
|
422
|
+
* declaration-pinned gateway instead. Live 2026-09-09: a machine task on Uai
|
|
423
|
+
* Cloud had the Infisical identity in the store and the host, and the agent
|
|
424
|
+
* never saw it, because only the compose path injected project env.
|
|
345
425
|
*/
|
|
346
426
|
export function projectEnvForTask(projectIds: readonly string[]): Record<string, string> {
|
|
347
427
|
const merged: Record<string, string> = {};
|
package/lib/lease.ts
CHANGED
|
@@ -110,13 +110,21 @@ export function leaseAwsConfig(lease: Pick<TaskLease, "region">): string {
|
|
|
110
110
|
return `[default]\nregion = ${lease.region}\ncredential_process = ${LEASE_CREDENTIAL_PROCESS}\n`;
|
|
111
111
|
}
|
|
112
112
|
|
|
113
|
-
/** The org and user a lease was issued for — the credential store paths a
|
|
113
|
+
/** The org and user a current, unexpired lease was issued for — the credential store paths a
|
|
114
114
|
* sidecar may list (`/uai/orgs/<org>`, `/uai/users/<user>`; the task-machine
|
|
115
115
|
* policy grants nothing above them). Null on an owned host or before the
|
|
116
116
|
* first grant; `orgId` is null for a lease with no org scope. Absent from
|
|
117
117
|
* the lease when the cloud predates the field. */
|
|
118
|
-
export function leaseScope(
|
|
119
|
-
|
|
118
|
+
export function leaseScope(
|
|
119
|
+
now: number = Date.now(),
|
|
120
|
+
): { orgId: string | null; userId: string } | null {
|
|
121
|
+
if (
|
|
122
|
+
!current ||
|
|
123
|
+
current.expiresAt <= now ||
|
|
124
|
+
typeof current.userId !== "string"
|
|
125
|
+
) {
|
|
126
|
+
return null;
|
|
127
|
+
}
|
|
120
128
|
return { orgId: current.orgId ?? null, userId: current.userId };
|
|
121
129
|
}
|
|
122
130
|
|
|
@@ -4,18 +4,18 @@
|
|
|
4
4
|
* Containers reach the host's MCP gateway via container topology
|
|
5
5
|
* (host.docker.internal, the vmnet gateway IP). A machine — an EC2
|
|
6
6
|
* instance behind its own network — can never route to the host, so the
|
|
7
|
-
* host pushes the gateway TO the machine instead: one persistent
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* (setupMcpTaskConfig's machine branch).
|
|
7
|
+
* host pushes the gateway TO the machine instead: one persistent reverse
|
|
8
|
+
* tunnel publishes the runner's 5877 listener as machine-loopback 5878.
|
|
9
|
+
* The task-local authenticated forwarder owns 5877 and relays there only
|
|
10
|
+
* after proving the task generation. Task configs keep pointing MCP servers
|
|
11
|
+
* at the machine's own loopback (setupMcpTaskConfig's machine branch).
|
|
12
12
|
*
|
|
13
13
|
* Supervision: the tunnel respawns with a short backoff while ensured —
|
|
14
14
|
* re-resolving the machine address each spawn, since it changes across
|
|
15
15
|
* stop/start — and stops when the task stops, tears down, or the process
|
|
16
|
-
* exits.
|
|
17
|
-
*
|
|
18
|
-
*
|
|
16
|
+
* exits. Initial provisioning can await an OpenSSH forwarding acknowledgement
|
|
17
|
+
* before starting consumers; background recovery keeps the original
|
|
18
|
+
* quiet-but-logged behavior.
|
|
19
19
|
*/
|
|
20
20
|
|
|
21
21
|
import { spawn, type ChildProcess } from "node:child_process";
|
|
@@ -23,10 +23,20 @@ import { spawn, type ChildProcess } from "node:child_process";
|
|
|
23
23
|
import { MCP_GATEWAY_PORT } from "./mcp-gateway";
|
|
24
24
|
|
|
25
25
|
const RESPAWN_DELAY_MS = 3_000;
|
|
26
|
+
/** Long enough for the provider's 10s SSH connect deadline plus remote
|
|
27
|
+
* forwarding setup, but bounded so task-up fails closed rather than starting
|
|
28
|
+
* consumers with an unusable env loader. */
|
|
29
|
+
export const MACHINE_GATEWAY_TUNNEL_READY_TIMEOUT_MS = 20_000;
|
|
30
|
+
/** Task-local 5877 belongs to the authenticated forwarder. SSH publishes the
|
|
31
|
+
* runner's real gateway one hop away so unproved traffic can never dial it. */
|
|
32
|
+
export const MACHINE_GATEWAY_TUNNEL_PORT = 5_878;
|
|
26
33
|
/** After this many consecutive sub-5s deaths, slow the respawn cadence so a
|
|
27
34
|
* permanently unreachable machine does not spin ssh forever. */
|
|
28
35
|
const FAST_DEATH_THRESHOLD = 5;
|
|
29
36
|
const SLOW_RESPAWN_DELAY_MS = 60_000;
|
|
37
|
+
const READY_MARKER = "__UAI_MACHINE_GATEWAY_TUNNEL_READY__";
|
|
38
|
+
const READY_REMOTE_COMMAND =
|
|
39
|
+
`/bin/echo '${READY_MARKER}'; while :; do sleep 3600; done`;
|
|
30
40
|
|
|
31
41
|
export interface MachineTunnelSource {
|
|
32
42
|
gatewayTunnelSshArgs(
|
|
@@ -37,36 +47,125 @@ export interface MachineTunnelSource {
|
|
|
37
47
|
|
|
38
48
|
interface TunnelState {
|
|
39
49
|
generation: number;
|
|
50
|
+
machinePort: number;
|
|
40
51
|
child: ChildProcess | null;
|
|
41
52
|
timer: ReturnType<typeof setTimeout> | null;
|
|
53
|
+
spawning: boolean;
|
|
54
|
+
ready: boolean;
|
|
55
|
+
readinessWaiters: Set<ReadinessWaiter>;
|
|
42
56
|
fastDeaths: number;
|
|
43
57
|
spawnedAt: number;
|
|
44
58
|
}
|
|
45
59
|
|
|
60
|
+
interface ReadinessWaiter {
|
|
61
|
+
resolve(): void;
|
|
62
|
+
reject(error: Error): void;
|
|
63
|
+
}
|
|
64
|
+
|
|
46
65
|
const tunnels = new Map<string, TunnelState>();
|
|
47
66
|
|
|
48
|
-
/** Idempotently keep a gateway tunnel alive for this machine task.
|
|
67
|
+
/** Idempotently keep a gateway tunnel alive for this machine task. Legacy
|
|
68
|
+
* baked tasks retain direct 5877→5877 forwarding; loader tasks explicitly
|
|
69
|
+
* request 5878 so their authenticated task-local forwarder owns 5877. */
|
|
49
70
|
export function ensureMachineGatewayTunnel(
|
|
50
71
|
taskId: string,
|
|
51
72
|
source: MachineTunnelSource,
|
|
73
|
+
machinePort = MCP_GATEWAY_PORT,
|
|
52
74
|
): void {
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
75
|
+
armMachineGatewayTunnel(taskId, source, machinePort);
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
/** Keep the tunnel supervised and wait until OpenSSH has acknowledged the
|
|
79
|
+
* remote listener. `ExitOnForwardFailure=yes` delays the remote command until
|
|
80
|
+
* every requested forwarding listener is installed; its stdout marker is
|
|
81
|
+
* therefore stronger than the local child `spawn` event. A timeout rejects
|
|
82
|
+
* (fail closed) but deliberately leaves the supervisor armed for recovery. */
|
|
83
|
+
export function ensureMachineGatewayTunnelReady(
|
|
84
|
+
taskId: string,
|
|
85
|
+
source: MachineTunnelSource,
|
|
86
|
+
machinePort = MCP_GATEWAY_PORT,
|
|
87
|
+
timeoutMs = MACHINE_GATEWAY_TUNNEL_READY_TIMEOUT_MS,
|
|
88
|
+
): Promise<void> {
|
|
89
|
+
if (!Number.isFinite(timeoutMs) || timeoutMs <= 0) {
|
|
90
|
+
return Promise.reject(
|
|
91
|
+
new Error("machine gateway tunnel readiness timeout must be positive"),
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
const state = armMachineGatewayTunnel(taskId, source, machinePort);
|
|
95
|
+
if (state.ready) return Promise.resolve();
|
|
96
|
+
|
|
97
|
+
return new Promise<void>((resolve, reject) => {
|
|
98
|
+
let settled = false;
|
|
99
|
+
let timer: ReturnType<typeof setTimeout> | null = null;
|
|
100
|
+
const waiter: ReadinessWaiter = {
|
|
101
|
+
resolve: () => {
|
|
102
|
+
if (settled) return;
|
|
103
|
+
settled = true;
|
|
104
|
+
if (timer) clearTimeout(timer);
|
|
105
|
+
state.readinessWaiters.delete(waiter);
|
|
106
|
+
resolve();
|
|
107
|
+
},
|
|
108
|
+
reject: (error) => {
|
|
109
|
+
if (settled) return;
|
|
110
|
+
settled = true;
|
|
111
|
+
if (timer) clearTimeout(timer);
|
|
112
|
+
state.readinessWaiters.delete(waiter);
|
|
113
|
+
reject(error);
|
|
114
|
+
},
|
|
115
|
+
};
|
|
116
|
+
state.readinessWaiters.add(waiter);
|
|
117
|
+
// Readiness can race the waiter registration when an already-running ssh
|
|
118
|
+
// child emits its marker in the same turn.
|
|
119
|
+
if (state.ready) {
|
|
120
|
+
waiter.resolve();
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
timer = setTimeout(() => {
|
|
124
|
+
waiter.reject(
|
|
125
|
+
new Error(
|
|
126
|
+
`machine gateway tunnel ${taskId} did not become ready within ${timeoutMs}ms`,
|
|
127
|
+
),
|
|
128
|
+
);
|
|
129
|
+
}, timeoutMs);
|
|
130
|
+
timer.unref?.();
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function armMachineGatewayTunnel(
|
|
135
|
+
taskId: string,
|
|
136
|
+
source: MachineTunnelSource,
|
|
137
|
+
machinePort: number,
|
|
138
|
+
): TunnelState {
|
|
139
|
+
let existing = tunnels.get(taskId);
|
|
140
|
+
if (
|
|
141
|
+
existing &&
|
|
142
|
+
(existing.child !== null || existing.timer !== null || existing.spawning)
|
|
143
|
+
) {
|
|
144
|
+
if (existing.machinePort === machinePort) return existing;
|
|
145
|
+
// The persisted env mode is attach-incompatible, so this should only be
|
|
146
|
+
// a recovery-order correction. Replace the wrong topology immediately.
|
|
147
|
+
stopMachineGatewayTunnel(taskId);
|
|
148
|
+
existing = undefined;
|
|
56
149
|
}
|
|
57
150
|
const state: TunnelState = existing ?? {
|
|
58
151
|
generation: 0,
|
|
152
|
+
machinePort,
|
|
59
153
|
child: null,
|
|
60
154
|
timer: null,
|
|
155
|
+
spawning: false,
|
|
156
|
+
ready: false,
|
|
157
|
+
readinessWaiters: new Set(),
|
|
61
158
|
fastDeaths: 0,
|
|
62
159
|
spawnedAt: 0,
|
|
63
160
|
};
|
|
161
|
+
state.machinePort = machinePort;
|
|
64
162
|
state.generation += 1;
|
|
65
163
|
tunnels.set(taskId, state);
|
|
66
164
|
if (existing === undefined) {
|
|
67
165
|
console.log(`[machine-tunnel] ${taskId}: ensuring gateway tunnel`);
|
|
68
166
|
}
|
|
69
167
|
void spawnTunnel(taskId, source, state, state.generation);
|
|
168
|
+
return state;
|
|
70
169
|
}
|
|
71
170
|
|
|
72
171
|
async function spawnTunnel(
|
|
@@ -76,13 +175,15 @@ async function spawnTunnel(
|
|
|
76
175
|
generation: number,
|
|
77
176
|
): Promise<void> {
|
|
78
177
|
if (tunnels.get(taskId) !== state || state.generation !== generation) return;
|
|
178
|
+
state.spawning = true;
|
|
79
179
|
let args: string[];
|
|
80
180
|
try {
|
|
81
181
|
args = await source.gatewayTunnelSshArgs(
|
|
82
|
-
|
|
182
|
+
state.machinePort,
|
|
83
183
|
MCP_GATEWAY_PORT,
|
|
84
184
|
);
|
|
85
185
|
} catch (err) {
|
|
186
|
+
state.spawning = false;
|
|
86
187
|
// Machine not reachable right now (stopped, address settling) — retry
|
|
87
188
|
// on the slow cadence; a stop/teardown clears us before it matters.
|
|
88
189
|
scheduleRespawn(taskId, source, state, generation, SLOW_RESPAWN_DELAY_MS);
|
|
@@ -94,19 +195,52 @@ async function spawnTunnel(
|
|
|
94
195
|
state.fastDeaths += 1;
|
|
95
196
|
return;
|
|
96
197
|
}
|
|
198
|
+
if (tunnels.get(taskId) !== state || state.generation !== generation) return;
|
|
199
|
+
let sshArgs: string[];
|
|
200
|
+
try {
|
|
201
|
+
sshArgs = readinessAwareSshArgs(
|
|
202
|
+
args,
|
|
203
|
+
state.machinePort,
|
|
204
|
+
MCP_GATEWAY_PORT,
|
|
205
|
+
);
|
|
206
|
+
} catch (err) {
|
|
207
|
+
state.spawning = false;
|
|
208
|
+
scheduleRespawn(taskId, source, state, generation, SLOW_RESPAWN_DELAY_MS);
|
|
209
|
+
console.warn(
|
|
210
|
+
`[machine-tunnel] ${taskId}: invalid ssh arguments (${err instanceof Error ? err.message : err}) — retrying`,
|
|
211
|
+
);
|
|
212
|
+
return;
|
|
213
|
+
}
|
|
214
|
+
state.spawning = false;
|
|
215
|
+
state.ready = false;
|
|
97
216
|
state.spawnedAt = Date.now();
|
|
98
|
-
const child = spawn("ssh",
|
|
217
|
+
const child = spawn("ssh", sshArgs, {
|
|
218
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
219
|
+
});
|
|
99
220
|
state.child = child;
|
|
221
|
+
let stdoutTail = "";
|
|
100
222
|
let stderrTail = "";
|
|
223
|
+
child.stdout?.setEncoding("utf8");
|
|
224
|
+
child.stdout?.on("data", (chunk: string) => {
|
|
225
|
+
if (tunnels.get(taskId) !== state || state.generation !== generation) {
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
228
|
+
stdoutTail = (stdoutTail + chunk).slice(-2048);
|
|
229
|
+
if (!state.ready && stdoutTail.includes(`${READY_MARKER}\n`)) {
|
|
230
|
+
state.ready = true;
|
|
231
|
+
for (const waiter of [...state.readinessWaiters]) waiter.resolve();
|
|
232
|
+
}
|
|
233
|
+
});
|
|
101
234
|
child.stderr?.setEncoding("utf8");
|
|
102
235
|
child.stderr?.on("data", (chunk: string) => {
|
|
103
236
|
stderrTail = (stderrTail + chunk).slice(-2048);
|
|
104
237
|
});
|
|
105
|
-
child.once("
|
|
238
|
+
child.once("close", (code) => {
|
|
106
239
|
if (tunnels.get(taskId) !== state || state.generation !== generation) {
|
|
107
240
|
return;
|
|
108
241
|
}
|
|
109
242
|
state.child = null;
|
|
243
|
+
state.ready = false;
|
|
110
244
|
const lifetimeMs = Date.now() - state.spawnedAt;
|
|
111
245
|
if (lifetimeMs < 5_000) state.fastDeaths += 1;
|
|
112
246
|
else state.fastDeaths = 0;
|
|
@@ -127,10 +261,48 @@ async function spawnTunnel(
|
|
|
127
261
|
);
|
|
128
262
|
});
|
|
129
263
|
child.once("error", () => {
|
|
130
|
-
//
|
|
264
|
+
// close fires too; the handler above owns respawn.
|
|
131
265
|
});
|
|
132
266
|
}
|
|
133
267
|
|
|
268
|
+
/** Replace `-N` (no remote command) with a fixed remote command. With the
|
|
269
|
+
* provider's `ExitOnForwardFailure=yes`, OpenSSH runs this command only after
|
|
270
|
+
* the server has acknowledged the `-R` listener. The loop keeps the session
|
|
271
|
+
* alive exactly as `-N` did. */
|
|
272
|
+
function readinessAwareSshArgs(
|
|
273
|
+
args: readonly string[],
|
|
274
|
+
machinePort: number,
|
|
275
|
+
hostPort: number,
|
|
276
|
+
): string[] {
|
|
277
|
+
const noCommandIndex = args.indexOf("-N");
|
|
278
|
+
if (noCommandIndex === -1) {
|
|
279
|
+
throw new Error("machine gateway tunnel ssh args must include -N");
|
|
280
|
+
}
|
|
281
|
+
const exitOnForwardFailure = args.some(
|
|
282
|
+
(value, index) =>
|
|
283
|
+
value === "ExitOnForwardFailure=yes" && args[index - 1] === "-o",
|
|
284
|
+
);
|
|
285
|
+
if (!exitOnForwardFailure) {
|
|
286
|
+
throw new Error(
|
|
287
|
+
"machine gateway tunnel ssh args must enable ExitOnForwardFailure",
|
|
288
|
+
);
|
|
289
|
+
}
|
|
290
|
+
const expectedForward =
|
|
291
|
+
`127.0.0.1:${machinePort}:127.0.0.1:${hostPort}`;
|
|
292
|
+
const remoteForward = args.some(
|
|
293
|
+
(value, index) => value === expectedForward && args[index - 1] === "-R",
|
|
294
|
+
);
|
|
295
|
+
if (!remoteForward) {
|
|
296
|
+
throw new Error(
|
|
297
|
+
`machine gateway tunnel ssh args must publish ${expectedForward}`,
|
|
298
|
+
);
|
|
299
|
+
}
|
|
300
|
+
const readyArgs = [...args];
|
|
301
|
+
readyArgs.splice(noCommandIndex, 1);
|
|
302
|
+
readyArgs.unshift("-T", "-o", `RemoteCommand=${READY_REMOTE_COMMAND}`);
|
|
303
|
+
return readyArgs;
|
|
304
|
+
}
|
|
305
|
+
|
|
134
306
|
function scheduleRespawn(
|
|
135
307
|
taskId: string,
|
|
136
308
|
source: MachineTunnelSource,
|
|
@@ -153,6 +325,9 @@ export function stopMachineGatewayTunnel(taskId: string): void {
|
|
|
153
325
|
tunnels.delete(taskId);
|
|
154
326
|
state.generation += 1; // invalidate in-flight spawns
|
|
155
327
|
if (state.timer) clearTimeout(state.timer);
|
|
328
|
+
for (const waiter of [...state.readinessWaiters]) {
|
|
329
|
+
waiter.reject(new Error(`machine gateway tunnel ${taskId} was stopped`));
|
|
330
|
+
}
|
|
156
331
|
if (state.child) {
|
|
157
332
|
state.child.kill("SIGTERM");
|
|
158
333
|
state.child = null;
|
|
@@ -162,5 +337,8 @@ export function stopMachineGatewayTunnel(taskId: string): void {
|
|
|
162
337
|
/** Test-only view. */
|
|
163
338
|
export function machineGatewayTunnelActive(taskId: string): boolean {
|
|
164
339
|
const state = tunnels.get(taskId);
|
|
165
|
-
return Boolean(
|
|
340
|
+
return Boolean(
|
|
341
|
+
state &&
|
|
342
|
+
(state.child !== null || state.timer !== null || state.spawning),
|
|
343
|
+
);
|
|
166
344
|
}
|