socketduct 0.1.15

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 (138) hide show
  1. package/.dockerignore +11 -0
  2. package/.env.example +131 -0
  3. package/AGENTS.md +168 -0
  4. package/Dockerfile +30 -0
  5. package/README.md +134 -0
  6. package/changelog.d/20260716083144-v2-ack-wire-format.md +3 -0
  7. package/changelog.d/20260716092044-v2-only-stream-framing.md +1 -0
  8. package/changelog.d/20260727113000-relay-connect-timeout-destroy.md +1 -0
  9. package/changelog.d/20260813093000-private-relay-hardening.md +1 -0
  10. package/changelog.d/20260824183412-initial-target-recovery-https.md +1 -0
  11. package/changelog.d/20260824230000-tls-relay-session-close.md +1 -0
  12. package/changelog.d/20260825120000-reconnect-close-target-timeout.md +1 -0
  13. package/changelog.d/20260829120000-protocol-v3-multiplexing.md +1 -0
  14. package/changelog.d/20260829160000-v3-review-corrections.md +1 -0
  15. package/changelog.d/20260830000000-fix-socket-idle-timeout.md +1 -0
  16. package/changelog.d/20260830090000-multi-gateway-transport-set.md +1 -0
  17. package/changelog.d/20260830093000-relay-close-settlement.md +1 -0
  18. package/changelog.d/20260830120000-immutable-gateway-image.md +1 -0
  19. package/changelog.d/20260902210000-native-reverse-gateway.md +1 -0
  20. package/changelog.d/20260902230000-relay-resume-terminal-order.md +1 -0
  21. package/changelog.d/20260902230708-multiplexed-open-ownership.md +1 -0
  22. package/changelog.d/20260902231541-multiplexed-terminal-close.md +1 -0
  23. package/changelog.d/20260902231542-reverse-close-position.md +1 -0
  24. package/changelog.d/20260902231543-reverse-terminal-connect.md +1 -0
  25. package/changelog.d/20260902233000-reverse-gateway-bounds.md +1 -0
  26. package/changelog.d/20260903001000-reverse-data-ordering.md +1 -0
  27. package/changelog.d/20260903003000-reverse-hub-restart-window.md +1 -0
  28. package/changelog.d/20260903010000-reverse-terminal-lifecycle.md +1 -0
  29. package/changelog.d/20260904142743-transport-set-readiness-wait.md +1 -0
  30. package/changelog.d/20260904152855-deferred-open-snapshots-binary-views.md +1 -0
  31. package/changelog.d/20260904163041-tensorbuzz-container-workflow.md +1 -0
  32. package/changelog.d/20260904174955-transport-set-terminal-causes.md +1 -0
  33. package/changelog.d/20260905081000-contain-reverse-auth-write-errors.md +1 -0
  34. package/changelog.d/20260905100520-reverse-hub-close-race.md +1 -0
  35. package/changelog.d/20260905120000-persistent-jsonl-logging.md +1 -0
  36. package/changelog.d/20260908071009-jsonl-partial-append-recovery.md +1 -0
  37. package/changelog.d/20260908074916-private-log-directory-setup.md +1 -0
  38. package/changelog.d/20260909093057-reverse-large-write-backpressure.md +1 -0
  39. package/changelog.d/20260909135830-actionable-error-diagnostics.md +1 -0
  40. package/changelog.d/20260909150000-transport-set-error-redaction.md +1 -0
  41. package/docker-compose.dev.yml +19 -0
  42. package/docker-compose.reverse-gateway.yml +36 -0
  43. package/docker-compose.yml +73 -0
  44. package/docs/architecture.md +264 -0
  45. package/docs/docker-topology.md +164 -0
  46. package/docs/docker.md +281 -0
  47. package/docs/logging.md +185 -0
  48. package/docs/plans/initial-implementation.md +205 -0
  49. package/docs/plans/socket-docker-integration.md +221 -0
  50. package/docs/protocol.md +558 -0
  51. package/docs/releasing.md +18 -0
  52. package/docs/reverse-gateway.md +186 -0
  53. package/docs/runbooks/jsonl-integrity-rollout.md +144 -0
  54. package/docs/running.md +215 -0
  55. package/docs/security.md +258 -0
  56. package/docs/session-lifecycle.md +127 -0
  57. package/docs/socket-api.md +448 -0
  58. package/eslint.config.js +36 -0
  59. package/package.json +44 -0
  60. package/scripts/deploy-gateway.js +194 -0
  61. package/scripts/test.js +28 -0
  62. package/src/auth/passwords.js +38 -0
  63. package/src/auth/static-token-store.js +41 -0
  64. package/src/auth/user-store.js +139 -0
  65. package/src/client/connect.js +76 -0
  66. package/src/client/deferred-virtual-socket.js +312 -0
  67. package/src/client/http-agent.js +101 -0
  68. package/src/client/https-agent.js +366 -0
  69. package/src/client/multiplexed-connection.js +1504 -0
  70. package/src/client/multiplexed-transport-set.js +917 -0
  71. package/src/client/multiplexed.js +3 -0
  72. package/src/client/socketduct-socket.js +1174 -0
  73. package/src/client/virtual-socket.js +256 -0
  74. package/src/config/environment.js +400 -0
  75. package/src/edge/tcp-edge.js +82 -0
  76. package/src/index.js +101 -0
  77. package/src/logging/structured-logger.js +745 -0
  78. package/src/protocol/compression.js +325 -0
  79. package/src/protocol/frame.js +341 -0
  80. package/src/protocol/v3-compression.js +44 -0
  81. package/src/protocol/v3-frame.js +209 -0
  82. package/src/proxy/multiplexed-relay.js +798 -0
  83. package/src/proxy/tcp-relay.js +1191 -0
  84. package/src/relay.js +3 -0
  85. package/src/reverse/client.js +721 -0
  86. package/src/reverse/common.js +88 -0
  87. package/src/reverse/gateway.js +663 -0
  88. package/src/reverse/hub.js +563 -0
  89. package/src/reverse/index.js +3 -0
  90. package/src/reverse/runtime.js +78 -0
  91. package/src/server/control-handler.js +170 -0
  92. package/src/server/primary.js +64 -0
  93. package/src/server/relay-runtime.js +124 -0
  94. package/src/server/worker.js +39 -0
  95. package/src/sessions/session.js +212 -0
  96. package/src/spool/spool-file.js +451 -0
  97. package/tensorbuzz.yml +36 -0
  98. package/test/e2e/gateway-process.test.js +262 -0
  99. package/test/e2e/reverse-gateway-process.test.js +285 -0
  100. package/test/fixtures/logging/enospc-matrix.mjs +238 -0
  101. package/test/fixtures/logging/enospc-recovery.mjs +146 -0
  102. package/test/fixtures/logging/process-writer.mjs +20 -0
  103. package/test/helpers/tls-certificates.js +115 -0
  104. package/test/integration/initial-target-recovery.test.js +518 -0
  105. package/test/integration/logging-recovery.test.js +163 -0
  106. package/test/integration/multiplexed-compatibility.test.js +254 -0
  107. package/test/integration/multiplexed-corrections.test.js +551 -0
  108. package/test/integration/multiplexed-failures.test.js +339 -0
  109. package/test/integration/multiplexed-transport-set.test.js +456 -0
  110. package/test/integration/multiplexed-transport.test.js +208 -0
  111. package/test/integration/reverse-gateway.test.js +1293 -0
  112. package/test/integration/socketduct-http-agent.test.js +435 -0
  113. package/test/integration/socketduct-https-agent.test.js +669 -0
  114. package/test/integration/stream-compression.test.js +1267 -0
  115. package/test/integration/tcp-forwarding.test.js +2878 -0
  116. package/test/integration/tls-forwarding.test.js +748 -0
  117. package/test/unit/auth/auth.test.js +64 -0
  118. package/test/unit/auth/static-token-store.test.js +14 -0
  119. package/test/unit/client/connect.test.js +58 -0
  120. package/test/unit/client/multiplexed-spool-budget.test.js +309 -0
  121. package/test/unit/client/multiplexed-transport-set.test.js +906 -0
  122. package/test/unit/client/socketduct-socket.test.js +1124 -0
  123. package/test/unit/client/virtual-socket.test.js +98 -0
  124. package/test/unit/config/docker-gateway-files.test.js +274 -0
  125. package/test/unit/config/environment.test.js +507 -0
  126. package/test/unit/index.test.js +78 -0
  127. package/test/unit/logging/structured-logger.test.js +674 -0
  128. package/test/unit/protocol/compression.test.js +421 -0
  129. package/test/unit/protocol/frame.test.js +336 -0
  130. package/test/unit/protocol/v3-compression.test.js +31 -0
  131. package/test/unit/protocol/v3-frame.test.js +57 -0
  132. package/test/unit/release-configuration.test.js +21 -0
  133. package/test/unit/server/control-handler.test.js +291 -0
  134. package/test/unit/server/primary.test.js +59 -0
  135. package/test/unit/server/relay-runtime.test.js +434 -0
  136. package/test/unit/sessions/session.test.js +84 -0
  137. package/test/unit/spool/spool-file.test.js +295 -0
  138. package/tsconfig.json +20 -0
@@ -0,0 +1,98 @@
1
+ /* eslint-disable jsdoc/escape-inline-tags, jsdoc/require-param-type, jsdoc/require-returns */
2
+ import assert from "node:assert/strict"
3
+ import test from "node:test"
4
+
5
+ import {VirtualSocket} from "../../../src/client/virtual-socket.js"
6
+
7
+ /**
8
+ *
9
+ */
10
+ function fakeConnection() {
11
+ const calls = {destroyed: 0, ended: 0, resumed: 0, refs: 0, unrefs: 0}
12
+ return {
13
+ calls,
14
+ connection: /** @type {import('../../../src/client/multiplexed-connection.js').MultiplexedSocketductConnection} */ (/** @type {unknown} */ ({
15
+ /**
16
+ * @param {number} _id @param {Buffer} _data @param {(error?: Error | null) => void} callback
17
+ * @param _data
18
+ * @param callback
19
+ */
20
+ _writeStream: (_id, _data, callback) => setImmediate(callback),
21
+ /**
22
+ * @param {number} _id @param {(error?: Error | null) => void} callback
23
+ * @param callback
24
+ */
25
+ _endStream: (_id, callback) => { calls.ended += 1; callback() },
26
+ _destroyStream: () => { calls.destroyed += 1 },
27
+ _resumeReadable: () => { calls.resumed += 1 },
28
+ physicalAddress: () => ({address: "127.0.0.1", port: 43210}),
29
+ ref: () => { calls.refs += 1 },
30
+ unref: () => { calls.unrefs += 1 }
31
+ }))
32
+ }
33
+ }
34
+
35
+ test("VirtualSocket exposes normal connect metadata and compatibility methods", async () => {
36
+ const fake = fakeConnection()
37
+ const socket = new VirtualSocket(fake.connection, 1, {host: "target.internal", port: 443})
38
+ /** @type {string[]} */
39
+ const events = []
40
+ socket.on("connect", () => events.push("connect"))
41
+ socket.on("ready", () => events.push("ready"))
42
+ socket._markConnected()
43
+
44
+ assert.deepEqual(events, ["connect", "ready"])
45
+ assert.equal(socket.connecting, false)
46
+ assert.equal(socket.pending, false)
47
+ assert.equal(socket.remoteAddress, "target.internal")
48
+ assert.equal(socket.remotePort, 443)
49
+ assert.deepEqual(socket.address(), {address: "127.0.0.1", family: "IPv4", port: 43210})
50
+ assert.equal(socket.setNoDelay(), socket)
51
+ assert.equal(socket.setKeepAlive(), socket)
52
+ assert.equal(socket.ref(), socket)
53
+ assert.equal(socket.unref(), socket)
54
+ assert.equal(fake.calls.refs, 1)
55
+ assert.equal(fake.calls.unrefs, 1)
56
+ socket.destroy()
57
+ await new Promise((resolve) => socket.once("close", resolve))
58
+ })
59
+
60
+ test("VirtualSocket write backpressure emits drain and end emits finish", async () => {
61
+ const fake = fakeConnection()
62
+ const socket = new VirtualSocket(fake.connection, 3, {
63
+ host: "127.0.0.1",
64
+ port: 80,
65
+ writableHighWaterMark: 1,
66
+ allowHalfOpen: true
67
+ })
68
+ socket._markConnected()
69
+ const drained = new Promise((resolve) => socket.once("drain", resolve))
70
+ assert.equal(socket.write("payload"), false)
71
+ await drained
72
+ assert.equal(socket.bytesWritten, 7)
73
+ const finished = new Promise((resolve) => socket.once("finish", resolve))
74
+ socket.end()
75
+ await finished
76
+ assert.equal(fake.calls.ended, 1)
77
+ socket.destroy()
78
+ })
79
+
80
+ test("VirtualSocket pause/resume, timeout, data and half-close remain stream-local", async () => {
81
+ const fake = fakeConnection()
82
+ const socket = new VirtualSocket(fake.connection, 5, {host: "127.0.0.1", port: 80, allowHalfOpen: true})
83
+ socket._markConnected()
84
+ socket.pause()
85
+ const timeout = new Promise((resolve) => socket.setTimeout(10, () => resolve(undefined)))
86
+ await timeout
87
+ socket.setTimeout(0)
88
+ const data = new Promise((resolve) => socket.once("data", (chunk) => resolve(chunk.toString())))
89
+ socket._receive(Buffer.from("hello"))
90
+ socket.resume()
91
+ assert.equal(await data, "hello")
92
+ assert.equal(socket.bytesRead, 5)
93
+ const ended = new Promise((resolve) => socket.once("end", resolve))
94
+ socket._receiveEnd()
95
+ await ended
96
+ assert(fake.calls.resumed > 0)
97
+ socket.destroy()
98
+ })
@@ -0,0 +1,274 @@
1
+ import assert from "node:assert/strict"
2
+ import {spawnSync} from "node:child_process"
3
+ import {copyFile, mkdtemp, readFile, readdir, rm} from "node:fs/promises"
4
+ import {tmpdir} from "node:os"
5
+ import {delimiter, join} from "node:path"
6
+ import test from "node:test"
7
+
8
+ const composePath = new URL("../../../docker-compose.yml", import.meta.url)
9
+ const developmentComposePath = new URL("../../../docker-compose.dev.yml", import.meta.url)
10
+ const reverseComposePath = new URL("../../../docker-compose.reverse-gateway.yml", import.meta.url)
11
+ const envExamplePath = new URL("../../../.env.example", import.meta.url)
12
+ const githubWorkflowsPath = new URL("../../../.github/workflows/", import.meta.url)
13
+ const docsPath = new URL("../../../docs/docker.md", import.meta.url)
14
+ const packagePath = new URL("../../../package.json", import.meta.url)
15
+ const runningDocsPath = new URL("../../../docs/running.md", import.meta.url)
16
+ const scriptPath = new URL("../../../scripts/deploy-gateway.js", import.meta.url)
17
+ const securityDocsPath = new URL("../../../docs/security.md", import.meta.url)
18
+ const tensorbuzzPath = new URL("../../../tensorbuzz.yml", import.meta.url)
19
+
20
+ /** @returns {Promise<string[]>} GitHub Actions workflow filenames, if the directory exists. */
21
+ async function githubWorkflowNames() {
22
+ try {
23
+ return await readdir(githubWorkflowsPath)
24
+ } catch (error) {
25
+ if (error instanceof Error && "code" in error && error.code === "ENOENT") return []
26
+ throw error
27
+ }
28
+ }
29
+
30
+ test("TensorBuzz exclusively owns verification and container build validation", async () => {
31
+ const [packageJson, tensorbuzz, workflowNames] = await Promise.all([
32
+ readFile(packagePath, "utf8"),
33
+ readFile(tensorbuzzPath, "utf8"),
34
+ githubWorkflowNames()
35
+ ])
36
+ const packageMetadata = JSON.parse(packageJson)
37
+
38
+ assert.deepEqual(workflowNames, [])
39
+ assert.match(tensorbuzz, /DOCKER_HOST: "tcp:\/\/docker_server:2375"/)
40
+ assert.match(tensorbuzz, /docker_server:\n\s+image: "docker:[^"]+-dind"/)
41
+ assert.match(tensorbuzz, /privileged: true/)
42
+ assert.match(tensorbuzz, /docker build --tag socketduct-ci \./)
43
+ assert.doesNotMatch(tensorbuzz, /docker (?:login|push)/)
44
+ assert.equal(packageMetadata.private, true)
45
+ assert.equal(packageMetadata.license, "UNLICENSED")
46
+ })
47
+
48
+ test("Docker gateway files define a private Docker socket shim target", async () => {
49
+ const [compose, envExample, docs] = await Promise.all([
50
+ readFile(composePath, "utf8"),
51
+ readFile(envExamplePath, "utf8"),
52
+ readFile(docsPath, "utf8")
53
+ ])
54
+
55
+ assert.match(compose, /docker-socket-shim:/)
56
+ assert.match(compose, /profiles:\n\s+- docker-host/)
57
+ assert.match(compose, /\/var\/run\/docker\.sock:\/var\/run\/docker\.sock/)
58
+ assert.match(compose, /expose:\n\s+- "2375"/)
59
+ assert.doesNotMatch(compose, /2375:2375/)
60
+ assert.match(envExample, /SOCKETDUCT_TARGET_ALLOWLIST=docker-socket-shim:2375/)
61
+ assert.match(docs, /Docker socket shim/)
62
+ })
63
+
64
+ test("native reverse gateway Compose has outbound-only private Docker access", async () => {
65
+ const compose = await readFile(reverseComposePath, "utf8")
66
+ assert.match(compose, /SOCKETDUCT_MODE: reverse-gateway/)
67
+ assert.match(compose, /SOCKETDUCT_TARGET_ALLOWLIST: docker-server:2375/)
68
+ assert.match(compose, /docker-control:/)
69
+ assert.match(compose, /wan-egress:/)
70
+ assert.doesNotMatch(compose, /\bports:/)
71
+ assert.doesNotMatch(compose, /socat|socket-shim|2375:2375/)
72
+ })
73
+
74
+ test("production Compose roles expose the opt-in persistent log mount contract", async () => {
75
+ const [compose, reverseCompose, envExample, docs] = await Promise.all([
76
+ readFile(composePath, "utf8"),
77
+ readFile(reverseComposePath, "utf8"),
78
+ readFile(envExamplePath, "utf8"),
79
+ readFile(docsPath, "utf8")
80
+ ])
81
+
82
+ for (const model of [compose, reverseCompose]) {
83
+ assert.match(model, /\$\{SOCKETDUCT_LOG_HOST_DIRECTORY:-\.\/logs\}:\/var\/log\/socketduct/)
84
+ }
85
+ assert.match(envExample, /SOCKETDUCT_LOG_DIRECTORY=\/var\/log\/socketduct/)
86
+ assert.match(envExample, /SOCKETDUCT_LOG_HOST_DIRECTORY=\/srv\/socketduct\/logs/)
87
+ assert.match(docs, /SOCKETDUCT_LOG_HOST_DIRECTORY/)
88
+ assert.match(docs, /\/var\/log\/socketduct/)
89
+ })
90
+
91
+ test("production gateway Compose requires an immutable Socketduct GHCR image", async () => {
92
+ const [compose, developmentCompose, envExample] = await Promise.all([
93
+ readFile(composePath, "utf8"),
94
+ readFile(developmentComposePath, "utf8"),
95
+ readFile(envExamplePath, "utf8")
96
+ ])
97
+
98
+ assert.match(compose, /image: \$\{SOCKETDUCT_IMAGE:\?[^}]+\}/)
99
+ assert.doesNotMatch(compose, /\bbuild:/)
100
+ assert.doesNotMatch(compose, /socketduct:latest/)
101
+ assert.match(developmentCompose, /\bbuild:/)
102
+ assert.match(envExample, /SOCKETDUCT_IMAGE=ghcr\.io\/kaspernj\/socketduct@sha256:/)
103
+ })
104
+
105
+ test("gateway deployment rejects mutable images before running Docker", async () => {
106
+ const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
107
+ /** @type {Array<[string, string[]]>} */
108
+ const calls = []
109
+
110
+ for (const image of [
111
+ undefined,
112
+ "",
113
+ "socketduct:latest",
114
+ "ghcr.io/kaspernj/socketduct:latest",
115
+ "ghcr.io/kaspernj/socketduct:main",
116
+ "ghcr.io/kaspernj/socketduct:40cc261",
117
+ `ghcr.io/kaspernj/socketduct:release@sha256:${"a".repeat(64)}`,
118
+ `ghcr.io/kaspernj/socketduct@sha256:${"A".repeat(64)}`
119
+ ]) {
120
+ await assert.rejects(
121
+ deployGateway({image, run: (command, args) => { calls.push([command, args]); return "" }}),
122
+ /immutable GHCR image/
123
+ )
124
+ }
125
+ assert.deepEqual(calls, [])
126
+ })
127
+
128
+ test("gateway deployment fixes Compose inputs, neutralizes ambient selectors, then verifies and applies", async () => {
129
+ const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
130
+ /** @type {Array<[string, string[], {captureStdout?: boolean, env: Record<string, string | undefined>}]>} */
131
+ const calls = []
132
+ const digest = "a".repeat(64)
133
+ const image = `ghcr.io/kaspernj/socketduct@sha256:${digest}`
134
+ const environment = {
135
+ PATH: "/usr/bin",
136
+ DOCKER_HOST: "ssh://gateway.example.test",
137
+ DOCKER_CONTEXT: "production-gateway",
138
+ COMPOSE_FILE: "/tmp/mutable-compose.yml",
139
+ COMPOSE_ENV_FILES: "/tmp/mutable.env",
140
+ COMPOSE_DISABLE_ENV_FILE: "1",
141
+ COMPOSE_IGNORE_ORPHANS: "1",
142
+ COMPOSE_REMOVE_ORPHANS: "1",
143
+ COMPOSE_PATH_SEPARATOR: ";",
144
+ COMPOSE_PROFILES: "docker-host",
145
+ COMPOSE_PROJECT_NAME: "ambient-project"
146
+ }
147
+
148
+ await deployGateway({
149
+ environment,
150
+ image,
151
+ projectDirectory: "/srv/socketduct release #1",
152
+ run: (command, args, options) => {
153
+ calls.push([command, args, options])
154
+ return args.includes("config")
155
+ ? JSON.stringify({services: {"socketduct-relay": {image}}})
156
+ : ""
157
+ }
158
+ })
159
+
160
+ const compose = [
161
+ "compose",
162
+ "--file", "/srv/socketduct release #1/docker-compose.yml",
163
+ "--env-file", "/srv/socketduct release #1/.env",
164
+ "--project-directory", "/srv/socketduct release #1",
165
+ "--project-name", "socketduct-gateway"
166
+ ]
167
+ assert.deepEqual(calls.map(([command, args, options]) => [command, args, options.captureStdout]), [
168
+ ["docker", [...compose, "config", "--format", "json"], true],
169
+ ["docker", [...compose, "pull"], undefined],
170
+ ["docker", [...compose, "up", "-d", "--wait", "--no-build"], undefined]
171
+ ])
172
+ for (const [, , options] of calls) {
173
+ assert.equal(options.env.DOCKER_HOST, environment.DOCKER_HOST)
174
+ assert.equal(options.env.DOCKER_CONTEXT, environment.DOCKER_CONTEXT)
175
+ assert.equal(options.env.COMPOSE_FILE, "/srv/socketduct release #1/docker-compose.yml")
176
+ assert.equal(options.env.COMPOSE_ENV_FILES, "/srv/socketduct release #1/.env")
177
+ assert.equal(options.env.COMPOSE_PATH_SEPARATOR, delimiter)
178
+ assert.equal(options.env.COMPOSE_DISABLE_ENV_FILE, "false")
179
+ assert.equal(options.env.COMPOSE_IGNORE_ORPHANS, "false")
180
+ assert.equal(options.env.COMPOSE_REMOVE_ORPHANS, "false")
181
+ assert.equal(options.env.COMPOSE_PROFILES, "")
182
+ assert.equal(options.env.COMPOSE_PROJECT_NAME, "socketduct-gateway")
183
+ assert.equal(options.env.SOCKETDUCT_IMAGE, image)
184
+ }
185
+ })
186
+
187
+ test("gateway deployment enables only the explicit docker-host profile", async () => {
188
+ const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
189
+ /** @type {Array<{args: string[], environment: Record<string, string | undefined>}>} */
190
+ const calls = []
191
+ const image = `ghcr.io/kaspernj/socketduct@sha256:${"b".repeat(64)}`
192
+
193
+ await deployGateway({
194
+ dockerHost: true,
195
+ environment: {COMPOSE_PROFILES: "unexpected"},
196
+ image,
197
+ projectDirectory: "/srv/socketduct",
198
+ run: (_command, args, options) => {
199
+ calls.push({args, environment: options.env})
200
+ return args.includes("config")
201
+ ? JSON.stringify({services: {"socketduct-relay": {image}}})
202
+ : ""
203
+ }
204
+ })
205
+
206
+ assert.equal(calls.length, 3)
207
+ for (const {args, environment: childEnvironment} of calls) {
208
+ assert.equal(args.filter((argument) => argument === "--profile").length, 1)
209
+ assert.equal(args[args.indexOf("--profile") + 1], "docker-host")
210
+ assert.equal(childEnvironment.COMPOSE_PROFILES, "")
211
+ }
212
+ })
213
+
214
+ test("gateway deployment fails closed on a rendered image mismatch or build source", async () => {
215
+ const {deployGateway} = await import("../../../scripts/deploy-gateway.js")
216
+ const image = `ghcr.io/kaspernj/socketduct@sha256:${"c".repeat(64)}`
217
+ const renderedModels = [
218
+ {services: {"socketduct-relay": {image: "ghcr.io/kaspernj/socketduct:latest"}}},
219
+ {services: {"socketduct-relay": {build: {context: "."}, image}}},
220
+ {services: {}}
221
+ ]
222
+
223
+ for (const rendered of renderedModels) {
224
+ let calls = 0
225
+ await assert.rejects(deployGateway({
226
+ environment: {},
227
+ image,
228
+ projectDirectory: "/srv/socketduct",
229
+ run: () => {
230
+ calls += 1
231
+ return JSON.stringify(rendered)
232
+ }
233
+ }), /rendered.*socketduct-relay|build source/i)
234
+ assert.equal(calls, 1)
235
+ }
236
+ })
237
+
238
+ test("gateway deployment CLI runs from paths containing spaces and hash characters", async () => {
239
+ const root = await mkdtemp(join(tmpdir(), "socketduct gateway #"))
240
+ const escapedScriptPath = join(root, "deploy gateway #.mjs")
241
+ try {
242
+ await copyFile(scriptPath, escapedScriptPath)
243
+ const result = spawnSync(process.execPath, [escapedScriptPath], {
244
+ encoding: "utf8",
245
+ env: {...process.env, SOCKETDUCT_IMAGE: "ghcr.io/kaspernj/socketduct:latest"}
246
+ })
247
+
248
+ assert.equal(result.status, 1)
249
+ assert.match(result.stderr, /immutable GHCR image/)
250
+ } finally {
251
+ await rm(root, {recursive: true, force: true})
252
+ }
253
+ })
254
+
255
+ test("gateway documentation describes pinning, private GHCR login, and digest rollback", async () => {
256
+ const [dockerDocs, packageJson, runningDocs, securityDocs] = await Promise.all([
257
+ readFile(docsPath, "utf8"),
258
+ readFile(packagePath, "utf8"),
259
+ readFile(runningDocsPath, "utf8"),
260
+ readFile(securityDocsPath, "utf8")
261
+ ])
262
+ const publicDocs = `${dockerDocs}\n${runningDocs}\n${securityDocs}`
263
+
264
+ assert.equal(JSON.parse(packageJson).scripts["gateway:deploy"], "node scripts/deploy-gateway.js")
265
+ assert.match(dockerDocs, /Node\.js 24/i)
266
+ assert.match(dockerDocs, /node --version/)
267
+ assert.match(dockerDocs, /npm --version/)
268
+ assert.match(dockerDocs, /npm run gateway:deploy/)
269
+ assert.match(dockerDocs, /--project-name socketduct-gateway/)
270
+ assert.match(dockerDocs, /docker login ghcr\.io/)
271
+ assert.match(dockerDocs, /rollback/i)
272
+ assert.match(dockerDocs, /change.*digest/i)
273
+ assert.doesNotMatch(publicDocs, /docker compose[^\n]*up[^\n]*--build/)
274
+ })