@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.
- package/db/migrations/0031_first_turn_receipts.sql +9 -0
- package/db/migrations/meta/_journal.json +7 -0
- package/db/schema.ts +21 -0
- 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/container-runtime.ts +28 -11
- package/lib/env-schema.ts +70 -24
- package/lib/first-turn-receipts.ts +62 -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/bridge-diagnostics.ts +39 -0
- package/src/cli.ts +6 -4
- package/src/enrollment-next-steps.ts +24 -0
- package/src/index.ts +31 -13
- package/src/main.ts +46 -10
- package/src/preview-upstream.ts +41 -0
- package/src/protocol.ts +11 -1
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,
|
|
@@ -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
|
-
|
|
1733
|
-
console.log("
|
|
1734
|
-
|
|
1735
|
-
|
|
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
|
|
2054
|
-
|
|
2055
|
-
|
|
2056
|
-
|
|
2057
|
-
|
|
2058
|
-
|
|
2059
|
-
|
|
2060
|
-
|
|
2061
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
};
|