@runuai/host 0.9.13 → 0.9.42
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/README.md +22 -5
- package/db/migrations/0014_host_inventory_event_index.sql +1 -0
- package/db/migrations/0015_host_settings.sql +9 -0
- package/db/migrations/0016_task_environment.sql +2 -0
- package/db/migrations/meta/_journal.json +21 -0
- package/db/schema.ts +80 -30
- package/images/standard/Dockerfile +36 -10
- package/images/standard/README.md +63 -18
- package/images/standard/container/corepack-version +1 -0
- package/images/standard/container/uai-init +308 -38
- package/images/standard/container/uai-materialize-runtimes +1527 -0
- package/lib/agent-cli.ts +69 -4
- package/lib/agent.ts +46 -7
- package/lib/agents/claude.ts +13 -8
- package/lib/agents/codex.ts +11 -6
- package/lib/agents/cursor.ts +39 -29
- package/lib/agents/durable-proc.ts +20 -27
- package/lib/agents/factory.ts +9 -25
- package/lib/agents/grok.ts +43 -30
- package/lib/agents/kimi.ts +44 -29
- package/lib/agents/opencode.ts +43 -31
- package/lib/agents/proc.ts +149 -114
- package/lib/agents/transport.ts +62 -50
- package/lib/agents/types.ts +6 -4
- package/lib/apple-runtime-recycle.ts +236 -0
- package/lib/apple-uninstall-teardown.ts +224 -0
- package/lib/browser-testing.ts +233 -93
- package/lib/codex-auth.ts +40 -6
- package/lib/command-db.ts +20 -0
- package/lib/container-runtime.ts +1338 -0
- package/lib/db.ts +1 -0
- package/lib/docker-exec.ts +87 -5
- package/lib/engine-accounts.ts +68 -5
- package/lib/engine-login.ts +1952 -0
- package/lib/enrollment-state.ts +251 -0
- package/lib/env-file.ts +155 -0
- package/lib/env.ts +4 -0
- package/lib/git-diff.ts +98 -32
- package/lib/git-identity.ts +199 -87
- package/lib/github-tokens.ts +202 -91
- package/lib/host-cloud-url.ts +62 -0
- package/lib/host-config.ts +279 -0
- package/lib/host-logs.ts +962 -0
- package/lib/keyed-promise-tail.ts +23 -0
- package/lib/legacy-runtime-v1.fixture.ts +627 -0
- package/lib/managed-activation-watcher.ts +72 -0
- package/lib/managed-install-owner-watcher.ts +55 -0
- package/lib/managed-operation-drain.ts +49 -0
- package/lib/managed-runtime.ts +3644 -0
- package/lib/managed-update-scheduler.ts +125 -0
- package/lib/mcp-gateway.ts +450 -23
- package/lib/orchestrator.ts +3070 -223
- package/lib/preview-sidecar.ts +57 -13
- package/lib/release-manifest.ts +708 -0
- package/lib/release-trust.ts +28 -0
- package/lib/runtime-activation-tail.ts +232 -0
- package/lib/runtime-archive.ts +1086 -0
- package/lib/runtime-authority.ts +79 -0
- package/lib/runtime-guard.ts +36 -0
- package/lib/runtime-provider-state.ts +169 -0
- package/lib/runtime-state.ts +232 -12
- package/lib/skills.ts +24 -3
- package/lib/ssh.ts +18 -0
- package/lib/standard-image.ts +1104 -141
- package/lib/stopped-task-status-queue.ts +44 -0
- package/lib/task-container-cli.ts +269 -0
- package/lib/task-diff.ts +66 -46
- package/lib/task-environment/apple-container.ts +757 -0
- package/lib/task-environment/docker.ts +945 -0
- package/lib/task-environment/index.ts +364 -0
- package/lib/task-environment/legacy-adoption.ts +443 -0
- package/lib/task-environment/registry.ts +58 -0
- package/lib/task-environment/types.ts +408 -0
- package/lib/task-identity.ts +19 -0
- package/lib/task-inventory.ts +585 -0
- package/lib/tunnel-registry.ts +135 -19
- package/lib/tunnel-runtime.ts +235 -0
- package/package.json +1 -1
- package/scripts/agent/_common.sh +123 -3
- package/scripts/agent/task-down.sh +146 -38
- package/scripts/agent/task-status.sh +19 -3
- package/scripts/agent/task-up.sh +1463 -109
- package/scripts/install/darwin.ts +848 -50
- package/scripts/install/linux.ts +838 -35
- package/scripts/install/types.ts +43 -0
- package/scripts/install/util.ts +215 -8
- package/scripts/install/win.ts +12 -0
- package/src/apple-tunnel-route.ts +104 -0
- package/src/cli.ts +1464 -72
- package/src/event-outbox.ts +83 -4
- package/src/index.ts +871 -50
- package/src/main.ts +1398 -255
- package/src/paths.ts +17 -1
- package/src/protocol.ts +695 -1
- package/src/runtime-bootstrap.ts +165 -0
- package/src/ui/server.ts +46 -10
- package/src/ui/types.ts +37 -0
package/scripts/install/types.ts
CHANGED
|
@@ -15,6 +15,15 @@ export interface InstallContext {
|
|
|
15
15
|
args: string[];
|
|
16
16
|
/** Absolute working directory for the service. */
|
|
17
17
|
cwd: string;
|
|
18
|
+
/** PATH captured for the service. Managed installs prepend the stable shim. */
|
|
19
|
+
envPath?: string;
|
|
20
|
+
/** Register the service without launching it (fresh, unenrolled hosts). */
|
|
21
|
+
startOnInstall?: boolean;
|
|
22
|
+
/** Public install transaction which owns this definition replacement. */
|
|
23
|
+
definitionInstallIntent?: {
|
|
24
|
+
readonly nonce: string;
|
|
25
|
+
readonly ownerPid: number;
|
|
26
|
+
};
|
|
18
27
|
/**
|
|
19
28
|
* When true, print the unit file + the load commands instead of executing.
|
|
20
29
|
* Used by CI tests (no launchd/systemd/winsw side effects).
|
|
@@ -22,6 +31,20 @@ export interface InstallContext {
|
|
|
22
31
|
dryRun: boolean;
|
|
23
32
|
}
|
|
24
33
|
|
|
34
|
+
export interface ServiceManagerState {
|
|
35
|
+
/** The canonical job/unit is currently loaded by the service manager. */
|
|
36
|
+
readonly loaded: boolean;
|
|
37
|
+
/** A daemon is active or the manager has an in-flight activation/restart. */
|
|
38
|
+
readonly active: boolean;
|
|
39
|
+
/** The job/unit is enabled for a future login or reboot. */
|
|
40
|
+
readonly enabled: boolean;
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
export interface ServiceDefinitionState {
|
|
44
|
+
readonly ownership: "missing" | "managed" | "legacy";
|
|
45
|
+
readonly manager: ServiceManagerState;
|
|
46
|
+
}
|
|
47
|
+
|
|
25
48
|
export interface Installer {
|
|
26
49
|
/** Write the unit/plist/xml and load it. */
|
|
27
50
|
install(ctx: InstallContext): Promise<void>;
|
|
@@ -29,7 +52,27 @@ export interface Installer {
|
|
|
29
52
|
uninstall(dryRun?: boolean): Promise<void>;
|
|
30
53
|
start(dryRun?: boolean): Promise<void>;
|
|
31
54
|
stop(dryRun?: boolean): Promise<void>;
|
|
55
|
+
/** Owner-authorized stop used before a first managed conversion. */
|
|
56
|
+
stopForInstall(
|
|
57
|
+
installIntent: string,
|
|
58
|
+
priorManager: ServiceManagerState,
|
|
59
|
+
): Promise<void>;
|
|
60
|
+
/** Owner-authorized idempotent resume after command-graph rollback. */
|
|
61
|
+
resumeServiceForInstall(
|
|
62
|
+
installIntent: string,
|
|
63
|
+
priorManager: ServiceManagerState,
|
|
64
|
+
): Promise<void>;
|
|
32
65
|
restart(dryRun?: boolean): Promise<void>;
|
|
33
66
|
/** Human-readable one-line status (e.g. "running (pid 1234)"). */
|
|
34
67
|
status(): Promise<string>;
|
|
68
|
+
/** Resume/rollback an interrupted transactional definition replacement. */
|
|
69
|
+
reconcileDefinitionInstall(installIntent?: string): Promise<void>;
|
|
70
|
+
/** Restore definition/static manager state without launching the prior command. */
|
|
71
|
+
prepareDefinitionInstallRollback(installIntent: string): Promise<void>;
|
|
72
|
+
/** Resume the recorded prior service after runtime commands have been restored. */
|
|
73
|
+
resumeDefinitionInstallRollback(installIntent: string): Promise<void>;
|
|
74
|
+
/** Commit a definition replacement after the enclosing install attests. */
|
|
75
|
+
completeDefinitionInstall(installIntent?: string): Promise<void>;
|
|
76
|
+
/** Classify the canonical definition and snapshot its manager lifecycle state. */
|
|
77
|
+
definitionState(managedCommand: string): Promise<ServiceDefinitionState>;
|
|
35
78
|
}
|
package/scripts/install/util.ts
CHANGED
|
@@ -5,23 +5,230 @@
|
|
|
5
5
|
*/
|
|
6
6
|
|
|
7
7
|
import { spawnSync } from "node:child_process";
|
|
8
|
+
import { randomBytes } from "node:crypto";
|
|
9
|
+
import {
|
|
10
|
+
closeSync,
|
|
11
|
+
constants,
|
|
12
|
+
fchmodSync,
|
|
13
|
+
fstatSync,
|
|
14
|
+
fsyncSync,
|
|
15
|
+
mkdirSync,
|
|
16
|
+
openSync,
|
|
17
|
+
readSync,
|
|
18
|
+
readFileSync,
|
|
19
|
+
renameSync,
|
|
20
|
+
rmSync,
|
|
21
|
+
writeFileSync,
|
|
22
|
+
} from "node:fs";
|
|
23
|
+
import { dirname, basename, join } from "node:path";
|
|
8
24
|
|
|
9
|
-
export
|
|
25
|
+
export interface StepResult {
|
|
26
|
+
status: number | null;
|
|
27
|
+
stdout: string;
|
|
28
|
+
stderr: string;
|
|
29
|
+
error?: Error;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export type StepExecutor = (
|
|
10
33
|
dryRun: boolean,
|
|
11
34
|
cmd: string,
|
|
12
35
|
args: string[],
|
|
13
|
-
|
|
14
|
-
|
|
36
|
+
) => StepResult;
|
|
37
|
+
|
|
38
|
+
export type DefinitionSnapshot =
|
|
39
|
+
| { readonly exists: false }
|
|
40
|
+
| { readonly exists: true; readonly bytes: Buffer; readonly mode: number };
|
|
41
|
+
|
|
42
|
+
export const MAX_SERVICE_DEFINITION_BYTES = 1024 * 1024;
|
|
43
|
+
export const MAX_SERVICE_INSTALL_JOURNAL_BYTES = 2 * 1024 * 1024;
|
|
44
|
+
|
|
45
|
+
export type DirectorySync = (directory: string) => void;
|
|
46
|
+
|
|
47
|
+
function readBounded(fd: number, maximumBytes: number, description: string): Buffer {
|
|
48
|
+
const bytes = Buffer.allocUnsafe(maximumBytes + 1);
|
|
49
|
+
let length = 0;
|
|
50
|
+
while (length < bytes.length) {
|
|
51
|
+
const read = readSync(fd, bytes, length, bytes.length - length, null);
|
|
52
|
+
if (read === 0) break;
|
|
53
|
+
length += read;
|
|
54
|
+
}
|
|
55
|
+
if (length > maximumBytes) {
|
|
56
|
+
throw new Error(`${description} exceeds ${maximumBytes} bytes`);
|
|
57
|
+
}
|
|
58
|
+
return bytes.subarray(0, length);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Read a service definition without following a surprising symlink. */
|
|
62
|
+
export function snapshotDefinition(file: string): DefinitionSnapshot {
|
|
63
|
+
let fd: number;
|
|
64
|
+
try {
|
|
65
|
+
fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
66
|
+
} catch (error) {
|
|
67
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return { exists: false };
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
try {
|
|
71
|
+
const stat = fstatSync(fd);
|
|
72
|
+
if (!stat.isFile()) {
|
|
73
|
+
throw new Error(`service definition is not a regular file: ${file}`);
|
|
74
|
+
}
|
|
75
|
+
if (stat.size > MAX_SERVICE_DEFINITION_BYTES) {
|
|
76
|
+
throw new Error(`service definition exceeds ${MAX_SERVICE_DEFINITION_BYTES} bytes: ${file}`);
|
|
77
|
+
}
|
|
78
|
+
const bytes = readBounded(
|
|
79
|
+
fd,
|
|
80
|
+
MAX_SERVICE_DEFINITION_BYTES,
|
|
81
|
+
`service definition ${file}`,
|
|
82
|
+
);
|
|
83
|
+
return {
|
|
84
|
+
exists: true,
|
|
85
|
+
bytes,
|
|
86
|
+
mode: stat.mode & 0o7777,
|
|
87
|
+
};
|
|
88
|
+
} finally {
|
|
89
|
+
closeSync(fd);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Atomically replace a definition and sync both its bytes and directory entry. */
|
|
94
|
+
export function writeDefinitionDurable(
|
|
95
|
+
file: string,
|
|
96
|
+
contents: string | Buffer,
|
|
97
|
+
mode = 0o644,
|
|
98
|
+
syncDirectoryEntry: DirectorySync = syncDirectoryDurable,
|
|
99
|
+
): void {
|
|
100
|
+
const directory = dirname(file);
|
|
101
|
+
mkdirSync(directory, { recursive: true });
|
|
102
|
+
const temporary = join(
|
|
103
|
+
directory,
|
|
104
|
+
`.${basename(file)}.${process.pid}.${randomBytes(8).toString("hex")}.tmp`,
|
|
105
|
+
);
|
|
106
|
+
let fd: number | undefined;
|
|
107
|
+
try {
|
|
108
|
+
fd = openSync(
|
|
109
|
+
temporary,
|
|
110
|
+
constants.O_WRONLY |
|
|
111
|
+
constants.O_CREAT |
|
|
112
|
+
constants.O_EXCL |
|
|
113
|
+
(constants.O_NOFOLLOW ?? 0),
|
|
114
|
+
0o600,
|
|
115
|
+
);
|
|
116
|
+
writeFileSync(fd, contents);
|
|
117
|
+
fchmodSync(fd, mode);
|
|
118
|
+
fsyncSync(fd);
|
|
119
|
+
closeSync(fd);
|
|
120
|
+
fd = undefined;
|
|
121
|
+
renameSync(temporary, file);
|
|
122
|
+
syncDirectoryEntry(directory);
|
|
123
|
+
} finally {
|
|
124
|
+
if (fd !== undefined) closeSync(fd);
|
|
125
|
+
rmSync(temporary, { force: true });
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
export function restoreDefinitionDurable(
|
|
130
|
+
file: string,
|
|
131
|
+
snapshot: DefinitionSnapshot,
|
|
132
|
+
syncDirectoryEntry: DirectorySync = syncDirectoryDurable,
|
|
133
|
+
): void {
|
|
134
|
+
if (snapshot.exists) {
|
|
135
|
+
writeDefinitionDurable(file, snapshot.bytes, snapshot.mode, syncDirectoryEntry);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
rmSync(file, { force: true });
|
|
139
|
+
syncDirectoryEntry(dirname(file));
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
export function readPrivateJournal(file: string): string | null {
|
|
143
|
+
let fd: number;
|
|
144
|
+
try {
|
|
145
|
+
fd = openSync(file, constants.O_RDONLY | (constants.O_NOFOLLOW ?? 0));
|
|
146
|
+
} catch (error) {
|
|
147
|
+
if ((error as NodeJS.ErrnoException).code === "ENOENT") return null;
|
|
148
|
+
throw error;
|
|
149
|
+
}
|
|
150
|
+
try {
|
|
151
|
+
const stat = fstatSync(fd);
|
|
152
|
+
if (!stat.isFile()) throw new Error(`service install journal is not regular: ${file}`);
|
|
153
|
+
if ((stat.mode & 0o077) !== 0) {
|
|
154
|
+
throw new Error(`service install journal permissions are too broad: ${file}`);
|
|
155
|
+
}
|
|
156
|
+
if (stat.size > MAX_SERVICE_INSTALL_JOURNAL_BYTES) {
|
|
157
|
+
throw new Error(
|
|
158
|
+
`service install journal exceeds ${MAX_SERVICE_INSTALL_JOURNAL_BYTES} bytes: ${file}`,
|
|
159
|
+
);
|
|
160
|
+
}
|
|
161
|
+
return readBounded(
|
|
162
|
+
fd,
|
|
163
|
+
MAX_SERVICE_INSTALL_JOURNAL_BYTES,
|
|
164
|
+
`service install journal ${file}`,
|
|
165
|
+
).toString("utf8");
|
|
166
|
+
} finally {
|
|
167
|
+
closeSync(fd);
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
export function removeFileDurable(file: string): void {
|
|
172
|
+
rmSync(file, { force: true });
|
|
173
|
+
syncDirectoryDurable(dirname(file));
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
export function syncDirectoryDurable(directory: string): void {
|
|
177
|
+
const fd = openSync(directory, constants.O_RDONLY);
|
|
178
|
+
try {
|
|
179
|
+
fsyncSync(fd);
|
|
180
|
+
} finally {
|
|
181
|
+
closeSync(fd);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
/**
|
|
186
|
+
* Run a service-manager command without deciding whether its failure is safe.
|
|
187
|
+
* Stop/uninstall need the complete result so they can distinguish an absent
|
|
188
|
+
* unit from a manager failure or a daemon which is still live.
|
|
189
|
+
*/
|
|
190
|
+
export const executeStep: StepExecutor = (dryRun, cmd, args) => {
|
|
15
191
|
if (dryRun) {
|
|
16
192
|
console.log(`$ ${cmd} ${args.join(" ")}`);
|
|
17
|
-
return "";
|
|
193
|
+
return { status: 0, stdout: "", stderr: "" };
|
|
18
194
|
}
|
|
19
195
|
const res = spawnSync(cmd, args, { encoding: "utf8" });
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
196
|
+
return {
|
|
197
|
+
status: res.status,
|
|
198
|
+
stdout: res.stdout ?? "",
|
|
199
|
+
stderr: res.stderr ?? "",
|
|
200
|
+
error: res.error,
|
|
201
|
+
};
|
|
202
|
+
};
|
|
203
|
+
|
|
204
|
+
export function stepFailure(
|
|
205
|
+
cmd: string,
|
|
206
|
+
args: string[],
|
|
207
|
+
result: StepResult,
|
|
208
|
+
context?: string,
|
|
209
|
+
): Error {
|
|
210
|
+
const detail = (
|
|
211
|
+
result.stderr ||
|
|
212
|
+
result.stdout ||
|
|
213
|
+
result.error?.message ||
|
|
214
|
+
`exit status ${String(result.status)}`
|
|
215
|
+
).trim();
|
|
216
|
+
return new Error(
|
|
217
|
+
`\`${cmd} ${args.join(" ")}\` failed: ${detail}${context ? ` (${context})` : ""}`,
|
|
218
|
+
);
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
export function step(
|
|
222
|
+
dryRun: boolean,
|
|
223
|
+
cmd: string,
|
|
224
|
+
args: string[],
|
|
225
|
+
opts: { ignoreError?: boolean } = {},
|
|
226
|
+
): string {
|
|
227
|
+
const result = executeStep(dryRun, cmd, args);
|
|
228
|
+
if (result.status !== 0 && !opts.ignoreError) {
|
|
229
|
+
throw stepFailure(cmd, args, result);
|
|
23
230
|
}
|
|
24
|
-
return
|
|
231
|
+
return result.stdout;
|
|
25
232
|
}
|
|
26
233
|
|
|
27
234
|
/** Print a unit/plist/xml file's path + contents (dry-run). */
|
package/scripts/install/win.ts
CHANGED
|
@@ -125,6 +125,18 @@ export const installer: Installer = {
|
|
|
125
125
|
uninstall,
|
|
126
126
|
start,
|
|
127
127
|
stop,
|
|
128
|
+
stopForInstall: async () => undefined,
|
|
129
|
+
resumeServiceForInstall: async () => undefined,
|
|
128
130
|
restart,
|
|
129
131
|
status,
|
|
132
|
+
// Standalone managed runtimes currently reject Windows before service
|
|
133
|
+
// installation, so there is no Windows definition journal to reconcile.
|
|
134
|
+
reconcileDefinitionInstall: async () => undefined,
|
|
135
|
+
prepareDefinitionInstallRollback: async () => undefined,
|
|
136
|
+
resumeDefinitionInstallRollback: async () => undefined,
|
|
137
|
+
completeDefinitionInstall: async () => undefined,
|
|
138
|
+
definitionState: async () => ({
|
|
139
|
+
ownership: "missing",
|
|
140
|
+
manager: { loaded: false, active: false, enabled: false },
|
|
141
|
+
}),
|
|
130
142
|
};
|
|
@@ -0,0 +1,104 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ADR-106 Apple tunnel routing, extracted from main.ts so its two safety
|
|
3
|
+
* properties are directly testable: the identity proof and the TCP connect
|
|
4
|
+
* both happen inside the task's lifecycle slot (a concurrent
|
|
5
|
+
* taskDown/recreate cannot free and reuse the address between proof and
|
|
6
|
+
* connection), and a readiness-epoch change during the awaited connect
|
|
7
|
+
* destroys the socket rather than carrying a dead generation's proof out.
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
export interface AppleRouteSocket {
|
|
11
|
+
connect(port: number, host: string): void;
|
|
12
|
+
once(event: "connect" | "error", listener: () => void): void;
|
|
13
|
+
destroy(): void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
export interface AppleTunnelRouteDeps {
|
|
17
|
+
getTask(taskId: string): { statusMirror: string | null } | undefined;
|
|
18
|
+
reconstruct(task: unknown): Promise<{
|
|
19
|
+
inspectRoute?: () => Promise<
|
|
20
|
+
| { kind: "running"; ipv4Address: string }
|
|
21
|
+
| { kind: "unavailable" }
|
|
22
|
+
| { kind: "unknown"; detail: string }
|
|
23
|
+
>;
|
|
24
|
+
} | null>;
|
|
25
|
+
runTaskLifecycle<T>(taskId: string, work: () => Promise<T>): Promise<T>;
|
|
26
|
+
runtimeProblem(): unknown;
|
|
27
|
+
readinessEpoch(): number;
|
|
28
|
+
epochIsCurrent(epoch: number): boolean;
|
|
29
|
+
poison(detail: string): void;
|
|
30
|
+
createSocket(): AppleRouteSocket;
|
|
31
|
+
connectTimeoutMs?: number;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
export interface AppleTunnelRouteRequest {
|
|
35
|
+
taskId: string;
|
|
36
|
+
target: string;
|
|
37
|
+
containerPort?: number;
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export async function resolveAppleTunnelRoute(
|
|
41
|
+
request: AppleTunnelRouteRequest,
|
|
42
|
+
deps: AppleTunnelRouteDeps,
|
|
43
|
+
): Promise<{ host: string; port: number; socket: AppleRouteSocket } | null> {
|
|
44
|
+
const applePort =
|
|
45
|
+
request.target === "editor" ? 8080 : request.containerPort;
|
|
46
|
+
if (!applePort) return null;
|
|
47
|
+
if (deps.getTask(request.taskId)?.statusMirror !== "running") return null;
|
|
48
|
+
return deps.runTaskLifecycle(request.taskId, async () => {
|
|
49
|
+
const current = deps.getTask(request.taskId);
|
|
50
|
+
if (!current || current.statusMirror !== "running") return null;
|
|
51
|
+
let route:
|
|
52
|
+
| { kind: "running"; ipv4Address: string }
|
|
53
|
+
| { kind: "unavailable" }
|
|
54
|
+
| { kind: "unknown"; detail: string }
|
|
55
|
+
| null = null;
|
|
56
|
+
try {
|
|
57
|
+
const handle = await deps.reconstruct(current);
|
|
58
|
+
if (handle === null || handle.inspectRoute === undefined) return null;
|
|
59
|
+
route = await handle.inspectRoute();
|
|
60
|
+
} catch {
|
|
61
|
+
return null;
|
|
62
|
+
}
|
|
63
|
+
if (route === null) return null;
|
|
64
|
+
if (route.kind === "unknown") {
|
|
65
|
+
// The CLI answered with neither presence nor absence — the same signal
|
|
66
|
+
// class as a Docker daemon that stopped responding mid-tunnel.
|
|
67
|
+
deps.poison(route.detail);
|
|
68
|
+
return null;
|
|
69
|
+
}
|
|
70
|
+
if (route.kind !== "running") return null;
|
|
71
|
+
if (deps.runtimeProblem()) return null;
|
|
72
|
+
const routeEpoch = deps.readinessEpoch();
|
|
73
|
+
const upstream = deps.createSocket();
|
|
74
|
+
const connected = await new Promise<boolean>((resolveConnect) => {
|
|
75
|
+
let settled = false;
|
|
76
|
+
const settle = (ok: boolean): void => {
|
|
77
|
+
if (settled) return;
|
|
78
|
+
settled = true;
|
|
79
|
+
clearTimeout(deadline);
|
|
80
|
+
resolveConnect(ok);
|
|
81
|
+
};
|
|
82
|
+
const deadline = setTimeout(() => {
|
|
83
|
+
upstream.destroy();
|
|
84
|
+
settle(false);
|
|
85
|
+
}, deps.connectTimeoutMs ?? 10_000);
|
|
86
|
+
deadline.unref?.();
|
|
87
|
+
upstream.once("connect", () => settle(true));
|
|
88
|
+
upstream.once("error", () => settle(false));
|
|
89
|
+
upstream.connect(applePort, route.ipv4Address);
|
|
90
|
+
});
|
|
91
|
+
if (
|
|
92
|
+
!connected ||
|
|
93
|
+
// The awaited connect is a window: a forced recheck can invalidate the
|
|
94
|
+
// runtime mid-wait, and a proof from a dead generation must not carry
|
|
95
|
+
// a live socket out of the slot.
|
|
96
|
+
!deps.epochIsCurrent(routeEpoch) ||
|
|
97
|
+
deps.runtimeProblem()
|
|
98
|
+
) {
|
|
99
|
+
upstream.destroy();
|
|
100
|
+
return null;
|
|
101
|
+
}
|
|
102
|
+
return { host: route.ipv4Address, port: applePort, socket: upstream };
|
|
103
|
+
});
|
|
104
|
+
}
|