openship 0.6.1 → 0.6.6

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.
Files changed (67) hide show
  1. package/README.md +7 -2
  2. package/dist/{bare-IWS2QHJ3.js → bare-OXJVW3AK.js} +11 -9
  3. package/dist/{chunk-VZ2ZHPS2.js → chunk-2YM3NI2E.js} +3 -9
  4. package/dist/chunk-4I2BM2DT.js +68 -0
  5. package/dist/{chunk-2NZSUXOB.js → chunk-5DMWSTOM.js} +310 -1459
  6. package/dist/{chunk-3FYIXVX5.js → chunk-77QDF6UD.js} +581 -122
  7. package/dist/{chunk-IOT5ZX23.js → chunk-A4GXYE4L.js} +449 -383
  8. package/dist/chunk-CBLD2HS5.js +428 -0
  9. package/dist/chunk-ELGRMZOW.js +25 -0
  10. package/dist/{chunk-TWJCLD4W.js → chunk-FE7ZZQSE.js} +5 -5
  11. package/dist/{chunk-3NZICQHF.js → chunk-HKRQXPTI.js} +31 -8
  12. package/dist/{chunk-IXM5245F.js → chunk-LFE5KQ7Y.js} +553 -538
  13. package/dist/{chunk-VDDMADOF.js → chunk-LH2PLRQO.js} +16 -2
  14. package/dist/{chunk-EMXI4UCU.js → chunk-LUKYPVJ4.js} +29 -10
  15. package/dist/{chunk-UC7YIEHT.js → chunk-MLYSEZQB.js} +352 -47
  16. package/dist/chunk-NK5N5LXN.js +1681 -0
  17. package/dist/{chunk-RRJJGVKE.js → chunk-OBPKYXQL.js} +75 -17
  18. package/dist/{chunk-6ODUBKUB.js → chunk-PCPVTH6M.js} +78 -88
  19. package/dist/chunk-Q7WGTQQ5.js +88 -0
  20. package/dist/{chunk-EW6KB2J7.js → chunk-QVW76WDL.js} +440 -74
  21. package/dist/{chunk-YIPMDGG6.js → chunk-RSR7PFIQ.js} +79 -23
  22. package/dist/{chunk-3LDWWY7U.js → chunk-SM26HDS2.js} +41 -7
  23. package/dist/{chunk-OTLSJYAC.js → chunk-UJ6JZM65.js} +5185 -341
  24. package/dist/chunk-UVNMQM7W.js +444 -0
  25. package/dist/chunk-VTGPEC6B.js +308 -0
  26. package/dist/chunk-WVAML4H6.js +2145 -0
  27. package/dist/{chunk-JIMUYEKN.js → chunk-YIMOCZBZ.js} +1 -1
  28. package/dist/chunk-YMMQRE5J.js +292 -0
  29. package/dist/cloud-75VIT4KV.js +37 -0
  30. package/dist/{detect-ALF4GXRX.js → detect-OIE5V3VZ.js} +3 -3
  31. package/dist/{docker-QECQJ5P4.js → docker-VTR2GK5Y.js} +16 -8
  32. package/dist/{docker-edge-executor-62N64MGS.js → docker-edge-executor-22IBXNYJ.js} +5 -5
  33. package/dist/{edge-import-SAYTRJLO.js → edge-import-WJUB4M7F.js} +27 -23
  34. package/dist/edge-preflight-GBNCPNVU.js +42 -0
  35. package/dist/{ensure-container-edge-CFSCPGZK.js → ensure-container-edge-KPI2CVXJ.js} +9 -8
  36. package/dist/{executor-PT6UEJ5M.js → executor-7LXXZPBR.js} +15 -5
  37. package/dist/host-channel-preflight-KE6OONYK.js +50 -0
  38. package/dist/index.js +3783 -4221
  39. package/dist/{nginx-YOEEFBTE.js → nginx-KUFMLMAC.js} +13 -5
  40. package/dist/node-bootstrap.js +241 -0
  41. package/dist/node-entry.js +52 -1
  42. package/dist/{openresty-lua-WGBO2M3Z.js → openresty-lua-ATTH35BE.js} +8 -2
  43. package/dist/privilege-I4W3ZNZK.js +19 -0
  44. package/dist/server/engine/samples/amavisd/amavisd.conf +25 -0
  45. package/dist/server/engine/samples/fail2ban/jail.d/sshd.local +4 -1
  46. package/dist/server/engine/samples/postfix/master.cf +14 -0
  47. package/dist/server/index.js +77659 -61909
  48. package/dist/server/migrations/0098_instance_product_mode.sql +12 -0
  49. package/dist/server/migrations/0099_mail_webmail_project.sql +24 -0
  50. package/dist/server/migrations/0100_server_ssh_private_key.sql +11 -0
  51. package/dist/server/migrations/0101_mail_resume_step.sql +12 -0
  52. package/dist/server/migrations/0102_project_workload_axes.sql +19 -0
  53. package/dist/server/migrations/0103_dns_credential.sql +41 -0
  54. package/dist/server/migrations/0104_instance_host_control.sql +15 -0
  55. package/dist/server/migrations/0105_mail_inbound_rules.sql +80 -0
  56. package/dist/server/migrations/0106_mcp_call_tracking.sql +23 -0
  57. package/dist/server/migrations/0107_build_minute_metering_indexes.sql +20 -0
  58. package/dist/server/migrations/0108_credential.sql +68 -0
  59. package/dist/server/migrations/meta/_journal.json +77 -0
  60. package/dist/{setup-M2ZDGH3O.js → setup-XGLGUUFW.js} +10 -9
  61. package/dist/ssh-client-JBMA7SN4.js +28 -0
  62. package/dist/up-K4BV7466.js +57 -0
  63. package/package.json +4 -2
  64. package/dist/chunk-IW2UKBVF.js +0 -144
  65. package/dist/chunk-ZCFVHA25.js +0 -89
  66. package/dist/cloud-M2YLWEXO.js +0 -31
  67. /package/dist/{dist-es-JGBPABJI.js → dist-es-AXFRS7PS.js} +0 -0
@@ -5,12 +5,12 @@ const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
6
  getTarCreateEnv,
7
7
  prepareSourceTarArgs
8
- } from "./chunk-6ODUBKUB.js";
8
+ } from "./chunk-Q7WGTQQ5.js";
9
9
  import {
10
10
  TRANSFER_EXCLUDES,
11
11
  packageManagerEnsureCommand,
12
12
  safeErrorMessage
13
- } from "./chunk-OTLSJYAC.js";
13
+ } from "./chunk-UJ6JZM65.js";
14
14
 
15
15
  // ../../packages/adapters/src/runtime/git-clone.ts
16
16
  function sq(value) {
@@ -193,6 +193,19 @@ var BuildCancelledError = class extends Error {
193
193
  this.name = "BuildCancelledError";
194
194
  }
195
195
  };
196
+ async function killProcessesUnderDir(executor, dir, opts) {
197
+ const quoted = sq(dir);
198
+ const roots = opts?.includeSuffixed ? [quoted, `${quoted}-*`] : [quoted];
199
+ const patterns = roots.flatMap((root) => [
200
+ root,
201
+ `${root}/*`,
202
+ `${root}" (deleted)"`,
203
+ `${root}/*" (deleted)"`
204
+ ]);
205
+ const scan = (sig) => `for p in /proc/[0-9]*; do c=$(readlink "$p/cwd" 2>/dev/null); case "$c" in ${patterns.join("|")}) kill -${sig} "\${p##*/}" 2>/dev/null || true;; esac; done`;
206
+ await executor.exec(`${scan("TERM")}; sleep 2; ${scan("KILL")}`).catch(() => {
207
+ });
208
+ }
196
209
  async function runBuildPipeline(env, config, logger) {
197
210
  let startTime = Date.now();
198
211
  let currentStep = "prepare";
@@ -413,6 +426,7 @@ export {
413
426
  localGitSshWriter,
414
427
  BuildLogger,
415
428
  BuildCancelledError,
429
+ killProcessesUnderDir,
416
430
  runBuildPipeline,
417
431
  parseLogLevel,
418
432
  detectBuildKillHint,
@@ -7,14 +7,14 @@ import {
7
7
  localGitSshWriter,
8
8
  materializeGitSsh,
9
9
  sq
10
- } from "./chunk-VDDMADOF.js";
10
+ } from "./chunk-LH2PLRQO.js";
11
11
  import {
12
12
  getTarCreateEnv,
13
13
  prepareSourceTarArgs
14
- } from "./chunk-6ODUBKUB.js";
14
+ } from "./chunk-Q7WGTQQ5.js";
15
15
  import {
16
16
  packageManagerEnsureCommand
17
- } from "./chunk-OTLSJYAC.js";
17
+ } from "./chunk-UJ6JZM65.js";
18
18
  import {
19
19
  __commonJS,
20
20
  __toESM
@@ -480,16 +480,34 @@ var require_ignore = __commonJS({
480
480
 
481
481
  // ../../packages/adapters/src/runtime/docker-paths.ts
482
482
  import { access, readdir } from "fs/promises";
483
- import { isAbsolute, join, relative, sep } from "path";
483
+ import { isAbsolute, join, relative, resolve, sep } from "path";
484
484
  function toPosixPath(value) {
485
485
  return value.split(sep).filter(Boolean).join("/");
486
486
  }
487
+ function toSafeSegments(value, label) {
488
+ const segments = value.split(/[\\/]/).filter((segment) => segment && segment !== ".");
489
+ if (segments.includes("..")) {
490
+ throw new Error(
491
+ `Invalid ${label} ${JSON.stringify(value)}: path segments must not contain "..".`
492
+ );
493
+ }
494
+ return segments;
495
+ }
487
496
  function normalizeDockerRelativePath(value) {
488
497
  const normalized = value?.trim().replace(/^\.\//, "").replace(/^\/+|\/+$/g, "");
489
498
  if (!normalized || normalized === ".") {
490
499
  return "";
491
500
  }
492
- return normalized.split(/[\\/]/).filter(Boolean).join("/");
501
+ return toSafeSegments(normalized, "path").join("/");
502
+ }
503
+ function resolveWithinDirectory(baseDir, relativePath) {
504
+ const base = resolve(baseDir);
505
+ const target = resolve(base, ...toSafeSegments(relativePath?.trim() ?? "", "path"));
506
+ const escape = relative(base, target);
507
+ if (escape === ".." || escape.startsWith(`..${sep}`) || isAbsolute(escape)) {
508
+ throw new Error(`Resolved path ${JSON.stringify(target)} escapes ${JSON.stringify(base)}.`);
509
+ }
510
+ return target;
493
511
  }
494
512
  function normalizeDockerRootDirectory(rootDirectory, localPath) {
495
513
  let normalized = rootDirectory?.trim() ?? "";
@@ -509,7 +527,7 @@ function normalizeDockerRootDirectory(rootDirectory, localPath) {
509
527
  if (!normalized || normalized === ".") {
510
528
  return "";
511
529
  }
512
- return toPosixPath(normalized);
530
+ return toSafeSegments(normalized, "rootDirectory").join("/");
513
531
  }
514
532
  function resolveExplicitDockerfileCandidate(rootDirectory, dockerfilePath) {
515
533
  const normalizedRootDirectory = normalizeDockerRelativePath(rootDirectory);
@@ -904,7 +922,7 @@ import { join as join2, relative as relative2, sep as sep2 } from "path";
904
922
  var GIT_CLONE_IDLE_TIMEOUT_MS = 5 * 6e4;
905
923
  var GIT_CHECKOUT_IDLE_TIMEOUT_MS = 6e4;
906
924
  function spawnGit(args, opts) {
907
- return new Promise((resolve, reject) => {
925
+ return new Promise((resolve2, reject) => {
908
926
  const child = spawn("git", args, {
909
927
  stdio: ["ignore", "pipe", "pipe"],
910
928
  env: {
@@ -954,7 +972,7 @@ function spawnGit(args, opts) {
954
972
  });
955
973
  child.on("close", (code) => {
956
974
  clearTimeout(idleTimer);
957
- if (code === 0) resolve();
975
+ if (code === 0) resolve2();
958
976
  else reject(new Error(`git exited with code ${code}: ${stderr.trim().slice(-500) || "no stderr"}`));
959
977
  });
960
978
  });
@@ -1014,7 +1032,7 @@ async function applyDockerignore(contextDir, config) {
1014
1032
  async function materializeLocalSource(sourcePath, targetPath) {
1015
1033
  const { args, cleanup } = await prepareSourceTarArgs(sourcePath);
1016
1034
  try {
1017
- await new Promise((resolve, reject) => {
1035
+ await new Promise((resolve2, reject) => {
1018
1036
  const create = spawn("tar", args, { env: getTarCreateEnv() });
1019
1037
  const extract = spawn("tar", ["-xzf", "-", "-C", targetPath]);
1020
1038
  let err = "";
@@ -1027,7 +1045,7 @@ async function materializeLocalSource(sourcePath, targetPath) {
1027
1045
  create.on("close", (code) => createCode = code);
1028
1046
  create.stdout.pipe(extract.stdin);
1029
1047
  extract.on("close", (code) => {
1030
- if (code === 0 && (createCode ?? 0) === 0) return resolve();
1048
+ if (code === 0 && (createCode ?? 0) === 0) return resolve2();
1031
1049
  const parts = [
1032
1050
  createCode ? `pack exited ${createCode}: ${createErr.trim().slice(-500)}` : "",
1033
1051
  code ? `extract exited ${code}: ${err.trim().slice(-500)}` : ""
@@ -1173,6 +1191,7 @@ async function createDockerBuildContext(config, opts) {
1173
1191
 
1174
1192
  export {
1175
1193
  normalizeDockerRelativePath,
1194
+ resolveWithinDirectory,
1176
1195
  resolveDockerfileCandidates,
1177
1196
  staticBuilderOutputPath,
1178
1197
  generateDockerfile,
@@ -3,16 +3,16 @@
3
3
  import { createRequire as __ospCreateRequire } from "node:module";
4
4
  const require = __ospCreateRequire(import.meta.url);
5
5
  import {
6
- connectSshClient,
7
6
  getTarCreateEnv,
7
+ prepareSourceTarArgs
8
+ } from "./chunk-Q7WGTQQ5.js";
9
+ import {
10
+ connectSshClient,
11
+ openDockerDialStdioChannel,
8
12
  openSftp,
9
13
  openSshUnixSocket,
10
- prepareSourceTarArgs,
11
14
  reconcileKnownHosts
12
- } from "./chunk-6ODUBKUB.js";
13
- import {
14
- SshDisconnectedError
15
- } from "./chunk-ZCFVHA25.js";
15
+ } from "./chunk-PCPVTH6M.js";
16
16
  import {
17
17
  LocalExecutor
18
18
  } from "./chunk-6FH4AXVY.js";
@@ -24,13 +24,80 @@ import {
24
24
  sq
25
25
  } from "./chunk-ZFJXPCQP.js";
26
26
  import {
27
+ HOST_CHANNEL_AUTH_REJECTED,
28
+ HOST_CHANNEL_NOT_PROVISIONED,
29
+ HOST_CHANNEL_UNPROVISIONED,
30
+ HostChannelUnavailableError,
31
+ SshDisconnectedError,
27
32
  TRANSFER_EXCLUDES,
33
+ commandForError,
34
+ explainHostChannelCause,
28
35
  formatBytes,
36
+ hostChannelAccount,
37
+ hostFirewallRule,
38
+ isSshAuthError,
29
39
  safeErrorMessage
30
- } from "./chunk-OTLSJYAC.js";
40
+ } from "./chunk-UJ6JZM65.js";
31
41
 
32
42
  // ../../packages/adapters/src/system/executor.ts
33
43
  import { existsSync, readFileSync } from "fs";
44
+ import { networkInterfaces } from "os";
45
+
46
+ // ../../packages/adapters/src/system/reachability.ts
47
+ import { connect } from "net";
48
+ import { request } from "http";
49
+ function classifyDialError(code) {
50
+ switch (code) {
51
+ case "ECONNREFUSED":
52
+ case "ECONNRESET":
53
+ return "refused";
54
+ case "ENOTFOUND":
55
+ case "EAI_AGAIN":
56
+ return "unresolved";
57
+ case "EHOSTUNREACH":
58
+ case "ENETUNREACH":
59
+ case "ENETDOWN":
60
+ return "no_route";
61
+ // The kernel's own connect timeout, i.e. our `timeout` reported for us.
62
+ case "ETIMEDOUT":
63
+ return "timeout";
64
+ default:
65
+ return "error";
66
+ }
67
+ }
68
+ function probeTcpDetailed(host, port, timeoutMs = 2500) {
69
+ return new Promise((resolve) => {
70
+ let settled = false;
71
+ const done = (result, socket2) => {
72
+ if (settled) return;
73
+ settled = true;
74
+ try {
75
+ socket2?.destroy();
76
+ } catch {
77
+ }
78
+ resolve(result);
79
+ };
80
+ const socket = connect({ host, port });
81
+ socket.setTimeout(timeoutMs);
82
+ socket.once("connect", () => done({ ok: true }, socket));
83
+ socket.once(
84
+ "timeout",
85
+ () => done({ ok: false, reason: "timeout", message: `no response within ${timeoutMs}ms` }, socket)
86
+ );
87
+ socket.once("error", (err) => {
88
+ const errno = err;
89
+ done(
90
+ {
91
+ ok: false,
92
+ reason: classifyDialError(errno.code),
93
+ ...errno.code ? { code: errno.code } : {},
94
+ ...errno.message ? { message: errno.message } : {}
95
+ },
96
+ socket
97
+ );
98
+ });
99
+ });
100
+ }
34
101
 
35
102
  // ../../packages/adapters/src/system/ssh-executor.ts
36
103
  import { createReadStream } from "fs";
@@ -240,7 +307,7 @@ var SshExecutor = class _SshExecutor {
240
307
  async connect() {
241
308
  if (this.client) return this.client;
242
309
  if (this.connecting) return this.connecting;
243
- this.connecting = (async () => {
310
+ const attempt = (async () => {
244
311
  const client = await connectSshClient(this.config);
245
312
  const onTransportDown = (cause) => {
246
313
  if (this.client !== client) return;
@@ -252,10 +319,14 @@ var SshExecutor = class _SshExecutor {
252
319
  client.on("end", () => onTransportDown());
253
320
  client.on("error", (err) => onTransportDown(err));
254
321
  this.client = client;
255
- this.connecting = null;
256
322
  return client;
257
323
  })();
258
- return this.connecting;
324
+ this.connecting = attempt;
325
+ const clearMemo = () => {
326
+ if (this.connecting === attempt) this.connecting = null;
327
+ };
328
+ attempt.then(clearMemo, clearMemo);
329
+ return attempt;
259
330
  }
260
331
  /**
261
332
  * Subscribe to transport-level disconnects. Returns an unsubscribe fn.
@@ -402,7 +473,9 @@ var SshExecutor = class _SshExecutor {
402
473
  };
403
474
  this.inflight.add(abort);
404
475
  timer = setTimeout(
405
- () => finish(() => reject(new Error(`Command timed out after ${timeout}ms: ${command}`))),
476
+ () => finish(
477
+ () => reject(new Error(`Command timed out after ${timeout}ms: ${commandForError(command)}`))
478
+ ),
406
479
  timeout
407
480
  );
408
481
  client.exec(_SshExecutor.ENV_PREFIX + command, (err, stream) => {
@@ -706,24 +779,23 @@ var SshExecutor = class _SshExecutor {
706
779
  * Carry the Docker Engine API over a `docker system dial-stdio` exec channel
707
780
  * on the pooled connection — the streamlocal-free transport used when the
708
781
  * SSH server (or the Bun-compiled desktop runtime) can't do socket
709
- * forwarding. Uses the SAME ENV_PREFIX as streamExec so `docker` resolves on
710
- * PATH exactly as it does for the remote build (which is proven to work).
782
+ * forwarding.
783
+ *
784
+ * Deliberately WITHOUT `ENV_PREFIX`: that prefix only exports apt's
785
+ * non-interactive variables (it sets no PATH, despite what the comment here
786
+ * used to claim), and the bridge's own ephemeral dial-stdio client opens the
787
+ * bare command. Two dial-stdio channels that differ by a shell prefix are two
788
+ * transports, and the bridge picks one by probing the other.
789
+ *
790
+ * Failure diagnostics (daemon down, socket permission, `docker: command not
791
+ * found`, an sshd ForceCommand banner) ride ON the returned channel — see
792
+ * `attachDialStdioDiagnostics`. They used to go to `console.warn` while the
793
+ * caller got a bare socket reset, which is how a host with no running dockerd
794
+ * reported itself as `socket hang up`.
711
795
  */
712
796
  async openDockerDialStdio() {
713
797
  const client = await this.connect();
714
- return new Promise((resolve, reject) => {
715
- client.exec(_SshExecutor.ENV_PREFIX + "docker system dial-stdio", (err, stream) => {
716
- if (err) return reject(err);
717
- stream.stderr?.on("data", (d) => {
718
- const text = d.toString().trim();
719
- if (text) console.warn(`[docker-ssh] dial-stdio stderr: ${text}`);
720
- });
721
- stream.on("exit", (code) => {
722
- if (code) console.warn(`[docker-ssh] dial-stdio exited early (code=${code})`);
723
- });
724
- resolve(stream);
725
- });
726
- });
798
+ return openDockerDialStdioChannel(client);
727
799
  }
728
800
  async forwardPort(remoteHost, remotePort) {
729
801
  const client = await this.connect();
@@ -1200,7 +1272,7 @@ var SystemSshExecutor = class _SystemSshExecutor {
1200
1272
  const timeout = opts?.timeout ?? 3e4;
1201
1273
  const res = await this.runSsh(_SystemSshExecutor.ENV_PREFIX + command, { timeout });
1202
1274
  if (res.timedOut) {
1203
- throw new Error(`Command timed out after ${timeout}ms: ${command}`);
1275
+ throw new Error(`Command timed out after ${timeout}ms: ${commandForError(command)}`);
1204
1276
  }
1205
1277
  if (res.code !== 0) {
1206
1278
  if (res.code === 255) throw describeSshFailure(res.stderr, `Exit code ${res.code}`);
@@ -1208,14 +1280,22 @@ var SystemSshExecutor = class _SystemSshExecutor {
1208
1280
  }
1209
1281
  return res.stdout.trim();
1210
1282
  }
1211
- async streamExec(command, onLog) {
1283
+ async streamExec(command, onLog, opts) {
1212
1284
  await this.ensureMaster();
1285
+ const signal = opts?.signal;
1286
+ if (signal?.aborted) return { code: 0, output: "" };
1213
1287
  return new Promise((resolve) => {
1214
1288
  const child = spawn2(
1215
1289
  "ssh",
1216
1290
  [...this.baseArgs(), sshTarget(this.config), _SystemSshExecutor.ENV_PREFIX + command],
1217
1291
  { env: sshChildEnv(this.config), stdio: ["ignore", "pipe", "pipe"] }
1218
1292
  );
1293
+ let aborted = false;
1294
+ const onAbort = () => {
1295
+ aborted = true;
1296
+ child.kill("SIGKILL");
1297
+ };
1298
+ signal?.addEventListener("abort", onAbort, { once: true });
1219
1299
  const chunks = [];
1220
1300
  const onChunk = (chunk, level) => {
1221
1301
  const text = chunk.toString();
@@ -1226,11 +1306,13 @@ var SystemSshExecutor = class _SystemSshExecutor {
1226
1306
  child.stdout.on("data", (chunk) => onChunk(chunk, "info"));
1227
1307
  child.stderr.on("data", (chunk) => onChunk(chunk, "warn"));
1228
1308
  child.on("error", (err) => {
1309
+ signal?.removeEventListener("abort", onAbort);
1229
1310
  onLog(logEntry(`Process error: ${err.message}`, "error"));
1230
1311
  resolve({ code: 1, output: err.message });
1231
1312
  });
1232
1313
  child.on("close", (code) => {
1233
- const c = code ?? 1;
1314
+ signal?.removeEventListener("abort", onAbort);
1315
+ const c = aborted ? 0 : code ?? 1;
1234
1316
  void this.maybeSignalDisconnect(c).catch(() => {
1235
1317
  });
1236
1318
  resolve({ code: c, output: chunks.join("") });
@@ -1524,8 +1606,12 @@ function createExecutor(ssh) {
1524
1606
  }
1525
1607
  return localExecutor;
1526
1608
  }
1609
+ var hostControlOverride = null;
1610
+ function setHostControlOverride(disabled) {
1611
+ hostControlOverride = disabled;
1612
+ }
1527
1613
  function hostControlDisabled() {
1528
- return process.env.OPENSHIP_HOST_CONTROL?.trim().toLowerCase() === "false";
1614
+ return hostControlOverride ?? process.env.OPENSHIP_HOST_CONTROL?.trim().toLowerCase() === "false";
1529
1615
  }
1530
1616
  function runningInContainer() {
1531
1617
  if (process.env.OPENSHIP_IN_CONTAINER?.trim().toLowerCase() === "true") return true;
@@ -1539,39 +1625,253 @@ function runningInContainer() {
1539
1625
  return false;
1540
1626
  }
1541
1627
  }
1628
+ function hostChannelPort() {
1629
+ const raw = Number(process.env.OPENSHIP_HOST_SSH_PORT || "22");
1630
+ return Number.isInteger(raw) && raw > 0 && raw < 65536 ? raw : 22;
1631
+ }
1632
+ function hostChannelUser() {
1633
+ return hostChannelAccount(process.env);
1634
+ }
1635
+ function readHostChannelKey(keyPath) {
1636
+ let contents;
1637
+ try {
1638
+ contents = readFileSync(keyPath, "utf8");
1639
+ } catch (err) {
1640
+ const detail = err instanceof Error ? err.message : String(err);
1641
+ return { reason: `Cannot read the host SSH key at ${keyPath} (${detail}).` };
1642
+ }
1643
+ if (!contents.trim()) return { reason: `The host SSH key at ${keyPath} is empty.` };
1644
+ return { key: contents };
1645
+ }
1646
+ function containerBridgeCidr() {
1647
+ for (const addrs of Object.values(networkInterfaces())) {
1648
+ for (const addr of addrs ?? []) {
1649
+ if (addr.family !== "IPv4" || addr.internal) continue;
1650
+ const prefix = Number(addr.cidr?.split("/")[1]);
1651
+ if (!Number.isInteger(prefix) || prefix < 8 || prefix > 32) continue;
1652
+ const octets = addr.address.split(".").map(Number);
1653
+ if (octets.length !== 4 || octets.some((o) => !Number.isInteger(o) || o < 0 || o > 255)) continue;
1654
+ const asInt = (octets[0] << 24 | octets[1] << 16 | octets[2] << 8 | octets[3]) >>> 0;
1655
+ const mask = 4294967295 << 32 - prefix >>> 0;
1656
+ const net = (asInt & mask) >>> 0;
1657
+ return `${net >>> 24 & 255}.${net >>> 16 & 255}.${net >>> 8 & 255}.${net & 255}/${prefix}`;
1658
+ }
1659
+ }
1660
+ return null;
1661
+ }
1662
+ function probeDetail(probe) {
1663
+ const { code, message } = probe;
1664
+ if (code && message && !message.includes(code)) return `${code}: ${message}`;
1665
+ return code ?? message;
1666
+ }
1667
+ function explainDialFailure(probe, host, port, target) {
1668
+ const { headline, body, firewallShaped } = explainHostChannelCause(probe.reason, {
1669
+ target,
1670
+ host,
1671
+ detail: probeDetail(probe)
1672
+ });
1673
+ const hint = body ? `${headline}. ${body}` : `${headline}.`;
1674
+ if (!firewallShaped) return { hint };
1675
+ const cidr = containerBridgeCidr();
1676
+ return { hint, rule: hostFirewallRule("unknown", cidr ? [cidr] : [], port) };
1677
+ }
1678
+ var AUTH_MEMO_OK_MS = 3e4;
1679
+ var AUTH_MEMO_FAIL_MS = 5e3;
1680
+ var authMemo = null;
1681
+ function invalidateHostChannelAuth() {
1682
+ authMemo = null;
1683
+ }
1684
+ function probeForwarding(client, timeoutMs) {
1685
+ return new Promise((resolve) => {
1686
+ let settled = false;
1687
+ const finish = (verdict) => {
1688
+ if (settled) return;
1689
+ settled = true;
1690
+ clearTimeout(timer);
1691
+ resolve(verdict);
1692
+ };
1693
+ const timer = setTimeout(() => finish("unknown"), timeoutMs);
1694
+ timer.unref?.();
1695
+ try {
1696
+ client.forwardOut("127.0.0.1", 0, "127.0.0.1", 1, (err, stream) => {
1697
+ if (stream) {
1698
+ stream.destroy();
1699
+ finish("ok");
1700
+ return;
1701
+ }
1702
+ const reason = err?.reason;
1703
+ const message = err?.message;
1704
+ if (reason === 1 || reason === 3) return finish("blocked");
1705
+ if (typeof message === "string" && /administratively prohibited/i.test(message)) {
1706
+ return finish("blocked");
1707
+ }
1708
+ finish("ok");
1709
+ });
1710
+ } catch {
1711
+ finish("unknown");
1712
+ }
1713
+ });
1714
+ }
1715
+ async function verifyHostChannelAuth(config) {
1716
+ const memoKey = `${config.username}@${config.host}:${config.port}#${config.privateKey.length}`;
1717
+ if (authMemo?.key === memoKey && authMemo.expires > Date.now()) {
1718
+ return authMemo.hint ? { hint: authMemo.hint, forwarding: authMemo.forwarding } : { forwarding: authMemo.forwarding };
1719
+ }
1720
+ let hint = null;
1721
+ let forwarding = "unknown";
1722
+ try {
1723
+ const { connectSshClient: connectSshClient2 } = await import("./ssh-client-JBMA7SN4.js");
1724
+ const client = await connectSshClient2({
1725
+ host: config.host,
1726
+ port: config.port,
1727
+ username: config.username,
1728
+ privateKey: config.privateKey,
1729
+ readyTimeoutMs: config.timeoutMs,
1730
+ // Load-bearing: it selects describeSshAuthFailure's host-channel wording, so the
1731
+ // hint an operator reads here is the same sentence the deploy log gives them.
1732
+ hostChannel: true
1733
+ });
1734
+ forwarding = await probeForwarding(
1735
+ client,
1736
+ config.timeoutMs
1737
+ );
1738
+ client.end();
1739
+ } catch (err) {
1740
+ if (isSshAuthError(err)) {
1741
+ hint = err instanceof Error ? err.message : HOST_CHANNEL_AUTH_REJECTED;
1742
+ }
1743
+ }
1744
+ authMemo = {
1745
+ key: memoKey,
1746
+ hint,
1747
+ forwarding,
1748
+ expires: Date.now() + (hint ? AUTH_MEMO_FAIL_MS : AUTH_MEMO_OK_MS)
1749
+ };
1750
+ return hint ? { hint, forwarding } : { forwarding };
1751
+ }
1752
+ async function hostChannelHealth(timeoutMs = 2500) {
1753
+ if (hostControlDisabled()) {
1754
+ return {
1755
+ ok: false,
1756
+ code: "disabled",
1757
+ hint: "Host control is off (OPENSHIP_HOST_CONTROL=false). Re-run `openship up` without --no-host-control."
1758
+ };
1759
+ }
1760
+ const host = process.env.OPENSHIP_HOST_SSH_HOST?.trim();
1761
+ if (!host) {
1762
+ if (runningInContainer()) {
1763
+ return {
1764
+ ok: false,
1765
+ code: "not_configured",
1766
+ hint: `${HOST_CHANNEL_UNPROVISIONED} ${HOST_CHANNEL_NOT_PROVISIONED}`
1767
+ };
1768
+ }
1769
+ return { ok: true, code: "not_applicable" };
1770
+ }
1771
+ const port = hostChannelPort();
1772
+ const target = `${hostChannelUser()}@${host}:${port}`;
1773
+ const keyPath = process.env.OPENSHIP_HOST_SSH_KEY?.trim();
1774
+ let privateKey;
1775
+ if (keyPath) {
1776
+ const read = readHostChannelKey(keyPath);
1777
+ if (read.reason) {
1778
+ return {
1779
+ ok: false,
1780
+ code: "key_unreadable",
1781
+ host,
1782
+ port,
1783
+ target,
1784
+ hint: `${read.reason} ${HOST_CHANNEL_NOT_PROVISIONED}`
1785
+ };
1786
+ }
1787
+ privateKey = read.key;
1788
+ }
1789
+ const probe = await probeTcpDetailed(host, port, timeoutMs);
1790
+ if (!probe.ok) {
1791
+ return {
1792
+ ok: false,
1793
+ code: "unreachable",
1794
+ host,
1795
+ port,
1796
+ target,
1797
+ cause: probe.reason,
1798
+ ...explainDialFailure(probe, host, port, target)
1799
+ };
1800
+ }
1801
+ let forwarding = "unknown";
1802
+ if (privateKey) {
1803
+ const verdict = await verifyHostChannelAuth({
1804
+ host,
1805
+ port,
1806
+ username: hostChannelUser(),
1807
+ privateKey,
1808
+ timeoutMs
1809
+ });
1810
+ if (verdict.hint) {
1811
+ return { ok: false, code: "auth_rejected", host, port, target, hint: verdict.hint };
1812
+ }
1813
+ forwarding = verdict.forwarding;
1814
+ }
1815
+ return { ok: true, code: "ok", host, port, target, forwarding };
1816
+ }
1817
+ function unavailableExecutor(reason, code = "not_configured") {
1818
+ const refuse = async () => {
1819
+ throw new HostChannelUnavailableError(code, reason);
1820
+ };
1821
+ return {
1822
+ exec: refuse,
1823
+ streamExec: refuse,
1824
+ writeFile: refuse,
1825
+ readFile: refuse,
1826
+ exists: refuse,
1827
+ mkdir: refuse,
1828
+ rm: refuse,
1829
+ transferIn: refuse,
1830
+ // Nothing was ever opened, so releasing must not be the one call that throws.
1831
+ dispose: async () => {
1832
+ }
1833
+ };
1834
+ }
1542
1835
  function createHostExecutor() {
1543
1836
  if (hostControlDisabled()) {
1544
- throw new Error(
1837
+ throw new HostChannelUnavailableError(
1838
+ "disabled",
1545
1839
  "Host control is disabled on this instance (OPENSHIP_HOST_CONTROL=false). Re-run `openship up` without --no-host-control to allow host operations."
1546
1840
  );
1547
1841
  }
1548
1842
  const host = process.env.OPENSHIP_HOST_SSH_HOST?.trim();
1549
1843
  if (!host) {
1550
1844
  if (runningInContainer()) {
1551
- throw new Error(
1552
- "This operation targets the HOST machine, but no host channel is configured (OPENSHIP_HOST_SSH_HOST is unset) and Openship is running in a container \u2014 so it would have run inside the container instead, against the wrong filesystem. Re-run `openship up` to provision the host channel."
1845
+ throw new HostChannelUnavailableError(
1846
+ "not_configured",
1847
+ `This operation targets the HOST machine, but no host channel is configured (OPENSHIP_HOST_SSH_HOST is unset) and Openship is running in a container \u2014 so it would have run inside the container instead, against the wrong filesystem. ${HOST_CHANNEL_NOT_PROVISIONED}`
1553
1848
  );
1554
1849
  }
1555
1850
  return localExecutor;
1556
1851
  }
1557
1852
  const keyPath = process.env.OPENSHIP_HOST_SSH_KEY?.trim();
1558
- const portRaw = Number(process.env.OPENSHIP_HOST_SSH_PORT || "22");
1559
- const port = Number.isInteger(portRaw) && portRaw > 0 && portRaw < 65536 ? portRaw : 22;
1560
- let privateKey;
1561
- if (keyPath) {
1562
- try {
1563
- privateKey = readFileSync(keyPath, "utf8");
1564
- } catch (err) {
1565
- throw new Error(
1566
- `Cannot read host SSH key at OPENSHIP_HOST_SSH_KEY=${keyPath}: ${err instanceof Error ? err.message : String(err)}`
1567
- );
1568
- }
1853
+ if (!keyPath) {
1854
+ throw new HostChannelUnavailableError(
1855
+ "not_configured",
1856
+ `A host channel is configured (OPENSHIP_HOST_SSH_HOST) but OPENSHIP_HOST_SSH_KEY is unset, so there is no credential to connect to the host with. ${HOST_CHANNEL_NOT_PROVISIONED}`
1857
+ );
1858
+ }
1859
+ const read = readHostChannelKey(keyPath);
1860
+ if (read.reason) {
1861
+ throw new HostChannelUnavailableError(
1862
+ "not_configured",
1863
+ `${read.reason} ${HOST_CHANNEL_NOT_PROVISIONED}`
1864
+ );
1569
1865
  }
1570
1866
  return new SshExecutor({
1571
1867
  host,
1572
- port,
1573
- username: process.env.OPENSHIP_HOST_SSH_USER?.trim() || "root",
1574
- privateKey
1868
+ port: hostChannelPort(),
1869
+ username: hostChannelUser(),
1870
+ privateKey: read.key,
1871
+ // One hop over the docker bridge: it answers at once or it is being filtered.
1872
+ // 20s of silence per host operation reads as a hang, not as a failure.
1873
+ readyTimeoutMs: 8e3,
1874
+ hostChannel: true
1575
1875
  });
1576
1876
  }
1577
1877
 
@@ -1579,6 +1879,11 @@ export {
1579
1879
  SshExecutor,
1580
1880
  SystemSshExecutor,
1581
1881
  createExecutor,
1882
+ setHostControlOverride,
1582
1883
  hostControlDisabled,
1884
+ containerBridgeCidr,
1885
+ invalidateHostChannelAuth,
1886
+ hostChannelHealth,
1887
+ unavailableExecutor,
1583
1888
  createHostExecutor
1584
1889
  };