@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.
@@ -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 add <name> <containerPort>"); process.exit(1); }
1279
- const r = await api("POST", "/api/agent/previews", { name, containerPort: port });
1280
- out("preview '" + r.name + "' on port " + r.containerPort + " — turn it on with: moondeck preview enable " + r.name);
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>",
@@ -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",
@@ -197,6 +197,8 @@ export function createLocalMachineProvider(
197
197
  const args = [
198
198
  "run",
199
199
  "-d",
200
+ "--sysctl",
201
+ "net.ipv4.ip_unprivileged_port_start=80",
200
202
  "--name",
201
203
  name,
202
204
  "--label",
@@ -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
+ }
@@ -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 res = await dockerCli([
9566
- "exec",
9567
- "-u",
9568
- "root",
9569
- ...runtimeAuthorityDockerExecArgs(),
9570
- containerName,
9571
- "/usr/bin/install",
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; /bin/mkdir -p /var/lib/uai/asdf-upper /var/lib/uai/asdf-work /opt/asdf-data; " +
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.
@@ -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
+ }
@@ -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
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@moondeck/station",
3
- "version": "1.0.1",
3
+ "version": "1.0.2",
4
4
  "description": "Moondeck Station: runs Moondeck tasks in containers on a machine you control.",
5
5
  "license": "MIT",
6
6
  "author": "Moondeck <team@moondeck.io>",
@@ -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
- # OpenCode and code-server share ~/.local/share. OpenCode credentials are
3368
- # copied as root below, so creating only its leaf with `mkdir -p` can leave the
3369
- # shared parent root-owned (live 2026-08-04). Then node cannot create
3370
- # code-server's User profile and the editor silently falls back to its light,
3371
- # full-chrome defaults. Repair both explicitly on every start/resume; GNU
3372
- # `install -d` also corrects an existing directory with the wrong owner.
3373
- if ! crt exec -u root "${managed_exec_env[@]}" "$app_container" \
3374
- /usr/bin/install -d -o node -g node -m 0755 \
3375
- /home/node/.local/share \
3376
- /home/node/.local/share/opencode >/dev/null; then
3377
- emit_err "CONTAINER_INIT_FAILED" \
3378
- "Moondeck could not prepare the task's node-owned application data directory. Retry the task; the editor cannot load its settings until this succeeds." \
3379
- "prepare container application data"
3380
- fi
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/chown -R node:node /home/node/.local/share/opencode >/dev/null 2>&1 || true
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
- return ok({ hostPort });
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
  }