@moondeck/station 1.0.1 → 1.0.2
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/images/standard/Dockerfile +6 -2
- package/images/standard/container/uai-create-node-data +6 -0
- package/images/standard/container/uai-repair-node-data.py +93 -0
- package/lib/agent-cli.ts +26 -5
- package/lib/browser-testing.ts +5 -0
- package/lib/machine-provider-local.ts +2 -0
- package/lib/node-state.ts +17 -0
- package/lib/orchestrator.ts +18 -31
- package/lib/preview-ports.ts +3 -0
- package/lib/preview-upstream.ts +61 -0
- package/lib/runtime-archive.ts +2 -0
- package/lib/task-environment/machine-task-up.ts +27 -0
- package/package.json +1 -1
- package/scripts/agent/task-up.sh +25 -15
- package/src/index.ts +13 -3
- package/src/main.ts +28 -6
- package/src/preview-upstream.ts +41 -0
- package/src/protocol.ts +5 -0
|
@@ -51,6 +51,7 @@ ENV DEBIAN_FRONTEND=noninteractive
|
|
|
51
51
|
RUN apt-get update \
|
|
52
52
|
&& apt-get install -y --no-install-recommends \
|
|
53
53
|
build-essential \
|
|
54
|
+
avahi-utils \
|
|
54
55
|
ca-certificates \
|
|
55
56
|
curl \
|
|
56
57
|
direnv \
|
|
@@ -71,6 +72,7 @@ RUN apt-get update \
|
|
|
71
72
|
openssh-client \
|
|
72
73
|
pkg-config \
|
|
73
74
|
procps \
|
|
75
|
+
python3-minimal \
|
|
74
76
|
ripgrep \
|
|
75
77
|
tk-dev \
|
|
76
78
|
tmux \
|
|
@@ -89,7 +91,7 @@ RUN apt-get update \
|
|
|
89
91
|
# uai-playwright volume (downloaded once per host); these are its deps.
|
|
90
92
|
RUN apt-get update \
|
|
91
93
|
&& apt-get install -y --no-install-recommends \
|
|
92
|
-
libnss3 libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
|
94
|
+
libnss3 libnss3-tools libnspr4 libatk1.0-0 libatk-bridge2.0-0 libcups2 libdrm2 \
|
|
93
95
|
libxkbcommon0 libatspi2.0-0 libxcomposite1 libxdamage1 libxfixes3 \
|
|
94
96
|
libxrandr2 libgbm1 libasound2 libpango-1.0-0 libcairo2 \
|
|
95
97
|
fonts-liberation fonts-unifont \
|
|
@@ -119,7 +121,9 @@ RUN curl -LsSf https://astral.sh/uv/install.sh | env UV_INSTALL_DIR=/usr/local/b
|
|
|
119
121
|
# ---------------------------------------------------------------------------
|
|
120
122
|
|
|
121
123
|
RUN groupadd --gid 1000 node \
|
|
122
|
-
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/node --shell /bin/bash node
|
|
124
|
+
&& useradd --uid 1000 --gid 1000 --create-home --home-dir /home/node --shell /bin/bash node \
|
|
125
|
+
&& install -d -o node -g node -m 0755 /home/node/.local \
|
|
126
|
+
&& install -d -o node -g node -m 0700 /home/node/.local/state
|
|
123
127
|
|
|
124
128
|
# ---------------------------------------------------------------------------
|
|
125
129
|
# 3. asdf.
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
#!/bin/sh
|
|
2
|
+
# Run only as node. Only allowlisted legacy root-owned children can be repaired separately.
|
|
3
|
+
umask 077
|
|
4
|
+
for path in /home/node/.local /home/node/.local/share /home/node/.local/share/opencode /home/node/.local/state; do
|
|
5
|
+
/bin/mkdir -p -m 0700 -- "$path" || printf 'node data setup: could not create %s; checking existing directory metadata\n' "$path" >&2
|
|
6
|
+
done
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
# Root only: never create directories or follow a task-controlled symlink.
|
|
2
|
+
import errno
|
|
3
|
+
import os
|
|
4
|
+
import pwd
|
|
5
|
+
import stat
|
|
6
|
+
import sys
|
|
7
|
+
|
|
8
|
+
flags = os.O_RDONLY | os.O_DIRECTORY | os.O_NOFOLLOW | os.O_CLOEXEC
|
|
9
|
+
node = pwd.getpwnam("node")
|
|
10
|
+
|
|
11
|
+
def repair(parent, name, display, mode):
|
|
12
|
+
try:
|
|
13
|
+
fd = os.open(name, flags, dir_fd=parent)
|
|
14
|
+
except OSError as error:
|
|
15
|
+
if error.errno in (errno.ELOOP, errno.ENOTDIR, errno.ENOENT):
|
|
16
|
+
print("node data repair: skipped " + display + " (missing, symlink or not a directory)", file=sys.stderr)
|
|
17
|
+
return None
|
|
18
|
+
raise
|
|
19
|
+
try:
|
|
20
|
+
current = os.fstat(fd)
|
|
21
|
+
if current.st_uid != node.pw_uid:
|
|
22
|
+
owner = os.fstat(parent)
|
|
23
|
+
# Node already controls entries in this parent. Transfer only
|
|
24
|
+
# these legacy application directories, never their contents.
|
|
25
|
+
legacy = (
|
|
26
|
+
current.st_uid == 0
|
|
27
|
+
and owner.st_uid == node.pw_uid
|
|
28
|
+
and current.st_dev == owner.st_dev
|
|
29
|
+
and display in (
|
|
30
|
+
"/home/node/.local/state",
|
|
31
|
+
"/home/node/.local/share",
|
|
32
|
+
"/home/node/.local/share/opencode",
|
|
33
|
+
)
|
|
34
|
+
)
|
|
35
|
+
if not legacy:
|
|
36
|
+
print("node data repair: skipped " + display + " (directory ownership or filesystem is not eligible; operator inspection required)", file=sys.stderr)
|
|
37
|
+
os.close(fd)
|
|
38
|
+
return None
|
|
39
|
+
os.fchown(fd, node.pw_uid, node.pw_gid)
|
|
40
|
+
os.fchmod(fd, mode)
|
|
41
|
+
except BaseException:
|
|
42
|
+
os.close(fd)
|
|
43
|
+
raise
|
|
44
|
+
return fd
|
|
45
|
+
|
|
46
|
+
fds = []
|
|
47
|
+
try:
|
|
48
|
+
root = os.open("/", flags)
|
|
49
|
+
fds.append(root)
|
|
50
|
+
# Pin the root-owned home chain too; no absolute-path traversal below it.
|
|
51
|
+
parent = root
|
|
52
|
+
for component in ("home", "node"):
|
|
53
|
+
try:
|
|
54
|
+
parent = os.open(component, flags, dir_fd=parent)
|
|
55
|
+
except OSError as error:
|
|
56
|
+
if error.errno not in (errno.ELOOP, errno.ENOTDIR, errno.ENOENT):
|
|
57
|
+
raise
|
|
58
|
+
print("node data repair: skipped home anchor (missing or redirected)", file=sys.stderr)
|
|
59
|
+
sys.exit(0)
|
|
60
|
+
fds.append(parent)
|
|
61
|
+
local = repair(parent, ".local", "/home/node/.local", 0o755)
|
|
62
|
+
if local is not None:
|
|
63
|
+
fds.append(local)
|
|
64
|
+
share = repair(local, "share", "/home/node/.local/share", 0o755)
|
|
65
|
+
if share is not None:
|
|
66
|
+
fds.append(share)
|
|
67
|
+
opencode = repair(share, "opencode", "/home/node/.local/share/opencode", 0o755)
|
|
68
|
+
if opencode is not None:
|
|
69
|
+
fds.append(opencode)
|
|
70
|
+
# docker cp creates root-owned credential files. Repair only
|
|
71
|
+
# these regular files through the pinned directory, never -R.
|
|
72
|
+
for name in ("auth.json", "config.json"):
|
|
73
|
+
try:
|
|
74
|
+
fd = os.open(name, os.O_RDONLY | os.O_NOFOLLOW | os.O_NONBLOCK | os.O_CLOEXEC, dir_fd=opencode)
|
|
75
|
+
except OSError as error:
|
|
76
|
+
if error.errno in (errno.ELOOP, errno.ENOTDIR, errno.ENOENT, errno.ENXIO):
|
|
77
|
+
continue
|
|
78
|
+
raise
|
|
79
|
+
try:
|
|
80
|
+
current = os.fstat(fd)
|
|
81
|
+
if stat.S_ISREG(current.st_mode) and current.st_nlink == 1 and current.st_uid in (0, node.pw_uid):
|
|
82
|
+
os.fchown(fd, node.pw_uid, node.pw_gid)
|
|
83
|
+
os.fchmod(fd, 0o600)
|
|
84
|
+
else:
|
|
85
|
+
print("node data repair: skipped OpenCode " + name + " (not a single-link root/node regular file)", file=sys.stderr)
|
|
86
|
+
finally:
|
|
87
|
+
os.close(fd)
|
|
88
|
+
state = repair(local, "state", "/home/node/.local/state", 0o700)
|
|
89
|
+
if state is not None:
|
|
90
|
+
fds.append(state)
|
|
91
|
+
finally:
|
|
92
|
+
for fd in reversed(fds):
|
|
93
|
+
os.close(fd)
|
package/lib/agent-cli.ts
CHANGED
|
@@ -1272,12 +1272,30 @@ async function main() {
|
|
|
1272
1272
|
out(action + " #" + t.shortId + ": " + t.text);
|
|
1273
1273
|
break;
|
|
1274
1274
|
}
|
|
1275
|
-
case "preview add":
|
|
1275
|
+
case "preview add":
|
|
1276
|
+
case "preview update": {
|
|
1276
1277
|
const name = pos[0] || flags.name;
|
|
1277
1278
|
const port = Number(pos[1] || flags.port);
|
|
1278
|
-
if (!name || !Number.isInteger(port)) { console.error("moondeck: preview
|
|
1279
|
-
|
|
1280
|
-
|
|
1279
|
+
if (!name || !Number.isInteger(port) || port < 1 || port > 65535) { console.error("moondeck: preview " + action + " <name> <containerPort>"); process.exit(1); }
|
|
1280
|
+
if (Object.keys(flags).some(k => !["name", "port", "upstream-host", "https"].includes(k)) || (flags.https !== undefined && flags.https !== "true") || (flags["upstream-host"] !== undefined && !flagValue(flags["upstream-host"]))) {
|
|
1281
|
+
console.error("moondeck: preview " + action + " <name> <port> [--upstream-host <hostname>] [--https]"); process.exit(1);
|
|
1282
|
+
}
|
|
1283
|
+
const upstream = {
|
|
1284
|
+
...(flags["upstream-host"] !== undefined ? { upstreamHost: flags["upstream-host"] } : {}),
|
|
1285
|
+
...(flags.https === "true" ? { upstreamTls: true } : {}),
|
|
1286
|
+
};
|
|
1287
|
+
const r = action === "add"
|
|
1288
|
+
? await api("POST", "/api/agent/previews", { name, containerPort: port, ...upstream })
|
|
1289
|
+
: await api("PATCH", "/api/agent/previews/" + encodeURIComponent(name), { containerPort: port, ...upstream });
|
|
1290
|
+
out("preview '" + r.name + "' on port " + r.containerPort + (action === "add" ? " — turn it on with: moondeck preview enable " + r.name : " — updated"));
|
|
1291
|
+
if (upstream.upstreamHost || upstream.upstreamTls) console.error("Portless users: outside self tasks, start with portless proxy start --lan (or PORTLESS_LAN=1; enables mDNS). Check for LAN mode disabled; if printed, use an updated Moondeck image with avahi-utils, or ask the operator to install it and fix any reported LAN-IP problem, then restart. Use its printed LAN hostname (usually mta.local) and proxy port: HTTPS 443, HTTP 80, or your explicit -p port (e.g. 1355).");
|
|
1292
|
+
break;
|
|
1293
|
+
}
|
|
1294
|
+
case "preview remove": {
|
|
1295
|
+
const name = pos[0] || flags.name;
|
|
1296
|
+
if (!name) { console.error("moondeck: preview remove <name>"); process.exit(1); }
|
|
1297
|
+
await api("DELETE", "/api/agent/previews/" + encodeURIComponent(name));
|
|
1298
|
+
out("removed preview '" + name + "'");
|
|
1281
1299
|
break;
|
|
1282
1300
|
}
|
|
1283
1301
|
case "preview enable":
|
|
@@ -1465,7 +1483,10 @@ async function main() {
|
|
|
1465
1483
|
" moondeck review report --pr <number|URL> --head <full-sha> --must <n> --should <n> --nits <n> --verdict approve|changes [--summary text] [--assignment id] [--revision n]",
|
|
1466
1484
|
" moondeck crew outcome @agent accepted|reworked|failed [--note text] [--assignment id] [--revision n]",
|
|
1467
1485
|
" Review reports require an authorized exact-head assignment. Outcomes are Secretary assessments, never human acceptance.",
|
|
1468
|
-
" moondeck preview add <name> <containerPort>",
|
|
1486
|
+
" moondeck preview add <name> <containerPort> [--upstream-host <h>] [--https]",
|
|
1487
|
+
" moondeck preview update <name> <containerPort> [--upstream-host <h>] [--https]",
|
|
1488
|
+
" moondeck preview remove <name>",
|
|
1489
|
+
" Portless outside self: portless proxy start --lan or PORTLESS_LAN=1 (mDNS); use printed *.local Host and proxy port 443/80, or explicit -p 1355. Check for LAN mode disabled; update the image/install avahi-utils and resolve the reported LAN issue, then restart.",
|
|
1469
1490
|
" moondeck preview enable|disable <name> (turn it on; prints its URL)",
|
|
1470
1491
|
" moondeck preview public|private <name> (public: a link anyone can open)",
|
|
1471
1492
|
" moondeck project list | show <id|slug>",
|
package/lib/browser-testing.ts
CHANGED
|
@@ -1472,6 +1472,9 @@ export async function setupBrowserTesting(
|
|
|
1472
1472
|
|
|
1473
1473
|
const rootWorkerEnv = {
|
|
1474
1474
|
...authorityEnv,
|
|
1475
|
+
// HOME remains node's for asdf resolution; root-run tools must keep
|
|
1476
|
+
// their XDG state out of the task user's home.
|
|
1477
|
+
XDG_STATE_HOME: "/root/.local/state",
|
|
1475
1478
|
NPM_CONFIG_CACHE: "/tmp/uai-root-npm-cache",
|
|
1476
1479
|
npm_config_cache: "/tmp/uai-root-npm-cache",
|
|
1477
1480
|
NPM_CONFIG_LOGS_DIR: "/tmp/uai-root-npm-cache/_logs",
|
|
@@ -1505,6 +1508,8 @@ export async function setupBrowserTesting(
|
|
|
1505
1508
|
// both cases aligned so npm's case-insensitive config lookup cannot
|
|
1506
1509
|
// fall back to node's private cache or logs directory.
|
|
1507
1510
|
"-e",
|
|
1511
|
+
"XDG_STATE_HOME=/root/.local/state",
|
|
1512
|
+
"-e",
|
|
1508
1513
|
"NPM_CONFIG_CACHE=/tmp/uai-root-npm-cache",
|
|
1509
1514
|
"-e",
|
|
1510
1515
|
"npm_config_cache=/tmp/uai-root-npm-cache",
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import { readFileSync } from "node:fs";
|
|
2
|
+
|
|
3
|
+
// Task-up.sh loads these same packaged assets: no inline repair copies.
|
|
4
|
+
export const NODE_STATE_SETUP_SCRIPT = readFileSync(new URL("../images/standard/container/uai-repair-node-data.py", import.meta.url), "utf8");
|
|
5
|
+
export const NODE_DATA_CREATE_SCRIPT = readFileSync(new URL("../images/standard/container/uai-create-node-data", import.meta.url), "utf8");
|
|
6
|
+
|
|
7
|
+
/** Normalize node-owned parents, create as node, then normalize new metadata. */
|
|
8
|
+
export async function prepareNodeData(exec: (user: "root" | "node", argv: [string, ...string[]]) => Promise<number | null>): Promise<boolean> {
|
|
9
|
+
for (const [user, argv] of [
|
|
10
|
+
["root", ["/usr/bin/python3", "-I", "-c", NODE_STATE_SETUP_SCRIPT]],
|
|
11
|
+
["node", ["/bin/sh", "-c", NODE_DATA_CREATE_SCRIPT]],
|
|
12
|
+
["root", ["/usr/bin/python3", "-I", "-c", NODE_STATE_SETUP_SCRIPT]],
|
|
13
|
+
] as const) {
|
|
14
|
+
if (await exec(user, [...argv]) !== 0) return false;
|
|
15
|
+
}
|
|
16
|
+
return true;
|
|
17
|
+
}
|
package/lib/orchestrator.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { isOwnedInternalConnectionRuntime } from "./internal-connections";
|
|
2
|
+
import { prepareNodeData } from "./node-state";
|
|
2
3
|
import { registerConnectionSecret } from "./connection-secret-redaction";
|
|
3
4
|
import { buildCustomConnectionLaunch, tomlString, connectionNativeBlanks, connectionHomePreparation, newConnectionHome, connectionDnsLookupArgv, validateConnectionAddresses, connectionConnectArgv, CustomConnectionLaunchError, launchRefusal, CONNECTION_CONNECT_TIMEOUT_MS } from "./custom-connection-launch";
|
|
4
5
|
import { connectionFailureReason, ConnectionEventSanitizer } from "./custom-connection-runtime";
|
|
@@ -9554,37 +9555,15 @@ async function quarantineRunningContainer(
|
|
|
9554
9555
|
return "failed";
|
|
9555
9556
|
}
|
|
9556
9557
|
|
|
9557
|
-
/**
|
|
9558
|
-
* Repair the shared node data root before any recovered-container init.
|
|
9559
|
-
* Older task-up versions created the OpenCode leaf as root and accidentally
|
|
9560
|
-
* left this parent root-owned, which prevented code-server from creating its
|
|
9561
|
-
* managed User profile. `install -d` creates or repairs the exact directory
|
|
9562
|
-
* without recursively changing unrelated application state beneath it.
|
|
9563
|
-
*/
|
|
9558
|
+
/** Heal only pinned node directory descriptors; task code may still be running. */
|
|
9564
9559
|
async function repairNodeDataRoot(containerName: string): Promise<boolean> {
|
|
9565
|
-
const
|
|
9566
|
-
"exec",
|
|
9567
|
-
|
|
9568
|
-
|
|
9569
|
-
|
|
9570
|
-
|
|
9571
|
-
|
|
9572
|
-
"-d",
|
|
9573
|
-
"-o",
|
|
9574
|
-
"node",
|
|
9575
|
-
"-g",
|
|
9576
|
-
"node",
|
|
9577
|
-
"-m",
|
|
9578
|
-
"0755",
|
|
9579
|
-
"/home/node/.local/share",
|
|
9580
|
-
]);
|
|
9581
|
-
if (res.status !== 0) {
|
|
9582
|
-
console.error(
|
|
9583
|
-
`[orchestrator] recovery: ${containerName} could not repair /home/node/.local/share ownership: ${res.stderr.trim() || `docker exec exited ${String(res.status)}`}`,
|
|
9584
|
-
);
|
|
9585
|
-
return false;
|
|
9586
|
-
}
|
|
9587
|
-
return true;
|
|
9560
|
+
const ok = await prepareNodeData(async (user, argv) => {
|
|
9561
|
+
const result = await dockerCli(["exec", "-u", user, ...runtimeAuthorityDockerExecArgs(), containerName, ...argv]);
|
|
9562
|
+
if (result.stderr.trim()) console.warn(result.stderr.trim());
|
|
9563
|
+
return result.status;
|
|
9564
|
+
});
|
|
9565
|
+
if (!ok) console.error(`[orchestrator] recovery: ${containerName} could not repair application directory metadata`);
|
|
9566
|
+
return ok;
|
|
9588
9567
|
}
|
|
9589
9568
|
|
|
9590
9569
|
interface DockerPortResult {
|
|
@@ -10581,7 +10560,8 @@ export async function recoverAppleTaskEnvironment(
|
|
|
10581
10560
|
// effective mount at the merged path and the lower, comma-delimited
|
|
10582
10561
|
// option tokens (substring matching accepted a -evil suffix), a
|
|
10583
10562
|
// read-only lower, and a real node-owned upper.
|
|
10584
|
-
"set -e;
|
|
10563
|
+
"set -e; { printf '80\\n' > /proc/sys/net/ipv4/ip_unprivileged_port_start; } || echo 'could not set low-port boundary to 80; continuing recovery' >&2; " +
|
|
10564
|
+
"/bin/mkdir -p /var/lib/uai/asdf-upper /var/lib/uai/asdf-work /opt/asdf-data; " +
|
|
10585
10565
|
"/bin/chown node:node /var/lib/uai/asdf-upper; " +
|
|
10586
10566
|
'if ! /bin/grep -q "^overlay /opt/asdf-data overlay " /proc/mounts; then ' +
|
|
10587
10567
|
"/bin/mount -t overlay overlay -o lowerdir=/run/uai/asdf-lower,upperdir=/var/lib/uai/asdf-upper,workdir=/var/lib/uai/asdf-work /opt/asdf-data; fi; " +
|
|
@@ -10644,6 +10624,13 @@ export async function recoverAppleTaskEnvironment(
|
|
|
10644
10624
|
);
|
|
10645
10625
|
return settleFromStatus(afterOverlay);
|
|
10646
10626
|
}
|
|
10627
|
+
const nodeDataReady = await prepareNodeData(async (user, argv) => {
|
|
10628
|
+
const result = await environment.exec({ user, argv, projectEnvProfile: "maintenance", timeoutMs: 10_000, maxOutputBytes: 64 * 1024 });
|
|
10629
|
+
const diagnostics = Buffer.from(result.stderr).toString("utf8").trim();
|
|
10630
|
+
if (diagnostics) console.warn(diagnostics);
|
|
10631
|
+
return result.exitCode;
|
|
10632
|
+
});
|
|
10633
|
+
if (!nodeDataReady) return { outcome: "deferred", detail: "could not repair application directory metadata" };
|
|
10647
10634
|
// The dependency worker inside uai-init performs its one loader sync. Its
|
|
10648
10635
|
// root forwarder and authenticated generation must be live first, while
|
|
10649
10636
|
// public agent admission remains quarantined until initialization ends.
|
package/lib/preview-ports.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { isPreviewUpstreamHost } from "../src/preview-upstream";
|
|
2
3
|
|
|
3
4
|
export const PREVIEW_PORT_NAME_RE = /^[a-z0-9][a-z0-9_-]{0,62}$/;
|
|
4
5
|
|
|
@@ -13,6 +14,8 @@ export const PreviewPortDeclarationSchema = z.object({
|
|
|
13
14
|
"name must be lowercase letters, digits, _ or -, starting with a letter or digit",
|
|
14
15
|
),
|
|
15
16
|
containerPort: z.coerce.number().int().min(1).max(65535),
|
|
17
|
+
upstreamHost: z.string().refine(isPreviewUpstreamHost).transform((host) => host.toLowerCase()).optional(),
|
|
18
|
+
upstreamTls: z.boolean().optional(),
|
|
16
19
|
});
|
|
17
20
|
|
|
18
21
|
export const PreviewPortRuntimeSchema = z.object({
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
import { connect as tcpConnect, type Socket } from "node:net";
|
|
2
|
+
import { connect as tlsConnect } from "node:tls";
|
|
3
|
+
import { parsePreviewUpstream, type PreviewUpstream } from "../src/preview-upstream";
|
|
4
|
+
|
|
5
|
+
export interface PreviewUpstreamTarget {
|
|
6
|
+
host: string;
|
|
7
|
+
port: number;
|
|
8
|
+
socket?: Socket;
|
|
9
|
+
connectTimeoutMs?: number;
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
class PreviewConnectFailure extends Error {}
|
|
13
|
+
export function isPreviewConnectFailure(error: unknown): boolean {
|
|
14
|
+
return error instanceof PreviewConnectFailure;
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
/** A resolved task route is the only destination authority. Host/SNI is never
|
|
18
|
+
* passed to connect() or DNS. Wrapping an Apple-proven socket preserves its peer. */
|
|
19
|
+
export async function preparePreviewUpstream<T extends PreviewUpstreamTarget>(target: T, input: PreviewUpstream): Promise<T> {
|
|
20
|
+
const settings = parsePreviewUpstream(input);
|
|
21
|
+
if (!settings) throw new Error("invalid preview upstream settings");
|
|
22
|
+
if (!settings.upstreamTls) return target;
|
|
23
|
+
return new Promise((resolve, reject) => {
|
|
24
|
+
const tcp = target.socket ?? tcpConnect(target.port, target.host);
|
|
25
|
+
// Task-generated dev CAs are deliberately not system-trusted. The caller
|
|
26
|
+
// already proved this exact task route, just as for plaintext previews.
|
|
27
|
+
// This option applies only to this socket, never external HTTPS or WSS.
|
|
28
|
+
const secure = tlsConnect({ socket: tcp, servername: settings.upstreamHost, rejectUnauthorized: false });
|
|
29
|
+
let connected = Boolean(target.socket) || !tcp.connecting;
|
|
30
|
+
tcp.once("connect", () => { connected = true; });
|
|
31
|
+
const fail = (error: Error) => {
|
|
32
|
+
clearTimeout(timer);
|
|
33
|
+
secure.destroy();
|
|
34
|
+
tcp.destroy();
|
|
35
|
+
reject(connected ? error : new PreviewConnectFailure(error.message, { cause: error }));
|
|
36
|
+
};
|
|
37
|
+
const closed = () => fail(new Error("preview TLS handshake closed"));
|
|
38
|
+
const timer = setTimeout(() => fail(new Error("preview TLS handshake timed out")), target.connectTimeoutMs ?? 10_000);
|
|
39
|
+
timer.unref?.();
|
|
40
|
+
tcp.once("error", fail);
|
|
41
|
+
secure.once("error", fail);
|
|
42
|
+
secure.once("close", closed);
|
|
43
|
+
secure.once("secureConnect", () => {
|
|
44
|
+
clearTimeout(timer);
|
|
45
|
+
tcp.removeListener("error", fail);
|
|
46
|
+
tcp.on("error", (error) => secure.destroy(error));
|
|
47
|
+
secure.removeListener("error", fail);
|
|
48
|
+
secure.removeListener("close", closed);
|
|
49
|
+
// Keep errors handled until the HTTP/upgrade consumer attaches its own
|
|
50
|
+
// listeners on the continuation. It still receives every error event.
|
|
51
|
+
secure.on("error", () => {});
|
|
52
|
+
resolve({ ...target, socket: secure });
|
|
53
|
+
});
|
|
54
|
+
});
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
export function previewRequestHeaders(headers: [string, string][], settings: PreviewUpstream): [string, string][] {
|
|
58
|
+
if (settings.upstreamHost === undefined) return headers;
|
|
59
|
+
if (!parsePreviewUpstream(settings)) throw new Error("invalid preview upstream settings");
|
|
60
|
+
return [...headers.filter(([name]) => name.toLowerCase() !== "host"), ["host", settings.upstreamHost.toLowerCase()]];
|
|
61
|
+
}
|
package/lib/runtime-archive.ts
CHANGED
|
@@ -64,8 +64,10 @@ const STANDARD_IMAGE_ASSETS = Object.freeze([
|
|
|
64
64
|
Object.freeze({ path: "README.md", mode: 0o644 as const }),
|
|
65
65
|
Object.freeze({ path: "container/code-server-settings.json", mode: 0o644 as const }),
|
|
66
66
|
Object.freeze({ path: "container/corepack-version", mode: 0o644 as const }),
|
|
67
|
+
Object.freeze({ path: "container/uai-create-node-data", mode: 0o644 as const }),
|
|
67
68
|
Object.freeze({ path: "container/uai-init", mode: 0o755 as const }),
|
|
68
69
|
Object.freeze({ path: "container/uai-materialize-runtimes", mode: 0o755 as const }),
|
|
70
|
+
Object.freeze({ path: "container/uai-repair-node-data.py", mode: 0o644 as const }),
|
|
69
71
|
Object.freeze({ path: "container/uai-task-forwarder.mjs", mode: 0o644 as const }),
|
|
70
72
|
Object.freeze({ path: "tool-versions", mode: 0o644 as const }),
|
|
71
73
|
]);
|
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
* key). Consumers that need the workspace go through the environment handle.
|
|
17
17
|
*/
|
|
18
18
|
import { execFile } from "node:child_process";
|
|
19
|
+
import { prepareNodeData } from "../node-state";
|
|
19
20
|
import { existsSync, readFileSync, readdirSync } from "node:fs";
|
|
20
21
|
import { dirname, resolve } from "node:path";
|
|
21
22
|
import { fileURLToPath } from "node:url";
|
|
@@ -1055,6 +1056,32 @@ export function createMachineHostTaskEnvironmentProvider(
|
|
|
1055
1056
|
|
|
1056
1057
|
try {
|
|
1057
1058
|
if (clone?.signal.aborted) throw withdrawal();
|
|
1059
|
+
// Local Docker machines receive the sysctl at create time (their
|
|
1060
|
+
// /proc/sys is read-only). Dedicated machine kernels can set it here,
|
|
1061
|
+
// covering preserved AMIs as well as the image's sysctl.d default.
|
|
1062
|
+
const nodeDataReady = await stage(prepareNodeData(async (user, argv) => {
|
|
1063
|
+
const result = await own.exec({ user, argv, projectEnvProfile: "maintenance", env: { HOME: user === "root" ? "/root" : "/home/node", PATH: "/usr/sbin:/usr/bin:/bin" }, timeoutMs: GIT_STEP_TIMEOUT_MS, maxOutputBytes: STEP_OUTPUT_BYTES });
|
|
1064
|
+
const diagnostics = Buffer.from(result.stderr).toString("utf8").trim();
|
|
1065
|
+
if (diagnostics) console.warn(diagnostics);
|
|
1066
|
+
return result.exitCode;
|
|
1067
|
+
}));
|
|
1068
|
+
if (!nodeDataReady) throw new Error("could not repair task machine application directory metadata");
|
|
1069
|
+
const workbench = await stage(own.exec({
|
|
1070
|
+
argv: ["/bin/sh", "-c",
|
|
1071
|
+
'if [ "$(cat /proc/sys/net/ipv4/ip_unprivileged_port_start)" != 80 ]; then printf "80\\n" > /proc/sys/net/ipv4/ip_unprivileged_port_start; fi'],
|
|
1072
|
+
user: "root",
|
|
1073
|
+
projectEnvProfile: "maintenance",
|
|
1074
|
+
env: { HOME: "/root", PATH: "/usr/sbin:/usr/bin:/bin" },
|
|
1075
|
+
timeoutMs: GIT_STEP_TIMEOUT_MS,
|
|
1076
|
+
maxOutputBytes: STEP_OUTPUT_BYTES,
|
|
1077
|
+
}));
|
|
1078
|
+
// Reused local Docker machines keep their creation-time sysctl and
|
|
1079
|
+
// mount /proc/sys read-only. Preserve their work and allow Resume;
|
|
1080
|
+
// the lower port boundary can wait until the machine is recreated.
|
|
1081
|
+
if (workbench.exitCode !== 0) {
|
|
1082
|
+
const diagnostics = Buffer.from(workbench.stderr).toString("utf8").trim();
|
|
1083
|
+
console.warn(`could not set low-port boundary to 80; recreate this machine for ports 80–1023${diagnostics ? `: ${diagnostics}` : ""}`);
|
|
1084
|
+
}
|
|
1058
1085
|
// ADR-154 §12: the runtime exists and nothing in it has run yet: the
|
|
1059
1086
|
// runner's writes (the owner's GitHub configuration and SSH key,
|
|
1060
1087
|
// recorded for cleanup) come first, so the clones below authenticate
|
package/package.json
CHANGED
package/scripts/agent/task-up.sh
CHANGED
|
@@ -1917,6 +1917,9 @@ mv -f "$managed_env_keys_tmp" "$managed_env_keys_file"
|
|
|
1917
1917
|
printf ' pull_policy: never\n'
|
|
1918
1918
|
fi
|
|
1919
1919
|
printf ' init: true\n'
|
|
1920
|
+
# Confined to this task's network namespace; no host-wide sysctl changes.
|
|
1921
|
+
printf ' sysctls:\n'
|
|
1922
|
+
printf ' net.ipv4.ip_unprivileged_port_start: "80"\n'
|
|
1920
1923
|
# Keep the host MCP gateway reachable through Docker's ordinary
|
|
1921
1924
|
# host-gateway address. (The Apple runtime has no extra_hosts equivalent
|
|
1922
1925
|
# and none is rendered for it; its gateway route is the vmnet gateway IP.)
|
|
@@ -2876,6 +2879,9 @@ if [ "$TASK_RUNTIME_MODE" = "apple-container" ]; then
|
|
|
2876
2879
|
overlay_rc=0
|
|
2877
2880
|
"$CONTAINER_CLI" exec -u root "$app_container" /bin/sh -c '
|
|
2878
2881
|
set -e
|
|
2882
|
+
# Apple has no run --sysctl option. This guest has its own kernel and
|
|
2883
|
+
# CAP_NET_ADMIN, so set the equivalent before any task process starts.
|
|
2884
|
+
{ printf "80\n" > /proc/sys/net/ipv4/ip_unprivileged_port_start; } || echo '"'"'could not set low-port boundary to 80; continuing'"'"' >&2
|
|
2879
2885
|
/bin/mkdir -p /var/lib/uai/asdf-upper /var/lib/uai/asdf-work /opt/asdf-data
|
|
2880
2886
|
# The merged root inherits the UPPER directory'"'"'s metadata; without this
|
|
2881
2887
|
# the node user cannot write the merged view at all (live 2026-08-18).
|
|
@@ -3364,20 +3370,24 @@ if [ "$secrets_proxy_trust" = "installed" ]; then
|
|
|
3364
3370
|
secrets_proxy_report pins "$secrets_proxy_pins"
|
|
3365
3371
|
fi
|
|
3366
3372
|
|
|
3367
|
-
#
|
|
3368
|
-
#
|
|
3369
|
-
|
|
3370
|
-
|
|
3371
|
-
|
|
3372
|
-
|
|
3373
|
-
|
|
3374
|
-
|
|
3375
|
-
|
|
3376
|
-
|
|
3377
|
-
|
|
3378
|
-
|
|
3379
|
-
|
|
3380
|
-
|
|
3373
|
+
# The same packaged scripts are consumed by lib/node-state.ts. Root only
|
|
3374
|
+
# changes metadata through no-follow directory descriptors; creation is node's.
|
|
3375
|
+
NODE_STATE_SETUP_SCRIPT=$(cat "$STANDARD_CONTAINER_DIR/uai-repair-node-data.py")
|
|
3376
|
+
NODE_DATA_CREATE_SCRIPT=$(cat "$STANDARD_CONTAINER_DIR/uai-create-node-data")
|
|
3377
|
+
for node_data_phase in repair create repair; do
|
|
3378
|
+
if [ "$node_data_phase" = create ]; then
|
|
3379
|
+
node_data_user=node
|
|
3380
|
+
node_data_argv=( /bin/sh -c "$NODE_DATA_CREATE_SCRIPT" )
|
|
3381
|
+
else
|
|
3382
|
+
node_data_user=root
|
|
3383
|
+
node_data_argv=( /usr/bin/python3 -I -c "$NODE_STATE_SETUP_SCRIPT" )
|
|
3384
|
+
fi
|
|
3385
|
+
if ! crt exec -u "$node_data_user" "${managed_exec_env[@]}" "$app_container" "${node_data_argv[@]}" >/dev/null; then
|
|
3386
|
+
emit_err "CONTAINER_INIT_FAILED" \
|
|
3387
|
+
"Moondeck could not prepare node-owned application data directory metadata." \
|
|
3388
|
+
"prepare container application data"
|
|
3389
|
+
fi
|
|
3390
|
+
done
|
|
3381
3391
|
|
|
3382
3392
|
# Git rejects a repository owned by another uid even when its shared group is
|
|
3383
3393
|
# writable. Trust only the selected worktrees in this task container—never `*`
|
|
@@ -3606,7 +3616,7 @@ for oc_item in auth.json config.json; do
|
|
|
3606
3616
|
fi
|
|
3607
3617
|
done
|
|
3608
3618
|
crt exec -u root "${managed_exec_env[@]}" "$app_container" \
|
|
3609
|
-
/usr/bin/
|
|
3619
|
+
/usr/bin/python3 -I -c "$NODE_STATE_SETUP_SCRIPT" >/dev/null || log "warning: opencode metadata repair failed"
|
|
3610
3620
|
|
|
3611
3621
|
# Copy the resolved SSH identity into the container for commit/tag signing and
|
|
3612
3622
|
# as the no-GitHub-connection transport fallback. Connected users clone, fetch,
|
package/src/index.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import { assertShareablePath, normalizedWorkspacePath } from "./workspace-path-policy";
|
|
2
|
+
import { hasPreviewUpstream, parsePreviewUpstream, tunnelPreviewUpstream } from "./preview-upstream";
|
|
2
3
|
import { readBoundedFile } from "../lib/task-environment/workspace-files";
|
|
3
4
|
import {
|
|
4
5
|
agent,
|
|
@@ -2072,8 +2073,15 @@ export const hostCommands: HostCommands = {
|
|
|
2072
2073
|
}
|
|
2073
2074
|
},
|
|
2074
2075
|
|
|
2075
|
-
async previewEnsure(ctx, taskId, name, containerPort) {
|
|
2076
|
+
async previewEnsure(ctx, taskId, name, containerPort, upstream) {
|
|
2076
2077
|
logCommand(ctx, "previewEnsure", taskId, name);
|
|
2078
|
+
const settings = parsePreviewUpstream(upstream);
|
|
2079
|
+
if (!settings || !tunnelPreviewUpstream({ target: "preview", containerPort, ...settings })) return { ok: false, code: HostErrorCode.Internal, message: "invalid preview upstream settings" };
|
|
2080
|
+
const configured = hasPreviewUpstream(settings);
|
|
2081
|
+
// Machine/Apple routes need no published local port or TCP sidecar.
|
|
2082
|
+
// Their tunnel still applies the same Host/TLS layer after route proof.
|
|
2083
|
+
const provider = getHostTask(taskId)?.environmentProvider;
|
|
2084
|
+
if (configured && (isEnvironmentTransportProvider(provider) || provider === "apple-container")) return ok({ hostPort: null });
|
|
2077
2085
|
const runtimeFailure = runtimeUnavailable();
|
|
2078
2086
|
if (runtimeFailure) return runtimeFailure;
|
|
2079
2087
|
const orchestrator = getOrchestrator();
|
|
@@ -2117,7 +2125,7 @@ export const hostCommands: HostCommands = {
|
|
|
2117
2125
|
// Task-up published port (preview enabled at launch) wins, but use the
|
|
2118
2126
|
// mapping Docker just proved rather than the persisted hostPort.
|
|
2119
2127
|
if (declared) {
|
|
2120
|
-
return ok({ hostPort: appProof.published?.port ?? null });
|
|
2128
|
+
return ok({ hostPort: configured ? null : appProof.published?.port ?? null });
|
|
2121
2129
|
}
|
|
2122
2130
|
// Else start (or find) the node-proxy sidecar NOW — the same one the
|
|
2123
2131
|
// tunnel lazy-starts on first access — and hand back its host port.
|
|
@@ -2135,7 +2143,9 @@ export const hostCommands: HostCommands = {
|
|
|
2135
2143
|
);
|
|
2136
2144
|
if (unavailable) return unavailable;
|
|
2137
2145
|
}
|
|
2138
|
-
|
|
2146
|
+
// The raw published port cannot translate Host/TLS. Only the cloud
|
|
2147
|
+
// preview URL applies those settings; don't offer a broken local URL.
|
|
2148
|
+
return ok({ hostPort: configured ? null : hostPort });
|
|
2139
2149
|
} catch (err) {
|
|
2140
2150
|
return failFromUnknown(err);
|
|
2141
2151
|
}
|
package/src/main.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
#!/usr/bin/env tsx
|
|
2
2
|
import { stationAgentCliAdvertisable } from "../lib/agent-cli";
|
|
3
|
+
import { PREVIEW_UPSTREAM_PROTOCOL_FEATURE, parsePreviewUpstream, tunnelPreviewUpstream } from "./preview-upstream";
|
|
4
|
+
import { preparePreviewUpstream, previewRequestHeaders, isPreviewConnectFailure } from "../lib/preview-upstream";
|
|
3
5
|
import { describeClose, suspendedFor } from "./bridge-diagnostics";
|
|
4
6
|
import { internalHostGatewayAdvertisable } from "../lib/custom-connection-launch";
|
|
5
7
|
import "./load-env";
|
|
@@ -1553,6 +1555,7 @@ function buildCapabilities(): HostCapabilities {
|
|
|
1553
1555
|
internalConnections: isOwnedInternalConnectionRuntime(internalRuntimeProvider()),
|
|
1554
1556
|
...(diskSnapshot ? { disk: diskSnapshot } : {}),
|
|
1555
1557
|
protocolFeatures: [
|
|
1558
|
+
PREVIEW_UPSTREAM_PROTOCOL_FEATURE,
|
|
1556
1559
|
"host-workspace-file-read-v1",
|
|
1557
1560
|
ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
|
|
1558
1561
|
FIRST_TURN_DEDUP_PROTOCOL_FEATURE,
|
|
@@ -4064,10 +4067,16 @@ async function handleTunnelOpen(
|
|
|
4064
4067
|
wsSocket: WebSocket,
|
|
4065
4068
|
frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
|
|
4066
4069
|
): Promise<void> {
|
|
4067
|
-
let target: UpstreamAddr | null;
|
|
4070
|
+
let target: UpstreamAddr | null = null;
|
|
4068
4071
|
try {
|
|
4072
|
+
const settings = tunnelPreviewUpstream(frame);
|
|
4073
|
+
if (!settings) throw new Error("invalid preview upstream settings");
|
|
4074
|
+
frame = { ...frame, reqLine: { ...frame.reqLine, headers: previewRequestHeaders(frame.reqLine.headers, settings) } };
|
|
4069
4075
|
target = await resolveTunnelTarget(frame);
|
|
4076
|
+
if (target) target = await preparePreviewUpstream(target, settings);
|
|
4070
4077
|
} catch (error) {
|
|
4078
|
+
if (target) invalidatePreviewSidecar(target.port);
|
|
4079
|
+
if (isPreviewConnectFailure(error)) invalidateMachineTunnelRoute(frame.taskId);
|
|
4071
4080
|
// A thrown resolution (daemon flap mid-inspect) must settle the
|
|
4072
4081
|
// opening entry: abortAll() joins it during bridge replacement, and the
|
|
4073
4082
|
// rejection must not escape the `void handleTunnelOpen(...)` call as a
|
|
@@ -4114,12 +4123,13 @@ function handleHttpTunnelOpen(
|
|
|
4114
4123
|
target: UpstreamAddr,
|
|
4115
4124
|
): void {
|
|
4116
4125
|
let acked = false;
|
|
4126
|
+
let connected = Boolean(target.socket);
|
|
4117
4127
|
const requestOptions = {
|
|
4118
4128
|
host: target.host,
|
|
4119
4129
|
port: target.port,
|
|
4120
4130
|
method: frame.reqLine.method,
|
|
4121
4131
|
path: frame.reqLine.url,
|
|
4122
|
-
headers: requestHeaders(frame.reqLine.headers),
|
|
4132
|
+
headers: requestHeaders(frame.reqLine.headers, frame.upstreamHost),
|
|
4123
4133
|
// Reuse the connection proven inside the task lifecycle slot; http will
|
|
4124
4134
|
// manage (and eventually close) the socket it is handed.
|
|
4125
4135
|
...(target.socket ? { createConnection: () => target.socket! } : {}),
|
|
@@ -4152,6 +4162,11 @@ function handleHttpTunnelOpen(
|
|
|
4152
4162
|
},
|
|
4153
4163
|
);
|
|
4154
4164
|
|
|
4165
|
+
upstream.once("socket", (socket: Socket) => {
|
|
4166
|
+
if (!socket.connecting) connected = true;
|
|
4167
|
+
else socket.once("connect", () => { connected = true; });
|
|
4168
|
+
});
|
|
4169
|
+
|
|
4155
4170
|
// ADR-121: a memoised machine address may black-hole (instance stopped
|
|
4156
4171
|
// while memoised), and a black-holed dial neither errors nor answers for
|
|
4157
4172
|
// the kernel's SYN budget. Bound the connect phase only — never `timeout`
|
|
@@ -4175,7 +4190,7 @@ function handleHttpTunnelOpen(
|
|
|
4175
4190
|
invalidatePreviewSidecar(target.port);
|
|
4176
4191
|
// ADR-121: a memoised machine address that no longer answers is stale —
|
|
4177
4192
|
// a refused dial and a connect timeout (tunnel-dial.ts) both land here.
|
|
4178
|
-
invalidateMachineTunnelRoute(frame.taskId);
|
|
4193
|
+
if (!connected) invalidateMachineTunnelRoute(frame.taskId);
|
|
4179
4194
|
if (!acked) {
|
|
4180
4195
|
send(wsSocket, {
|
|
4181
4196
|
kind: "tunnel.ack",
|
|
@@ -4207,10 +4222,12 @@ function handleRawTunnelOpen(
|
|
|
4207
4222
|
const upstream = target.socket ?? new Socket();
|
|
4208
4223
|
|
|
4209
4224
|
let acked = false;
|
|
4225
|
+
let connected = Boolean(target.socket);
|
|
4210
4226
|
let responseBuffer = Buffer.alloc(0);
|
|
4211
4227
|
|
|
4212
4228
|
if (!target.socket) {
|
|
4213
4229
|
upstream.on("connect", () => {
|
|
4230
|
+
connected = true;
|
|
4214
4231
|
upstream.write(serializeRequest(frame.reqLine));
|
|
4215
4232
|
});
|
|
4216
4233
|
}
|
|
@@ -4243,7 +4260,7 @@ function handleRawTunnelOpen(
|
|
|
4243
4260
|
// the next request recreates it (no-op for published / container-IP targets).
|
|
4244
4261
|
invalidatePreviewSidecar(target.port);
|
|
4245
4262
|
// ADR-121: a memoised machine address that no longer answers is stale.
|
|
4246
|
-
invalidateMachineTunnelRoute(frame.taskId);
|
|
4263
|
+
if (!connected) invalidateMachineTunnelRoute(frame.taskId);
|
|
4247
4264
|
if (!acked) {
|
|
4248
4265
|
send(wsSocket, {
|
|
4249
4266
|
kind: "tunnel.ack",
|
|
@@ -4505,13 +4522,13 @@ function serializeRequest(reqLine: {
|
|
|
4505
4522
|
return Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1");
|
|
4506
4523
|
}
|
|
4507
4524
|
|
|
4508
|
-
function requestHeaders(headers: [string, string][]): OutgoingHttpHeaders {
|
|
4525
|
+
function requestHeaders(headers: [string, string][], upstreamHost?: string): OutgoingHttpHeaders {
|
|
4509
4526
|
const out: OutgoingHttpHeaders = {};
|
|
4510
4527
|
for (const [name, value] of headers) {
|
|
4511
4528
|
const lower = name.toLowerCase();
|
|
4512
4529
|
if (isHopByHopHeader(lower)) continue;
|
|
4513
4530
|
if (lower === "host") {
|
|
4514
|
-
out.host = "127.0.0.1";
|
|
4531
|
+
out.host = upstreamHost?.toLowerCase() ?? "127.0.0.1";
|
|
4515
4532
|
continue;
|
|
4516
4533
|
}
|
|
4517
4534
|
|
|
@@ -4709,11 +4726,13 @@ async function dispatchCommand(
|
|
|
4709
4726
|
parseTranscriptTargets(args[3]),
|
|
4710
4727
|
);
|
|
4711
4728
|
case "previewEnsure":
|
|
4729
|
+
if (parsePreviewUpstream(args[3]) === null) throw new Error("invalid preview upstream settings");
|
|
4712
4730
|
return hostCommands.previewEnsure(
|
|
4713
4731
|
ctx,
|
|
4714
4732
|
expectString(args, 0),
|
|
4715
4733
|
expectString(args, 1),
|
|
4716
4734
|
expectNumberArg(args, 2),
|
|
4735
|
+
parsePreviewUpstream(args[3])!,
|
|
4717
4736
|
);
|
|
4718
4737
|
case "hostUpdate":
|
|
4719
4738
|
return hostCommands.hostUpdate(ctx, typeof args[0] === "string" ? args[0] : "");
|
|
@@ -5014,6 +5033,8 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
5014
5033
|
isReqLine(frame.reqLine) &&
|
|
5015
5034
|
typeof frame.upgrade === "boolean"
|
|
5016
5035
|
) {
|
|
5036
|
+
const upstream = tunnelPreviewUpstream(frame as Extract<CloudToHost, { kind: "tunnel.open" }>);
|
|
5037
|
+
if (!upstream) return null;
|
|
5017
5038
|
return {
|
|
5018
5039
|
kind: "tunnel.open",
|
|
5019
5040
|
tunnelId: frame.tunnelId,
|
|
@@ -5024,6 +5045,7 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
|
|
|
5024
5045
|
// node-proxy sidecar (NOT the unreachable bridge IP — see resolveTunnelTarget).
|
|
5025
5046
|
containerPort:
|
|
5026
5047
|
typeof frame.containerPort === "number" ? frame.containerPort : undefined,
|
|
5048
|
+
...upstream,
|
|
5027
5049
|
reqLine: frame.reqLine,
|
|
5028
5050
|
upgrade: frame.upgrade,
|
|
5029
5051
|
};
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
/** Optional preview behavior, negotiated per live Station connection. */
|
|
2
|
+
export const PREVIEW_UPSTREAM_PROTOCOL_FEATURE = "preview-upstream-v1";
|
|
3
|
+
|
|
4
|
+
export interface PreviewUpstream {
|
|
5
|
+
upstreamHost?: string;
|
|
6
|
+
upstreamTls?: boolean;
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
/** DNS spelling only. URL parsing here detects numeric/hex/octal IP aliases;
|
|
10
|
+
* it never resolves a name. No URL, authority, port or header syntax is allowed. */
|
|
11
|
+
export function isPreviewUpstreamHost(value: unknown): value is string {
|
|
12
|
+
if (typeof value !== "string" || value.length < 1 || value.length > 253) return false;
|
|
13
|
+
if (!value.split(".").every((label) => /^[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?$/i.test(label))) return false;
|
|
14
|
+
try {
|
|
15
|
+
return !/^\d+\.\d+\.\d+\.\d+$/.test(new URL(`http://${value}`).hostname);
|
|
16
|
+
} catch { return false; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function hasPreviewUpstream(value: PreviewUpstream): boolean {
|
|
20
|
+
return value.upstreamHost !== undefined || value.upstreamTls === true;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/** Read only these fields from a frame or declaration; reject malformed values. */
|
|
24
|
+
export function parsePreviewUpstream(value: unknown): PreviewUpstream | null {
|
|
25
|
+
if (value === undefined) return {};
|
|
26
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
27
|
+
const { upstreamHost, upstreamTls } = value as Record<string, unknown>;
|
|
28
|
+
if (upstreamHost !== undefined && !isPreviewUpstreamHost(upstreamHost)) return null;
|
|
29
|
+
if (upstreamTls !== undefined && typeof upstreamTls !== "boolean") return null;
|
|
30
|
+
return {
|
|
31
|
+
...(typeof upstreamHost === "string" ? { upstreamHost: upstreamHost.toLowerCase() } : {}),
|
|
32
|
+
...(typeof upstreamTls === "boolean" ? { upstreamTls } : {}),
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
export function tunnelPreviewUpstream(frame: { target: string; containerPort?: number } & PreviewUpstream): PreviewUpstream | null {
|
|
37
|
+
const settings = parsePreviewUpstream(frame);
|
|
38
|
+
if (!settings) return null;
|
|
39
|
+
if (hasPreviewUpstream(settings) && (frame.target !== "preview" || !Number.isInteger(frame.containerPort) || frame.containerPort! < 1 || frame.containerPort! > 65535)) return null;
|
|
40
|
+
return settings;
|
|
41
|
+
}
|
package/src/protocol.ts
CHANGED
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
export * from "./custom-connections";
|
|
2
|
+
export * from "./preview-upstream";
|
|
3
|
+
import type { PreviewUpstream } from "./preview-upstream";
|
|
2
4
|
import type { AccountBinding, EngineConnectCustomDiscoverFrame, EngineConnectCustomDiscoveredFrame, EngineConnectCustomSaveFrame, EngineConnectCustomManualSaveFrame, EngineConnectCustomSavedFrame, OpenRouterBudgetFrame } from "./custom-connections";
|
|
3
5
|
import type { TaskSecretApplied, TaskSecretStagingPut, TaskSecretStagingAck } from "./task-secrets";
|
|
4
6
|
import { isNeverProxiedDomain } from "./never-proxied-domains";
|
|
@@ -4835,6 +4837,7 @@ export interface HostCommands {
|
|
|
4835
4837
|
taskId: string,
|
|
4836
4838
|
name: string,
|
|
4837
4839
|
containerPort: number,
|
|
4840
|
+
upstream?: PreviewUpstream,
|
|
4838
4841
|
): Promise<HostCommandResult<{ hostPort: number | null }>>;
|
|
4839
4842
|
/** ADR-116: remove a labeled engine account by id. Refuses `default:*`
|
|
4840
4843
|
* exactly like the local API; re-advertises capabilities on success. */
|
|
@@ -4997,6 +5000,8 @@ export type CloudToHost =
|
|
|
4997
5000
|
// proxies to <container-ip>:<containerPort> over the Docker network.
|
|
4998
5001
|
// Absent for the editor target (which uses the published codeServerPort).
|
|
4999
5002
|
containerPort?: number;
|
|
5003
|
+
upstreamHost?: string;
|
|
5004
|
+
upstreamTls?: boolean;
|
|
5000
5005
|
reqLine: TunnelReqLine;
|
|
5001
5006
|
upgrade: boolean;
|
|
5002
5007
|
}
|