@moondeck/station 1.0.0 → 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.
@@ -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.0",
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,
@@ -0,0 +1,39 @@
1
+ /**
2
+ * What station.log says about a lost cloud connection, so its cause can be
3
+ * read off the log. The previous MacBook Air station showed 599 reconnects
4
+ * over about 18 hours as bare "disconnected; reconnecting in ~1s" lines:
5
+ * neither who closed the socket nor why was logged, and nothing showed that
6
+ * the Mac had been asleep (2026-11-03).
7
+ */
8
+
9
+ /** The close code and reason in words: who closed it, and why if known. */
10
+ export function describeClose(code: number, reason: string): string {
11
+ const why = reason.trim();
12
+ const known: Record<number, string> = {
13
+ 1000: "closed normally",
14
+ 1001: "the cloud went away (restart or deploy)",
15
+ 1006: "the connection dropped without a close frame (network, sleep or a proxy)",
16
+ 1011: "the cloud hit an internal error",
17
+ 1012: "the cloud is restarting",
18
+ 1013: "the cloud asked to try again later",
19
+ 4000: "closed by the cloud or this station",
20
+ 4001: "authentication refused",
21
+ };
22
+ const label = known[code] ?? "closed";
23
+ return `code ${code}, ${label}${why ? `: ${why}` : ""}`;
24
+ }
25
+
26
+ /** How far past its due time a periodic timer may fire before the gap counts
27
+ * as a suspension (system sleep, a stopped process) rather than load. */
28
+ export const SUSPEND_SLACK_MS = 20_000;
29
+
30
+ /**
31
+ * The time this process was suspended, when a timer due every `intervalMs`
32
+ * fires `now - previousTick` later; null when it fired on time. A sleeping
33
+ * Mac stops every timer; on wake the gap shows, the cloud has long dropped
34
+ * the silent socket (it closes one silent for 45 s), and a reconnect follows.
35
+ */
36
+ export function suspendedFor(previousTick: number, now: number, intervalMs: number): number | null {
37
+ const gap = now - previousTick;
38
+ return gap > intervalMs + SUSPEND_SLACK_MS ? gap : null;
39
+ }
package/src/cli.ts CHANGED
@@ -91,6 +91,7 @@ import { PINNED_RELEASE_MANIFEST_KEYS } from "../lib/release-trust";
91
91
  import { sidecarMode } from "../lib/sidecar";
92
92
  import { systemUserBusProbe } from "../lib/user-bus-env";
93
93
  import { LEGACY_HOST, STATION, STATION_DEFAULT_CLOUD_URL } from "./station-identity";
94
+ import { enrollmentNextSteps } from "./enrollment-next-steps";
94
95
  import {
95
96
  inspectLegacyHost,
96
97
  legacyHostRunningReason,
@@ -1729,10 +1730,11 @@ async function cmdSetup(rest: string[]): Promise<void> {
1729
1730
  console.log(green("✓ enrolled") + dim(` — config written to ${envLocalPath()}`));
1730
1731
  }
1731
1732
  printTelemetryNotice();
1732
- console.log("");
1733
- console.log("Start the host:");
1734
- console.log(cyan(" moondeck-station install && moondeck-station start") + dim(" (first time)"));
1735
- console.log(cyan(" moondeck-station restart") + dim(" (already installed)"));
1733
+ const steps = enrollmentNextSteps();
1734
+ if (steps.length > 0) console.log("");
1735
+ for (const step of steps) {
1736
+ console.log(step.command ? cyan(step.command) + dim(step.note ?? "") : step.text);
1737
+ }
1736
1738
  }
1737
1739
 
1738
1740
  // --- enroll (attach an org, ADR-098) -----------------------------------------
@@ -0,0 +1,24 @@
1
+ /**
2
+ * What `moondeck-station enroll` / `setup` tells the person to do once the
3
+ * machine is enrolled. Run by hand, the next step is theirs: install and
4
+ * start the service, or restart it. Run by the installer (which exports
5
+ * UAI_FROM_INSTALLER=1 for its enrollment call), the installer registers and
6
+ * starts the service itself right after, so telling the person to do it was
7
+ * a stale instruction (the owner's public-install test, 2026-11-03).
8
+ *
9
+ * An environment variable rather than a flag: the installer the cloud serves
10
+ * may run a station runtime older than this one, which ignores an unknown
11
+ * variable but would refuse an unknown flag.
12
+ */
13
+ import { STATION } from "./station-identity";
14
+
15
+ export const FROM_INSTALLER_ENV = "UAI_FROM_INSTALLER";
16
+
17
+ export function enrollmentNextSteps(env: NodeJS.ProcessEnv = process.env): Array<{ text: string; command?: string; note?: string }> {
18
+ if (env[FROM_INSTALLER_ENV] === "1") return [];
19
+ return [
20
+ { text: "Start the station:" },
21
+ { text: "", command: ` ${STATION.command} install && ${STATION.command} start`, note: " (first time)" },
22
+ { text: "", command: ` ${STATION.command} restart`, note: " (already installed)" },
23
+ ];
24
+ }
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,
@@ -18,6 +19,7 @@ import {
18
19
  type LegacyStartPermit,
19
20
  } from "../lib/cloud-credential-host";
20
21
  import type { TaskAssignment } from "../lib/cloud-credential-leases";
22
+ import { deliverFirstTurnOnce } from "../lib/first-turn-receipts";
21
23
  import {
22
24
  StartInterruptedError,
23
25
  type CloudCredentialRuntime,
@@ -2039,7 +2041,7 @@ export const hostCommands: HostCommands = {
2039
2041
  });
2040
2042
  },
2041
2043
 
2042
- async channelDeliver(ctx, taskId, agentId, text, origin, assignment) {
2044
+ async channelDeliver(ctx, taskId, agentId, text, origin, assignment, firstTurnId) {
2043
2045
  logCommand(ctx, "channelDeliver", taskId, agentId);
2044
2046
  if (credentialsMode() === "pending") {
2045
2047
  return switchingToCloudCredentials();
@@ -2050,22 +2052,36 @@ export const hostCommands: HostCommands = {
2050
2052
  // An inbound turn is activity: the idle veto must cover the gap between
2051
2053
  // this delivery landing and the agent's first output event.
2052
2054
  getOrchestrator().markChannelActivity(taskId);
2053
- const result = await getOrchestrator().deliver(taskId, agentId, text, origin, assignment);
2054
- if (!result.ok) {
2055
- return {
2056
- ok: false,
2057
- code: mapDeliverError(result.error),
2058
- message: result.error,
2059
- };
2060
- }
2061
- return ok(undefined);
2055
+ const deliverOnce = async (): Promise<HostCommandResult<void>> => {
2056
+ const result = await getOrchestrator().deliver(taskId, agentId, text, origin, assignment);
2057
+ if (!result.ok) {
2058
+ return {
2059
+ ok: false,
2060
+ code: mapDeliverError(result.error),
2061
+ message: result.error,
2062
+ };
2063
+ }
2064
+ return ok(undefined);
2065
+ };
2066
+ // A first-turn id is accepted at most once: a cloud that crashed before
2067
+ // recording its ack redelivers the same id and is acknowledged here.
2068
+ return firstTurnId
2069
+ ? await deliverFirstTurnOnce(taskId, firstTurnId, deliverOnce, () => ok(undefined))
2070
+ : await deliverOnce();
2062
2071
  } catch (err) {
2063
2072
  return failFromUnknown(err);
2064
2073
  }
2065
2074
  },
2066
2075
 
2067
- async previewEnsure(ctx, taskId, name, containerPort) {
2076
+ async previewEnsure(ctx, taskId, name, containerPort, upstream) {
2068
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 });
2069
2085
  const runtimeFailure = runtimeUnavailable();
2070
2086
  if (runtimeFailure) return runtimeFailure;
2071
2087
  const orchestrator = getOrchestrator();
@@ -2109,7 +2125,7 @@ export const hostCommands: HostCommands = {
2109
2125
  // Task-up published port (preview enabled at launch) wins, but use the
2110
2126
  // mapping Docker just proved rather than the persisted hostPort.
2111
2127
  if (declared) {
2112
- return ok({ hostPort: appProof.published?.port ?? null });
2128
+ return ok({ hostPort: configured ? null : appProof.published?.port ?? null });
2113
2129
  }
2114
2130
  // Else start (or find) the node-proxy sidecar NOW — the same one the
2115
2131
  // tunnel lazy-starts on first access — and hand back its host port.
@@ -2127,7 +2143,9 @@ export const hostCommands: HostCommands = {
2127
2143
  );
2128
2144
  if (unavailable) return unavailable;
2129
2145
  }
2130
- 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 });
2131
2149
  } catch (err) {
2132
2150
  return failFromUnknown(err);
2133
2151
  }
package/src/main.ts CHANGED
@@ -1,5 +1,8 @@
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";
5
+ import { describeClose, suspendedFor } from "./bridge-diagnostics";
3
6
  import { internalHostGatewayAdvertisable } from "../lib/custom-connection-launch";
4
7
  import "./load-env";
5
8
  import { discoverCustomConnection, saveCustomConnection, saveCustomConnectionManual } from "../lib/custom-connection-capture";
@@ -335,6 +338,7 @@ import {
335
338
  MCP_GATEWAY_HEALTH_PROTOCOL_FEATURE,
336
339
  SECRETARY_TYPED_DISPATCH_PROTOCOL_FEATURE,
337
340
  ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
341
+ FIRST_TURN_DEDUP_PROTOCOL_FEATURE,
338
342
  LEAD_EVIDENCE_PROTOCOL_FEATURE,
339
343
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
340
344
  type CloudToHost,
@@ -1551,8 +1555,10 @@ function buildCapabilities(): HostCapabilities {
1551
1555
  internalConnections: isOwnedInternalConnectionRuntime(internalRuntimeProvider()),
1552
1556
  ...(diskSnapshot ? { disk: diskSnapshot } : {}),
1553
1557
  protocolFeatures: [
1558
+ PREVIEW_UPSTREAM_PROTOCOL_FEATURE,
1554
1559
  "host-workspace-file-read-v1",
1555
1560
  ASSIGNMENT_EVIDENCE_PROTOCOL_FEATURE,
1561
+ FIRST_TURN_DEDUP_PROTOCOL_FEATURE,
1556
1562
  LEAD_EVIDENCE_PROTOCOL_FEATURE,
1557
1563
  TRANSCRIPT_TARGETS_PROTOCOL_FEATURE,
1558
1564
  EVENT_REPLAY_PROTOCOL_FEATURE,
@@ -3096,8 +3102,18 @@ async function connect(): Promise<void> {
3096
3102
  }
3097
3103
  }, PING_INTERVAL_MS);
3098
3104
 
3105
+ let lastTick = Date.now();
3099
3106
  deadTimer = setInterval(() => {
3100
- if (Date.now() - lastTraffic > DEAD_AFTER_MS) {
3107
+ const now = Date.now();
3108
+ const suspended = suspendedFor(lastTick, now, 5_000);
3109
+ lastTick = now;
3110
+ if (suspended !== null) {
3111
+ console.warn(
3112
+ `[host-agent] this process was suspended for ~${Math.round(suspended / 1000)}s (system sleep?); ` +
3113
+ "the cloud drops a connection that is silent for 45s",
3114
+ );
3115
+ }
3116
+ if (now - lastTraffic > DEAD_AFTER_MS) {
3101
3117
  console.warn("[host-agent] bridge heartbeat timed out");
3102
3118
  socket.close(4000, "heartbeat timeout");
3103
3119
  }
@@ -3970,7 +3986,7 @@ async function connect(): Promise<void> {
3970
3986
  const delay = reconnectDelay();
3971
3987
  markReconnecting(text || `disconnected${ready ? "" : " before auth"}`);
3972
3988
  console.warn(
3973
- `[host-agent] disconnected${ready ? "" : " before auth"}; reconnecting in ${Math.round(delay)}ms`,
3989
+ `[host-agent] disconnected${ready ? "" : " before auth"} (${describeClose(code, text)}); reconnecting in ${Math.round(delay)}ms`,
3974
3990
  );
3975
3991
  addHostBreadcrumb("bridge", "disconnected", {
3976
3992
  code,
@@ -4051,10 +4067,16 @@ async function handleTunnelOpen(
4051
4067
  wsSocket: WebSocket,
4052
4068
  frame: Extract<CloudToHost, { kind: "tunnel.open" }>,
4053
4069
  ): Promise<void> {
4054
- let target: UpstreamAddr | null;
4070
+ let target: UpstreamAddr | null = null;
4055
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) } };
4056
4075
  target = await resolveTunnelTarget(frame);
4076
+ if (target) target = await preparePreviewUpstream(target, settings);
4057
4077
  } catch (error) {
4078
+ if (target) invalidatePreviewSidecar(target.port);
4079
+ if (isPreviewConnectFailure(error)) invalidateMachineTunnelRoute(frame.taskId);
4058
4080
  // A thrown resolution (daemon flap mid-inspect) must settle the
4059
4081
  // opening entry: abortAll() joins it during bridge replacement, and the
4060
4082
  // rejection must not escape the `void handleTunnelOpen(...)` call as a
@@ -4101,12 +4123,13 @@ function handleHttpTunnelOpen(
4101
4123
  target: UpstreamAddr,
4102
4124
  ): void {
4103
4125
  let acked = false;
4126
+ let connected = Boolean(target.socket);
4104
4127
  const requestOptions = {
4105
4128
  host: target.host,
4106
4129
  port: target.port,
4107
4130
  method: frame.reqLine.method,
4108
4131
  path: frame.reqLine.url,
4109
- headers: requestHeaders(frame.reqLine.headers),
4132
+ headers: requestHeaders(frame.reqLine.headers, frame.upstreamHost),
4110
4133
  // Reuse the connection proven inside the task lifecycle slot; http will
4111
4134
  // manage (and eventually close) the socket it is handed.
4112
4135
  ...(target.socket ? { createConnection: () => target.socket! } : {}),
@@ -4139,6 +4162,11 @@ function handleHttpTunnelOpen(
4139
4162
  },
4140
4163
  );
4141
4164
 
4165
+ upstream.once("socket", (socket: Socket) => {
4166
+ if (!socket.connecting) connected = true;
4167
+ else socket.once("connect", () => { connected = true; });
4168
+ });
4169
+
4142
4170
  // ADR-121: a memoised machine address may black-hole (instance stopped
4143
4171
  // while memoised), and a black-holed dial neither errors nor answers for
4144
4172
  // the kernel's SYN budget. Bound the connect phase only — never `timeout`
@@ -4162,7 +4190,7 @@ function handleHttpTunnelOpen(
4162
4190
  invalidatePreviewSidecar(target.port);
4163
4191
  // ADR-121: a memoised machine address that no longer answers is stale —
4164
4192
  // a refused dial and a connect timeout (tunnel-dial.ts) both land here.
4165
- invalidateMachineTunnelRoute(frame.taskId);
4193
+ if (!connected) invalidateMachineTunnelRoute(frame.taskId);
4166
4194
  if (!acked) {
4167
4195
  send(wsSocket, {
4168
4196
  kind: "tunnel.ack",
@@ -4194,10 +4222,12 @@ function handleRawTunnelOpen(
4194
4222
  const upstream = target.socket ?? new Socket();
4195
4223
 
4196
4224
  let acked = false;
4225
+ let connected = Boolean(target.socket);
4197
4226
  let responseBuffer = Buffer.alloc(0);
4198
4227
 
4199
4228
  if (!target.socket) {
4200
4229
  upstream.on("connect", () => {
4230
+ connected = true;
4201
4231
  upstream.write(serializeRequest(frame.reqLine));
4202
4232
  });
4203
4233
  }
@@ -4230,7 +4260,7 @@ function handleRawTunnelOpen(
4230
4260
  // the next request recreates it (no-op for published / container-IP targets).
4231
4261
  invalidatePreviewSidecar(target.port);
4232
4262
  // ADR-121: a memoised machine address that no longer answers is stale.
4233
- invalidateMachineTunnelRoute(frame.taskId);
4263
+ if (!connected) invalidateMachineTunnelRoute(frame.taskId);
4234
4264
  if (!acked) {
4235
4265
  send(wsSocket, {
4236
4266
  kind: "tunnel.ack",
@@ -4492,13 +4522,13 @@ function serializeRequest(reqLine: {
4492
4522
  return Buffer.from(`${lines.join("\r\n")}\r\n\r\n`, "latin1");
4493
4523
  }
4494
4524
 
4495
- function requestHeaders(headers: [string, string][]): OutgoingHttpHeaders {
4525
+ function requestHeaders(headers: [string, string][], upstreamHost?: string): OutgoingHttpHeaders {
4496
4526
  const out: OutgoingHttpHeaders = {};
4497
4527
  for (const [name, value] of headers) {
4498
4528
  const lower = name.toLowerCase();
4499
4529
  if (isHopByHopHeader(lower)) continue;
4500
4530
  if (lower === "host") {
4501
- out.host = "127.0.0.1";
4531
+ out.host = upstreamHost?.toLowerCase() ?? "127.0.0.1";
4502
4532
  continue;
4503
4533
  }
4504
4534
 
@@ -4639,8 +4669,9 @@ async function dispatchCommand(
4639
4669
  expectString(args, 0),
4640
4670
  expectString(args, 1),
4641
4671
  expectString(args, 2),
4642
- args[3] as DeliveryOrigin | undefined,
4643
- args[4] as AssignmentDelivery | undefined,
4672
+ typeof args[3] === "string" ? (args[3] as DeliveryOrigin) : undefined,
4673
+ (args[4] ?? undefined) as AssignmentDelivery | undefined,
4674
+ typeof args[5] === "string" && args[5] ? args[5] : undefined,
4644
4675
  );
4645
4676
  case "channelResolvePermission":
4646
4677
  return hostCommands.channelResolvePermission(
@@ -4695,11 +4726,13 @@ async function dispatchCommand(
4695
4726
  parseTranscriptTargets(args[3]),
4696
4727
  );
4697
4728
  case "previewEnsure":
4729
+ if (parsePreviewUpstream(args[3]) === null) throw new Error("invalid preview upstream settings");
4698
4730
  return hostCommands.previewEnsure(
4699
4731
  ctx,
4700
4732
  expectString(args, 0),
4701
4733
  expectString(args, 1),
4702
4734
  expectNumberArg(args, 2),
4735
+ parsePreviewUpstream(args[3])!,
4703
4736
  );
4704
4737
  case "hostUpdate":
4705
4738
  return hostCommands.hostUpdate(ctx, typeof args[0] === "string" ? args[0] : "");
@@ -5000,6 +5033,8 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
5000
5033
  isReqLine(frame.reqLine) &&
5001
5034
  typeof frame.upgrade === "boolean"
5002
5035
  ) {
5036
+ const upstream = tunnelPreviewUpstream(frame as Extract<CloudToHost, { kind: "tunnel.open" }>);
5037
+ if (!upstream) return null;
5003
5038
  return {
5004
5039
  kind: "tunnel.open",
5005
5040
  tunnelId: frame.tunnelId,
@@ -5010,6 +5045,7 @@ function parseCloudFrame(data: RawData): CloudToHost | null {
5010
5045
  // node-proxy sidecar (NOT the unreachable bridge IP — see resolveTunnelTarget).
5011
5046
  containerPort:
5012
5047
  typeof frame.containerPort === "number" ? frame.containerPort : undefined,
5048
+ ...upstream,
5013
5049
  reqLine: frame.reqLine,
5014
5050
  upgrade: frame.upgrade,
5015
5051
  };