@runuai/host 0.9.76 → 0.9.78
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/lib/agents/transport.ts +12 -3
- package/lib/codex-auth.ts +55 -0
- package/lib/machine-gateway-tunnel.ts +166 -0
- package/lib/mcp-gateway.ts +14 -3
- package/lib/orchestrator.ts +23 -1
- package/lib/runtime-authority.ts +43 -0
- package/lib/task-environment/machine-task-up.ts +20 -0
- package/lib/task-environment/machine.ts +68 -2
- package/lib/task-environment/types.ts +8 -0
- package/package.json +1 -1
- package/src/index.ts +5 -0
package/lib/agents/transport.ts
CHANGED
|
@@ -37,6 +37,7 @@ import { and, eq } from "drizzle-orm";
|
|
|
37
37
|
|
|
38
38
|
import { getDb, schema } from "../db";
|
|
39
39
|
import { taskWorkspaceDir } from "../env";
|
|
40
|
+
import { machineSessionAuthorityEnv } from "../runtime-authority";
|
|
40
41
|
import { requireContainerRuntimeOperational } from "../runtime-guard";
|
|
41
42
|
import type { TaskEnvironmentAgentSessionSurface } from "../task-environment/types";
|
|
42
43
|
import { DurableProcess, runnerScriptPath } from "./durable-proc";
|
|
@@ -124,11 +125,19 @@ export function createAgentTransport(opts: AgentTransportOptions): LineTransport
|
|
|
124
125
|
// must not trip over, and the host-FS durable flow below polls files a
|
|
125
126
|
// machine does not share. Machine durability uses the ssh-tail backend.
|
|
126
127
|
if (opts.environment.descriptor.locator.provider === "machine") {
|
|
128
|
+
// The session env was assembled with the CONTAINER runtime authority
|
|
129
|
+
// (asdf/corepack pins to shared read-only volumes) — on a machine those
|
|
130
|
+
// paths do not exist and the pins actively break package managers.
|
|
131
|
+
// Swap in the machine authority; credentials and task env pass through.
|
|
132
|
+
const machineOpts: AgentTransportOptions = {
|
|
133
|
+
...opts,
|
|
134
|
+
explicitEnv: machineSessionAuthorityEnv(opts.explicitEnv),
|
|
135
|
+
};
|
|
127
136
|
if (!durableEnabled()) {
|
|
128
|
-
clearCurrentSession(
|
|
129
|
-
return directEnvironmentTransport(
|
|
137
|
+
clearCurrentSession(machineOpts.taskId, machineOpts.agentId);
|
|
138
|
+
return directEnvironmentTransport(machineOpts);
|
|
130
139
|
}
|
|
131
|
-
return machineDurableTransport(
|
|
140
|
+
return machineDurableTransport(machineOpts);
|
|
132
141
|
}
|
|
133
142
|
// Session creation can happen after channel/task lifecycle queues drain, well
|
|
134
143
|
// after the command-level runtime preflight. Recheck at the actual attach or
|
package/lib/codex-auth.ts
CHANGED
|
@@ -201,6 +201,61 @@ export async function injectCodexIntoContainer(
|
|
|
201
201
|
}
|
|
202
202
|
}
|
|
203
203
|
|
|
204
|
+
/**
|
|
205
|
+
* ADR-121: deliver the DEFAULT Codex slot into a MACHINE task over the
|
|
206
|
+
* environment transport. The legacy paths (task-up docker cp, recovery
|
|
207
|
+
* inject, the reinject sweep) all speak docker grammar, so a machine task's
|
|
208
|
+
* codex agent ran without auth.json and looped 'Reconnecting' (live
|
|
209
|
+
* 2026-08-27, first EC2 task). Files land as the ssh user (node), so no
|
|
210
|
+
* chown pass is needed. Returns true when there was nothing to inject.
|
|
211
|
+
*/
|
|
212
|
+
export async function injectCodexIntoMachine(
|
|
213
|
+
environment: {
|
|
214
|
+
exec(request: {
|
|
215
|
+
argv: readonly [string, ...string[]];
|
|
216
|
+
timeoutMs: number;
|
|
217
|
+
maxOutputBytes: number;
|
|
218
|
+
}): Promise<{ exitCode: number | null; stderr: Uint8Array }>;
|
|
219
|
+
copy(request: {
|
|
220
|
+
direction: "into";
|
|
221
|
+
source: string;
|
|
222
|
+
destination: string;
|
|
223
|
+
}): Promise<void>;
|
|
224
|
+
},
|
|
225
|
+
deps: CodexDeps = {},
|
|
226
|
+
): Promise<boolean> {
|
|
227
|
+
const exists = deps.fileExists ?? existsSync;
|
|
228
|
+
const dir = ownerCodexDir();
|
|
229
|
+
if (!exists(join(dir, "auth.json"))) return true; // nothing to inject
|
|
230
|
+
try {
|
|
231
|
+
const made = await environment.exec({
|
|
232
|
+
argv: ["/bin/mkdir", "-p", "/home/node/.codex"],
|
|
233
|
+
timeoutMs: EXEC_TIMEOUT_MS,
|
|
234
|
+
maxOutputBytes: 64 * 1024,
|
|
235
|
+
});
|
|
236
|
+
if (made.exitCode !== 0) {
|
|
237
|
+
throw new Error(
|
|
238
|
+
`mkdir /home/node/.codex exited ${made.exitCode}: ${Buffer.from(made.stderr).toString("utf8").trim()}`,
|
|
239
|
+
);
|
|
240
|
+
}
|
|
241
|
+
for (const item of CODEX_ITEMS) {
|
|
242
|
+
const source = join(dir, item);
|
|
243
|
+
if (!exists(source)) continue;
|
|
244
|
+
await environment.copy({
|
|
245
|
+
direction: "into",
|
|
246
|
+
source,
|
|
247
|
+
destination: `/home/node/.codex/${item}`,
|
|
248
|
+
});
|
|
249
|
+
}
|
|
250
|
+
return true;
|
|
251
|
+
} catch (err) {
|
|
252
|
+
console.error(
|
|
253
|
+
`[codex] machine inject: ${err instanceof Error ? err.message : err} — Codex in this task will fail until the next ensure retries`,
|
|
254
|
+
);
|
|
255
|
+
return false;
|
|
256
|
+
}
|
|
257
|
+
}
|
|
258
|
+
|
|
204
259
|
/**
|
|
205
260
|
* Refresh the freshly (re)logged-in Codex files in every task container that
|
|
206
261
|
* is both DB-active AND confirmed running by docker. `config.toml` is excluded:
|
|
@@ -0,0 +1,166 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-121: the MCP gateway reverse tunnel for machine tasks.
|
|
3
|
+
*
|
|
4
|
+
* Containers reach the host's MCP gateway via container topology
|
|
5
|
+
* (host.docker.internal, the vmnet gateway IP). A machine — an EC2
|
|
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
|
+
* `ssh -N -R 127.0.0.1:<port>:127.0.0.1:<port>` per machine task, using
|
|
9
|
+
* the same per-task key every other machine operation rides. Task configs
|
|
10
|
+
* then point MCP servers at the machine's own loopback
|
|
11
|
+
* (setupMcpTaskConfig's machine branch).
|
|
12
|
+
*
|
|
13
|
+
* Supervision: the tunnel respawns with a short backoff while ensured —
|
|
14
|
+
* re-resolving the machine address each spawn, since it changes across
|
|
15
|
+
* stop/start — and stops when the task stops, tears down, or the process
|
|
16
|
+
* exits. Failure is quiet-but-logged: MCP servers failing to connect is
|
|
17
|
+
* the visible symptom, and the agent-facing degradation is already
|
|
18
|
+
* per-server.
|
|
19
|
+
*/
|
|
20
|
+
|
|
21
|
+
import { spawn, type ChildProcess } from "node:child_process";
|
|
22
|
+
|
|
23
|
+
import { MCP_GATEWAY_PORT } from "./mcp-gateway";
|
|
24
|
+
|
|
25
|
+
const RESPAWN_DELAY_MS = 3_000;
|
|
26
|
+
/** After this many consecutive sub-5s deaths, slow the respawn cadence so a
|
|
27
|
+
* permanently unreachable machine does not spin ssh forever. */
|
|
28
|
+
const FAST_DEATH_THRESHOLD = 5;
|
|
29
|
+
const SLOW_RESPAWN_DELAY_MS = 60_000;
|
|
30
|
+
|
|
31
|
+
export interface MachineTunnelSource {
|
|
32
|
+
gatewayTunnelSshArgs(
|
|
33
|
+
machinePort: number,
|
|
34
|
+
hostPort: number,
|
|
35
|
+
): Promise<string[]>;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
interface TunnelState {
|
|
39
|
+
generation: number;
|
|
40
|
+
child: ChildProcess | null;
|
|
41
|
+
timer: ReturnType<typeof setTimeout> | null;
|
|
42
|
+
fastDeaths: number;
|
|
43
|
+
spawnedAt: number;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
const tunnels = new Map<string, TunnelState>();
|
|
47
|
+
|
|
48
|
+
/** Idempotently keep a gateway tunnel alive for this machine task. */
|
|
49
|
+
export function ensureMachineGatewayTunnel(
|
|
50
|
+
taskId: string,
|
|
51
|
+
source: MachineTunnelSource,
|
|
52
|
+
): void {
|
|
53
|
+
const existing = tunnels.get(taskId);
|
|
54
|
+
if (existing && (existing.child !== null || existing.timer !== null)) {
|
|
55
|
+
return; // live or already scheduled
|
|
56
|
+
}
|
|
57
|
+
const state: TunnelState = existing ?? {
|
|
58
|
+
generation: 0,
|
|
59
|
+
child: null,
|
|
60
|
+
timer: null,
|
|
61
|
+
fastDeaths: 0,
|
|
62
|
+
spawnedAt: 0,
|
|
63
|
+
};
|
|
64
|
+
state.generation += 1;
|
|
65
|
+
tunnels.set(taskId, state);
|
|
66
|
+
if (existing === undefined) {
|
|
67
|
+
console.log(`[machine-tunnel] ${taskId}: ensuring gateway tunnel`);
|
|
68
|
+
}
|
|
69
|
+
void spawnTunnel(taskId, source, state, state.generation);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
async function spawnTunnel(
|
|
73
|
+
taskId: string,
|
|
74
|
+
source: MachineTunnelSource,
|
|
75
|
+
state: TunnelState,
|
|
76
|
+
generation: number,
|
|
77
|
+
): Promise<void> {
|
|
78
|
+
if (tunnels.get(taskId) !== state || state.generation !== generation) return;
|
|
79
|
+
let args: string[];
|
|
80
|
+
try {
|
|
81
|
+
args = await source.gatewayTunnelSshArgs(
|
|
82
|
+
MCP_GATEWAY_PORT,
|
|
83
|
+
MCP_GATEWAY_PORT,
|
|
84
|
+
);
|
|
85
|
+
} catch (err) {
|
|
86
|
+
// Machine not reachable right now (stopped, address settling) — retry
|
|
87
|
+
// on the slow cadence; a stop/teardown clears us before it matters.
|
|
88
|
+
scheduleRespawn(taskId, source, state, generation, SLOW_RESPAWN_DELAY_MS);
|
|
89
|
+
if (state.fastDeaths === 0) {
|
|
90
|
+
console.warn(
|
|
91
|
+
`[machine-tunnel] ${taskId}: target unavailable (${err instanceof Error ? err.message : err}) — retrying`,
|
|
92
|
+
);
|
|
93
|
+
}
|
|
94
|
+
state.fastDeaths += 1;
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
state.spawnedAt = Date.now();
|
|
98
|
+
const child = spawn("ssh", args, { stdio: ["ignore", "ignore", "pipe"] });
|
|
99
|
+
state.child = child;
|
|
100
|
+
let stderrTail = "";
|
|
101
|
+
child.stderr?.setEncoding("utf8");
|
|
102
|
+
child.stderr?.on("data", (chunk: string) => {
|
|
103
|
+
stderrTail = (stderrTail + chunk).slice(-2048);
|
|
104
|
+
});
|
|
105
|
+
child.once("exit", (code) => {
|
|
106
|
+
if (tunnels.get(taskId) !== state || state.generation !== generation) {
|
|
107
|
+
return;
|
|
108
|
+
}
|
|
109
|
+
state.child = null;
|
|
110
|
+
const lifetimeMs = Date.now() - state.spawnedAt;
|
|
111
|
+
if (lifetimeMs < 5_000) state.fastDeaths += 1;
|
|
112
|
+
else state.fastDeaths = 0;
|
|
113
|
+
const slow = state.fastDeaths >= FAST_DEATH_THRESHOLD;
|
|
114
|
+
if (state.fastDeaths === FAST_DEATH_THRESHOLD) {
|
|
115
|
+
console.warn(
|
|
116
|
+
`[machine-tunnel] ${taskId}: tunnel keeps dying (exit ${code ?? "signal"}${
|
|
117
|
+
stderrTail.trim() ? `: ${stderrTail.trim().slice(0, 200)}` : ""
|
|
118
|
+
}) — slowing retries`,
|
|
119
|
+
);
|
|
120
|
+
}
|
|
121
|
+
scheduleRespawn(
|
|
122
|
+
taskId,
|
|
123
|
+
source,
|
|
124
|
+
state,
|
|
125
|
+
generation,
|
|
126
|
+
slow ? SLOW_RESPAWN_DELAY_MS : RESPAWN_DELAY_MS,
|
|
127
|
+
);
|
|
128
|
+
});
|
|
129
|
+
child.once("error", () => {
|
|
130
|
+
// exit fires too; the handler above owns respawn.
|
|
131
|
+
});
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
function scheduleRespawn(
|
|
135
|
+
taskId: string,
|
|
136
|
+
source: MachineTunnelSource,
|
|
137
|
+
state: TunnelState,
|
|
138
|
+
generation: number,
|
|
139
|
+
delayMs: number,
|
|
140
|
+
): void {
|
|
141
|
+
if (tunnels.get(taskId) !== state || state.generation !== generation) return;
|
|
142
|
+
state.timer = setTimeout(() => {
|
|
143
|
+
state.timer = null;
|
|
144
|
+
void spawnTunnel(taskId, source, state, generation);
|
|
145
|
+
}, delayMs);
|
|
146
|
+
state.timer.unref?.();
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/** Tear the tunnel down (task stop/teardown/GC). Idempotent. */
|
|
150
|
+
export function stopMachineGatewayTunnel(taskId: string): void {
|
|
151
|
+
const state = tunnels.get(taskId);
|
|
152
|
+
if (!state) return;
|
|
153
|
+
tunnels.delete(taskId);
|
|
154
|
+
state.generation += 1; // invalidate in-flight spawns
|
|
155
|
+
if (state.timer) clearTimeout(state.timer);
|
|
156
|
+
if (state.child) {
|
|
157
|
+
state.child.kill("SIGTERM");
|
|
158
|
+
state.child = null;
|
|
159
|
+
}
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
/** Test-only view. */
|
|
163
|
+
export function machineGatewayTunnelActive(taskId: string): boolean {
|
|
164
|
+
const state = tunnels.get(taskId);
|
|
165
|
+
return Boolean(state && (state.child !== null || state.timer !== null));
|
|
166
|
+
}
|
package/lib/mcp-gateway.ts
CHANGED
|
@@ -854,11 +854,14 @@ function gatewayIdentity(value, slug) {
|
|
|
854
854
|
}
|
|
855
855
|
|
|
856
856
|
function sameTaskGateway(oldUrl, desiredUrl, slug) {
|
|
857
|
+
// Ownership is proven by the /t/<taskId>.<token>/<slug> path, not the
|
|
858
|
+
// origin: the gateway host is topology-dependent (container gateway IP,
|
|
859
|
+
// host.docker.internal, machine loopback behind a reverse tunnel) and
|
|
860
|
+
// migrating a task across topologies is exactly when the rewrite runs.
|
|
857
861
|
const oldIdentity = gatewayIdentity(oldUrl, slug);
|
|
858
862
|
const desiredIdentity = gatewayIdentity(desiredUrl, slug);
|
|
859
863
|
return oldIdentity !== null &&
|
|
860
864
|
desiredIdentity !== null &&
|
|
861
|
-
oldIdentity.origin === desiredIdentity.origin &&
|
|
862
865
|
oldIdentity.taskId === desiredIdentity.taskId;
|
|
863
866
|
}
|
|
864
867
|
|
|
@@ -1063,7 +1066,8 @@ export async function setupMcpTaskConfig(
|
|
|
1063
1066
|
connections: TaskMcpConnection[],
|
|
1064
1067
|
engineKinds: string[],
|
|
1065
1068
|
codexHomes: readonly string[] = ["/home/node/.codex"],
|
|
1066
|
-
environment?: Pick<TaskEnvironmentHandle, "exec"
|
|
1069
|
+
environment?: Pick<TaskEnvironmentHandle, "exec"> &
|
|
1070
|
+
Partial<Pick<TaskEnvironmentHandle, "descriptor">>,
|
|
1067
1071
|
): Promise<boolean> {
|
|
1068
1072
|
// No early return on empty: the claude adapter passes
|
|
1069
1073
|
// `--mcp-config /workspace/.mcp.json` unconditionally (ADR-057), so the
|
|
@@ -1084,7 +1088,14 @@ export async function setupMcpTaskConfig(
|
|
|
1084
1088
|
throw new Error("invalid Codex MCP connection slug");
|
|
1085
1089
|
}
|
|
1086
1090
|
const acl = ensureTaskGatewayAcl(taskId, connections);
|
|
1087
|
-
|
|
1091
|
+
// ADR-121: a machine dials the gateway through its ssh REVERSE tunnel —
|
|
1092
|
+
// the host's loopback appears at the machine's own 127.0.0.1:PORT
|
|
1093
|
+
// (machine-gateway-tunnel.ts). host.docker.internal / vmnet addresses
|
|
1094
|
+
// are container topology a machine can never route.
|
|
1095
|
+
const gatewayHost =
|
|
1096
|
+
environment?.descriptor?.locator.provider === "machine"
|
|
1097
|
+
? "127.0.0.1"
|
|
1098
|
+
: await taskGatewayHost();
|
|
1088
1099
|
const urlFor = (slug: string): string =>
|
|
1089
1100
|
`http://${gatewayHost}:${MCP_GATEWAY_PORT}/t/${acl.token}/${slug}`;
|
|
1090
1101
|
|
package/lib/orchestrator.ts
CHANGED
|
@@ -90,6 +90,7 @@ import {
|
|
|
90
90
|
type SkillExec,
|
|
91
91
|
writeAgentSkills,
|
|
92
92
|
} from "./skills";
|
|
93
|
+
import { ensureMachineGatewayTunnel } from "./machine-gateway-tunnel";
|
|
93
94
|
import {
|
|
94
95
|
CONTAINER_CLI_PATH,
|
|
95
96
|
agentCliEnv,
|
|
@@ -1655,7 +1656,14 @@ export class Orchestrator {
|
|
|
1655
1656
|
return environment;
|
|
1656
1657
|
})();
|
|
1657
1658
|
const tracked = created.then((environment) => {
|
|
1658
|
-
if (this.isActiveChannel(channel))
|
|
1659
|
+
if (this.isActiveChannel(channel)) {
|
|
1660
|
+
channel.agentHandle = environment;
|
|
1661
|
+
// Every path that touches a machine workspace settles the handle
|
|
1662
|
+
// here — including durable-session reattach after a host restart,
|
|
1663
|
+
// which spawns no sessions and runs no config writers. Ensuring the
|
|
1664
|
+
// gateway tunnel at the funnel keeps it alive across all of them.
|
|
1665
|
+
ensureMachineGatewayTunnelFor(channel.taskId, environment);
|
|
1666
|
+
}
|
|
1659
1667
|
return environment;
|
|
1660
1668
|
});
|
|
1661
1669
|
channel.agentEnvironment = tracked;
|
|
@@ -5641,6 +5649,20 @@ async function quarantineWritableAppleRuntimeContainers(options: {
|
|
|
5641
5649
|
* machine tasks (their workspace is not host-reachable). Best-effort like the
|
|
5642
5650
|
* host-side writer.
|
|
5643
5651
|
*/
|
|
5652
|
+
/** Keep the MCP gateway reverse tunnel alive for a machine channel. The
|
|
5653
|
+
* handle exposes tunnel argv only on the machine provider; anything else is
|
|
5654
|
+
* a quiet no-op. */
|
|
5655
|
+
function ensureMachineGatewayTunnelFor(
|
|
5656
|
+
taskId: string,
|
|
5657
|
+
handle: TaskEnvironmentHandle<unknown> | null,
|
|
5658
|
+
): void {
|
|
5659
|
+
const argv = handle?.gatewayTunnelSshArgs;
|
|
5660
|
+
if (!argv) return;
|
|
5661
|
+
ensureMachineGatewayTunnel(taskId, {
|
|
5662
|
+
gatewayTunnelSshArgs: argv.bind(handle),
|
|
5663
|
+
});
|
|
5664
|
+
}
|
|
5665
|
+
|
|
5644
5666
|
async function materializeAgentCli(
|
|
5645
5667
|
task: typeof schema.hostTasks.$inferSelect,
|
|
5646
5668
|
roster: RosterAgent[],
|
package/lib/runtime-authority.ts
CHANGED
|
@@ -67,6 +67,49 @@ export const RUNTIME_AUTHORITY_ENV: Readonly<Record<string, string>> =
|
|
|
67
67
|
ENV: "",
|
|
68
68
|
});
|
|
69
69
|
|
|
70
|
+
/**
|
|
71
|
+
* ADR-121: the MACHINE flavor of the authority env. A machine is a
|
|
72
|
+
* single-task trust domain — the container constants above defend SHARED
|
|
73
|
+
* host resources (the read-only asdf/corepack volume, uai-npm prefixes)
|
|
74
|
+
* that do not exist on a machine, and carrying them anyway pinned corepack
|
|
75
|
+
* to an unwritable /opt path with the network off, so `pnpm install`
|
|
76
|
+
* failed out of the box (live 2026-08-27, first EC2 task — the agent had
|
|
77
|
+
* to hand-repair its own environment). Keep the hygiene (loader/env
|
|
78
|
+
* clearing, prompt-free corepack), drop the container-topology pins.
|
|
79
|
+
*/
|
|
80
|
+
export const MACHINE_RUNTIME_AUTHORITY_ENV: Readonly<Record<string, string>> =
|
|
81
|
+
Object.freeze({
|
|
82
|
+
PATH: "/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin:/home/node/.local/bin",
|
|
83
|
+
HOME: "/home/node",
|
|
84
|
+
XDG_CONFIG_HOME: "/home/node/.config",
|
|
85
|
+
XDG_DATA_HOME: "/home/node/.local/share",
|
|
86
|
+
XDG_CACHE_HOME: "/home/node/.cache",
|
|
87
|
+
COREPACK_ENABLE_DOWNLOAD_PROMPT: "0",
|
|
88
|
+
LD_PRELOAD: "",
|
|
89
|
+
LD_AUDIT: "",
|
|
90
|
+
LD_LIBRARY_PATH: "",
|
|
91
|
+
NODE_OPTIONS: "",
|
|
92
|
+
NODE_PATH: "",
|
|
93
|
+
PS4: "",
|
|
94
|
+
BASH_ENV: "",
|
|
95
|
+
ENV: "",
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
/** Rewrite a session env assembled for a CONTAINER into its machine form:
|
|
99
|
+
* every container-authority key is dropped, then the machine authority is
|
|
100
|
+
* overlaid. Non-authority keys (engine credentials, task tokens, preview
|
|
101
|
+
* env) pass through untouched. */
|
|
102
|
+
export function machineSessionAuthorityEnv(
|
|
103
|
+
env: Readonly<Record<string, string>> | undefined,
|
|
104
|
+
): Record<string, string> {
|
|
105
|
+
const out: Record<string, string> = {};
|
|
106
|
+
for (const [key, value] of Object.entries(env ?? {})) {
|
|
107
|
+
if (key in RUNTIME_AUTHORITY_ENV) continue;
|
|
108
|
+
out[key] = value;
|
|
109
|
+
}
|
|
110
|
+
return { ...out, ...MACHINE_RUNTIME_AUTHORITY_ENV };
|
|
111
|
+
}
|
|
112
|
+
|
|
70
113
|
/** Docker `exec` options for the fixed authority environment. Callers append
|
|
71
114
|
* this after every less-trusted `-e` option and immediately before the
|
|
72
115
|
* container name. Container admission additionally rejects noncanonical
|
|
@@ -27,6 +27,7 @@ import type {
|
|
|
27
27
|
TaskUpCredentials,
|
|
28
28
|
TaskUpResult,
|
|
29
29
|
} from "../agent";
|
|
30
|
+
import { injectCodexIntoMachine } from "../codex-auth";
|
|
30
31
|
import { sharedRoot } from "../shared-files";
|
|
31
32
|
import {
|
|
32
33
|
MACHINE_TASK_ENVIRONMENT_PROVIDER,
|
|
@@ -447,6 +448,12 @@ function withTaskDownResult(
|
|
|
447
448
|
...(handle.inspectRoute
|
|
448
449
|
? { inspectRoute: () => handle.inspectRoute!() }
|
|
449
450
|
: {}),
|
|
451
|
+
...(handle.gatewayTunnelSshArgs
|
|
452
|
+
? {
|
|
453
|
+
gatewayTunnelSshArgs: (machinePort: number, hostPort: number) =>
|
|
454
|
+
handle.gatewayTunnelSshArgs!(machinePort, hostPort),
|
|
455
|
+
}
|
|
456
|
+
: {}),
|
|
450
457
|
};
|
|
451
458
|
}
|
|
452
459
|
|
|
@@ -524,6 +531,19 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
524
531
|
});
|
|
525
532
|
if (sharedWarning) warnings.push(sharedWarning);
|
|
526
533
|
|
|
534
|
+
// Codex default-slot credentials: the legacy delivery paths are all
|
|
535
|
+
// docker grammar; machines get theirs over the transport. Best-effort
|
|
536
|
+
// — a failed inject degrades codex agents, never the task.
|
|
537
|
+
if (input.task.agents.some((agent) => agent.kind === "codex")) {
|
|
538
|
+
await injectCodexIntoMachine(handle).catch((error: unknown) => {
|
|
539
|
+
console.warn(
|
|
540
|
+
`[machine] task ${request.taskId}: codex inject failed: ${
|
|
541
|
+
error instanceof Error ? error.message : String(error)
|
|
542
|
+
}`,
|
|
543
|
+
);
|
|
544
|
+
});
|
|
545
|
+
}
|
|
546
|
+
|
|
527
547
|
// Editor pane: code-server on the machine (same flags as uai-init's
|
|
528
548
|
// container launch), idempotent across provision re-runs and resume.
|
|
529
549
|
// Best-effort — a missing binary degrades the Editor tab, never the task.
|
|
@@ -14,8 +14,9 @@
|
|
|
14
14
|
* is complete now so nothing about the shape is provisional.
|
|
15
15
|
*/
|
|
16
16
|
|
|
17
|
-
import { spawn as nodeSpawn } from "node:child_process";
|
|
18
|
-
import { readFileSync, writeFileSync } from "node:fs";
|
|
17
|
+
import { execFile, spawn as nodeSpawn } from "node:child_process";
|
|
18
|
+
import { readFileSync, statSync, writeFileSync } from "node:fs";
|
|
19
|
+
import { promisify } from "node:util";
|
|
19
20
|
|
|
20
21
|
import type {
|
|
21
22
|
MachineInfo,
|
|
@@ -386,6 +387,46 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
386
387
|
}
|
|
387
388
|
const target = await this.#target();
|
|
388
389
|
if (request.direction === "into") {
|
|
390
|
+
// Directory sources tar-stream over the transport (engine-account
|
|
391
|
+
// homes — live 2026-08-27: codex's auth.json never reached the
|
|
392
|
+
// machine because readFileSync(dir) threw EISDIR and the account
|
|
393
|
+
// materialization died). Single files keep the direct cat path.
|
|
394
|
+
if (statSync(request.source).isDirectory()) {
|
|
395
|
+
const tarball = await promisify(execFile)(
|
|
396
|
+
"tar",
|
|
397
|
+
["-C", request.source, "-cf", "-", "."],
|
|
398
|
+
{ encoding: "buffer", maxBuffer: 256 * 1024 * 1024 },
|
|
399
|
+
);
|
|
400
|
+
const extract = await capturedCliExec(
|
|
401
|
+
this.#deps.spawn,
|
|
402
|
+
"ssh",
|
|
403
|
+
machineEnvironmentSshArgs(
|
|
404
|
+
target,
|
|
405
|
+
{
|
|
406
|
+
argv: [
|
|
407
|
+
"/bin/sh",
|
|
408
|
+
"-c",
|
|
409
|
+
'mkdir -p "$1" && tar -C "$1" -xf -',
|
|
410
|
+
"copy",
|
|
411
|
+
request.destination,
|
|
412
|
+
],
|
|
413
|
+
},
|
|
414
|
+
"interactive",
|
|
415
|
+
),
|
|
416
|
+
{
|
|
417
|
+
argv: ["/bin/sh"],
|
|
418
|
+
stdin: tarball.stdout,
|
|
419
|
+
timeoutMs: 300_000,
|
|
420
|
+
maxOutputBytes: 1024 * 1024,
|
|
421
|
+
},
|
|
422
|
+
);
|
|
423
|
+
if (extract.exitCode !== 0) {
|
|
424
|
+
throw new Error(
|
|
425
|
+
`machine directory copy failed: ${Buffer.from(extract.stderr).toString("utf8").trim()}`,
|
|
426
|
+
);
|
|
427
|
+
}
|
|
428
|
+
return;
|
|
429
|
+
}
|
|
389
430
|
const bytes = readFileSync(request.source);
|
|
390
431
|
const result = await capturedCliExec(
|
|
391
432
|
this.#deps.spawn,
|
|
@@ -463,6 +504,31 @@ class MachineTaskEnvironmentHandle implements TaskEnvironmentHandle {
|
|
|
463
504
|
};
|
|
464
505
|
}
|
|
465
506
|
|
|
507
|
+
/** Full ssh argv for the persistent REVERSE gateway tunnel: the host's
|
|
508
|
+
* loopback gateway appears at the machine's own 127.0.0.1:<machinePort>.
|
|
509
|
+
* Resolved per call — cloud machines change address across stop/start, so
|
|
510
|
+
* each (re)spawn of the tunnel re-resolves the dial target. */
|
|
511
|
+
async gatewayTunnelSshArgs(
|
|
512
|
+
machinePort: number,
|
|
513
|
+
hostPort: number,
|
|
514
|
+
): Promise<string[]> {
|
|
515
|
+
const target = await this.#target();
|
|
516
|
+
return [
|
|
517
|
+
"-o", "BatchMode=yes",
|
|
518
|
+
"-o", "StrictHostKeyChecking=accept-new",
|
|
519
|
+
"-o", "ConnectTimeout=10",
|
|
520
|
+
"-o", "ExitOnForwardFailure=yes",
|
|
521
|
+
"-o", "ServerAliveInterval=15",
|
|
522
|
+
"-o", "ServerAliveCountMax=3",
|
|
523
|
+
"-i", target.keyPath,
|
|
524
|
+
"-p", String(target.port ?? 22),
|
|
525
|
+
"-l", this.#value.sshUser,
|
|
526
|
+
target.address,
|
|
527
|
+
"-N",
|
|
528
|
+
"-R", `127.0.0.1:${machinePort}:127.0.0.1:${hostPort}`,
|
|
529
|
+
];
|
|
530
|
+
}
|
|
531
|
+
|
|
466
532
|
async status(): Promise<TaskEnvironmentStatus> {
|
|
467
533
|
const info = await this.#describeOwn();
|
|
468
534
|
switch (info.state) {
|
|
@@ -220,6 +220,14 @@ export interface TaskEnvironmentHandle<TTeardownResult = void>
|
|
|
220
220
|
| { kind: "unavailable" }
|
|
221
221
|
| { kind: "unknown"; detail: string }
|
|
222
222
|
>;
|
|
223
|
+
/** ADR-121, machine provider only: full ssh argv for the persistent
|
|
224
|
+
* REVERSE gateway tunnel that makes the host's MCP gateway answer on the
|
|
225
|
+
* environment's own loopback. Container providers omit it — their tasks
|
|
226
|
+
* route to the gateway via container topology instead. */
|
|
227
|
+
gatewayTunnelSshArgs?(
|
|
228
|
+
machinePort: number,
|
|
229
|
+
hostPort: number,
|
|
230
|
+
): Promise<string[]>;
|
|
223
231
|
}
|
|
224
232
|
|
|
225
233
|
export interface TaskEnvironmentProvisionRequest<TInput, TCredentials> {
|
package/package.json
CHANGED
package/src/index.ts
CHANGED
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
removeTaskCliSecretStrict,
|
|
18
18
|
storeTaskCliSecret,
|
|
19
19
|
} from "../lib/agent-cli";
|
|
20
|
+
import { stopMachineGatewayTunnel } from "../lib/machine-gateway-tunnel";
|
|
20
21
|
import {
|
|
21
22
|
clearRefresh,
|
|
22
23
|
injectIntoContainer,
|
|
@@ -966,9 +967,13 @@ export const hostCommands: HostCommands = {
|
|
|
966
967
|
!orphanGc &&
|
|
967
968
|
environment.descriptor.locator.provider === "machine"
|
|
968
969
|
) {
|
|
970
|
+
stopMachineGatewayTunnel(input.taskId);
|
|
969
971
|
await environment.stop();
|
|
970
972
|
return taskDownResultForInput(input, { status: "stopped" });
|
|
971
973
|
}
|
|
974
|
+
if (environment.descriptor.locator.provider === "machine") {
|
|
975
|
+
stopMachineGatewayTunnel(input.taskId);
|
|
976
|
+
}
|
|
972
977
|
return taskDownResultForInput(input, await environment.teardown());
|
|
973
978
|
});
|
|
974
979
|
if (result.ok) {
|