@yourrealm/sdk 0.0.0-alpha.1378.ce03c8dc

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 (230) hide show
  1. package/LICENSE-EXCEPTION +26 -0
  2. package/README.md +34 -0
  3. package/dist/chunk-3R6KEFOI.js +22 -0
  4. package/dist/chunk-67BDAMYX.js +376 -0
  5. package/dist/chunk-AITGS27R.js +139 -0
  6. package/dist/chunk-CMAEHQ4F.js +6 -0
  7. package/dist/chunk-MLKGABMK.js +9 -0
  8. package/dist/chunk-O3UIK34B.js +1430 -0
  9. package/dist/chunk-VHTUSZUX.js +176 -0
  10. package/dist/cli.js +9006 -0
  11. package/dist/docker.js +54 -0
  12. package/dist/index.js +1053 -0
  13. package/dist/ingress.js +436 -0
  14. package/dist/jsx-runtime.js +2 -0
  15. package/dist/preact.js +22 -0
  16. package/dist/service-url.js +11 -0
  17. package/dist/services.js +213 -0
  18. package/dist/test.js +5069 -0
  19. package/dist/ui.js +254 -0
  20. package/dist/worker-protocol.js +89 -0
  21. package/package.json +112 -0
  22. package/src/app-icon.ts +74 -0
  23. package/src/apply.ts +52 -0
  24. package/src/bundle-ui.ts +211 -0
  25. package/src/bundle.ts +546 -0
  26. package/src/cli/build-dev-landing.ts +84 -0
  27. package/src/cli/build-install-panel.ts +90 -0
  28. package/src/cli/build-runtime.ts +133 -0
  29. package/src/cli/build.ts +63 -0
  30. package/src/cli/dev.ts +107 -0
  31. package/src/cli/down.ts +60 -0
  32. package/src/cli/main.ts +56 -0
  33. package/src/cli/test.ts +61 -0
  34. package/src/cli/validate.ts +37 -0
  35. package/src/compile.ts +100 -0
  36. package/src/create-app.ts +982 -0
  37. package/src/deep-merge.ts +33 -0
  38. package/src/dev/fixture.ts +48 -0
  39. package/src/dev/install-handler.ts +383 -0
  40. package/src/dev/server.ts +968 -0
  41. package/src/dev/stack.ts +108 -0
  42. package/src/dev/state.ts +159 -0
  43. package/src/docker.ts +96 -0
  44. package/src/errors.ts +61 -0
  45. package/src/factory.ts +481 -0
  46. package/src/garage.ts +56 -0
  47. package/src/generator/hash.ts +6 -0
  48. package/src/generator/resolve-refs.ts +207 -0
  49. package/src/grants-builder.ts +100 -0
  50. package/src/grants.ts +260 -0
  51. package/src/home-api.ts +172 -0
  52. package/src/home-cookie.ts +10 -0
  53. package/src/home-registry.ts +120 -0
  54. package/src/home.ts +187 -0
  55. package/src/host-registry.ts +143 -0
  56. package/src/http-header.ts +20 -0
  57. package/src/identifiers.ts +49 -0
  58. package/src/index.ts +66 -0
  59. package/src/info.ts +64 -0
  60. package/src/ini.ts +50 -0
  61. package/src/json.ts +14 -0
  62. package/src/jsx-runtime/index.ts +5 -0
  63. package/src/keys.ts +29 -0
  64. package/src/mysql.ts +56 -0
  65. package/src/pack-build.ts +108 -0
  66. package/src/pack.ts +115 -0
  67. package/src/peer-registry.ts +121 -0
  68. package/src/peer-world.ts +117 -0
  69. package/src/permission-meta.ts +116 -0
  70. package/src/permissions.ts +108 -0
  71. package/src/pkg-root.ts +34 -0
  72. package/src/postgres.ts +79 -0
  73. package/src/preact/index.ts +27 -0
  74. package/src/redis.ts +43 -0
  75. package/src/ref.ts +271 -0
  76. package/src/remote-registry.ts +33 -0
  77. package/src/required-host-kinds.ts +110 -0
  78. package/src/resolve-context.ts +111 -0
  79. package/src/resolve.ts +471 -0
  80. package/src/runtime/docker/compose/compose-schema.ts +118 -0
  81. package/src/runtime/docker/compose/convert-to-docker-compose.ts +278 -0
  82. package/src/runtime/docker/compose/default-hardening.ts +21 -0
  83. package/src/runtime/docker/compose/effects.ts +107 -0
  84. package/src/runtime/docker/compose/index.ts +17 -0
  85. package/src/runtime/docker/compose/labels.ts +39 -0
  86. package/src/runtime/docker/compose/types.ts +78 -0
  87. package/src/runtime/docker/home-proxy-server/index.ts +177 -0
  88. package/src/runtime/docker/home-proxy-server/protocol.ts +53 -0
  89. package/src/runtime/docker/ingress/build-config.ts +819 -0
  90. package/src/runtime/docker/ingress/index.ts +16 -0
  91. package/src/runtime/docker/ingress/types.ts +182 -0
  92. package/src/runtime/docker/log-frames.ts +121 -0
  93. package/src/runtime/docker/logs-server/index.ts +366 -0
  94. package/src/runtime/docker/spawn-server/context-key.ts +82 -0
  95. package/src/runtime/docker/spawn-server/index.ts +492 -0
  96. package/src/runtime/docker/spawn-server/protocol.ts +70 -0
  97. package/src/runtime/docker/spawn-server/spec.ts +122 -0
  98. package/src/runtime/docker/stats-server/disks.ts +161 -0
  99. package/src/runtime/docker/stats-server/index.ts +322 -0
  100. package/src/runtime/home-client/index.ts +71 -0
  101. package/src/runtime/log-line.ts +24 -0
  102. package/src/runtime/logs-client/index.ts +175 -0
  103. package/src/runtime/spawn-client/index.ts +93 -0
  104. package/src/runtime/spawn-client/kaniko.ts +79 -0
  105. package/src/runtime/stats-client/index.ts +65 -0
  106. package/src/runtime/stats-sample.ts +95 -0
  107. package/src/safe-json.ts +37 -0
  108. package/src/sandbox.ts +1209 -0
  109. package/src/schema/build-apps.ts +38 -0
  110. package/src/schema/home.ts +217 -0
  111. package/src/schema/host.ts +792 -0
  112. package/src/schema/inputs.ts +26 -0
  113. package/src/schema/nodes.ts +75 -0
  114. package/src/schema/peer.ts +400 -0
  115. package/src/schema/public-domain.ts +116 -0
  116. package/src/schema/remote.ts +74 -0
  117. package/src/security.ts +594 -0
  118. package/src/serialize.ts +21 -0
  119. package/src/service-definition.ts +284 -0
  120. package/src/service-url.ts +64 -0
  121. package/src/service.ts +39 -0
  122. package/src/services/index.ts +5 -0
  123. package/src/shared.ts +65 -0
  124. package/src/speculative-silo.ts +96 -0
  125. package/src/stable-secret.ts +14 -0
  126. package/src/surfaces.ts +264 -0
  127. package/src/tailwind-bin.ts +56 -0
  128. package/src/test/boot.ts +192 -0
  129. package/src/test/compile.ts +501 -0
  130. package/src/test/describe.ts +117 -0
  131. package/src/test/index.ts +11 -0
  132. package/src/test/ingress-fixture.ts +37 -0
  133. package/src/test/types.ts +318 -0
  134. package/src/trusted-headers.ts +79 -0
  135. package/src/types.ts +126 -0
  136. package/src/ui/components/aside.tsx +96 -0
  137. package/src/ui/components/badge.tsx +42 -0
  138. package/src/ui/components/button.tsx +59 -0
  139. package/src/ui/components/card.tsx +106 -0
  140. package/src/ui/components/checkbox.tsx +37 -0
  141. package/src/ui/components/code-block.tsx +29 -0
  142. package/src/ui/components/combobox.tsx +119 -0
  143. package/src/ui/components/config-field.tsx +284 -0
  144. package/src/ui/components/dialog.tsx +137 -0
  145. package/src/ui/components/domain-editor.tsx +317 -0
  146. package/src/ui/components/domain-list.ts +205 -0
  147. package/src/ui/components/dropdown-menu.tsx +73 -0
  148. package/src/ui/components/empty-state.tsx +51 -0
  149. package/src/ui/components/highlight.ts +59 -0
  150. package/src/ui/components/icons.tsx +101 -0
  151. package/src/ui/components/image-posture.tsx +85 -0
  152. package/src/ui/components/input.tsx +28 -0
  153. package/src/ui/components/label.tsx +16 -0
  154. package/src/ui/components/line-chart.tsx +113 -0
  155. package/src/ui/components/page.tsx +39 -0
  156. package/src/ui/components/permission-row.tsx +262 -0
  157. package/src/ui/components/permission-section.tsx +51 -0
  158. package/src/ui/components/pie-chart.tsx +245 -0
  159. package/src/ui/components/schema-block.tsx +60 -0
  160. package/src/ui/components/separator.tsx +42 -0
  161. package/src/ui/components/sheet.tsx +130 -0
  162. package/src/ui/components/sidebar.tsx +650 -0
  163. package/src/ui/components/silo-picker.tsx +510 -0
  164. package/src/ui/components/skeleton.tsx +13 -0
  165. package/src/ui/components/sonner.tsx +33 -0
  166. package/src/ui/components/switch.tsx +58 -0
  167. package/src/ui/components/table-view.ts +219 -0
  168. package/src/ui/components/table.tsx +506 -0
  169. package/src/ui/components/tabs.tsx +59 -0
  170. package/src/ui/components/timeago.tsx +84 -0
  171. package/src/ui/components/tooltip.tsx +56 -0
  172. package/src/ui/components.json +21 -0
  173. package/src/ui/dev/dev-shell.tsx +145 -0
  174. package/src/ui/home/home-grid.tsx +177 -0
  175. package/src/ui/hooks/use-mobile.ts +19 -0
  176. package/src/ui/index.ts +189 -0
  177. package/src/ui/install/adapter.ts +193 -0
  178. package/src/ui/install/deploy-targets-picker.tsx +185 -0
  179. package/src/ui/install/install-app-panel.tsx +912 -0
  180. package/src/ui/install/install-error.tsx +62 -0
  181. package/src/ui/install/perms-inactive.ts +50 -0
  182. package/src/ui/install/required-fields.ts +37 -0
  183. package/src/ui/install/tabs-form.tsx +557 -0
  184. package/src/ui/tokens.css +279 -0
  185. package/src/ui/utils.ts +78 -0
  186. package/src/ui-error.ts +139 -0
  187. package/src/ui-runtime/dev-landing-entry.tsx +258 -0
  188. package/src/ui-runtime/index.ts +83 -0
  189. package/src/ui-runtime/install-entry.tsx +119 -0
  190. package/src/ui-runtime/mount.ts +367 -0
  191. package/src/ui-runtime/runtime.css +56 -0
  192. package/src/ui-surface.ts +451 -0
  193. package/src/valkey.ts +43 -0
  194. package/src/worker/extract-tar.ts +125 -0
  195. package/src/worker/identity.ts +183 -0
  196. package/src/worker/index.ts +16 -0
  197. package/src/worker/public-ip.ts +92 -0
  198. package/src/worker/queue.ts +54 -0
  199. package/src/worker/reconcile.ts +127 -0
  200. package/src/worker/start-worker.ts +227 -0
  201. package/src/worker/ui-proxy.ts +335 -0
  202. package/src/worker/uplink.ts +523 -0
  203. package/src/worker/validate.ts +34 -0
  204. package/src/worker/verify-relay.ts +102 -0
  205. package/src/worker-protocol/app-dirs.ts +94 -0
  206. package/src/worker-protocol/home-proxy-paths.ts +76 -0
  207. package/src/worker-protocol/index.ts +55 -0
  208. package/src/worker-protocol/ingress.ts +166 -0
  209. package/src/worker-protocol/logs-socket-path.ts +50 -0
  210. package/src/worker-protocol/networks.ts +39 -0
  211. package/src/worker-protocol/node-version.ts +70 -0
  212. package/src/worker-protocol/silo-paths.ts +114 -0
  213. package/src/worker-protocol/spawn-paths.ts +165 -0
  214. package/src/worker-protocol/stats-socket-path.ts +40 -0
  215. package/src/worker-protocol/update-files.ts +77 -0
  216. package/src/worker-protocol/uplink/constants.ts +86 -0
  217. package/src/worker-protocol/uplink/errors.ts +61 -0
  218. package/src/worker-protocol/uplink/frame.ts +114 -0
  219. package/src/worker-protocol/uplink/identity.ts +40 -0
  220. package/src/worker-protocol/uplink/index.ts +63 -0
  221. package/src/worker-protocol/uplink/methods.ts +462 -0
  222. package/src/worker-protocol/uplink/mux.ts +646 -0
  223. package/src/worker-protocol/uplink/protocol.ts +11 -0
  224. package/src/worker-protocol/uplink/rpc.ts +309 -0
  225. package/src/worker-protocol/uplink/session.ts +540 -0
  226. package/src/worker-protocol/uplink/test-support.ts +188 -0
  227. package/src/worker-protocol/worker-mount.ts +27 -0
  228. package/src/worker-protocol/worker-state.ts +190 -0
  229. package/src/xml.ts +49 -0
  230. package/src/yaml.ts +15 -0
@@ -0,0 +1,82 @@
1
+ // The broker's mount defense, split into the two halves the back-room seal
2
+ // runs at two different times:
3
+ //
4
+ // 1. validateContextKeyShape(key) — string-only. Runs FIRST, before any fs
5
+ // touch, on the caller-supplied key. Rejects every traversal / absolute /
6
+ // blocklist shape so the only thing we ever `rename` is a clean single
7
+ // segment under the scratch root.
8
+ // 2. assertSealedDir(dir, root) — real-filesystem. Runs AFTER the broker has
9
+ // renamed that segment into its per-box back room, on the MOVED inode.
10
+ // Rejects a symlink and confirms the real path stays inside the back room.
11
+ //
12
+ // The split is what makes the seal sound: a string check can't stop a symlink
13
+ // planted in the shared scratch root (Docker follows symlinks in a bind source
14
+ // at mount time, so `spawn/evil -> /var/run/docker.sock` would escape), and a
15
+ // real-fs check done *before* the rename is a TOCTOU (the caller can swap the
16
+ // validated dir for a symlink in the validate→mount window). Renaming the
17
+ // segment into a dir the caller can't reach, then re-validating the moved
18
+ // inode, closes that window — `rename(2)` never follows symlinks on either
19
+ // path and is atomic w.r.t. concurrent fs ops.
20
+
21
+ import { isValidHostDirectoryPath } from "../../../schema/host.ts";
22
+
23
+ /**
24
+ * Shape-check a caller-supplied context key as text only — no filesystem
25
+ * access. `key` must be a single, clean path segment: non-empty, not `.` or
26
+ * `..`, no separators, no `..` substring. As defense in depth it must also
27
+ * clear the same dangerous-root blocklist operator-set Silo paths use
28
+ * (so a segment named `etc`, `proc`, … is refused even though joining it under
29
+ * the scratch root would be harmless). Those segment checks ARE the logical
30
+ * confinement: a key with no separator and no `..` cannot escape whatever root
31
+ * it's joined under. Throws on any violation; the broker turns the throw into a
32
+ * terminal error line. Runs before the rename, so a bad key never moves a file.
33
+ */
34
+ export function validateContextKeyShape(key: string): void {
35
+ if (!key || key === "." || key === "..") {
36
+ throw new Error(`invalid context key: ${JSON.stringify(key)}`);
37
+ }
38
+ if (key.includes("/") || key.includes("\\")) {
39
+ throw new Error(`context key must be a single path segment: ${JSON.stringify(key)}`);
40
+ }
41
+ if (key.includes("..")) {
42
+ throw new Error(`context key must not contain "..": ${JSON.stringify(key)}`);
43
+ }
44
+ // Mirror the host-path blocklist: a key that maps onto a dangerous root
45
+ // (e.g. `etc` → `/etc`) is refused even though joining it under the scratch
46
+ // root would be harmless.
47
+ if (!isValidHostDirectoryPath(`/${key}`)) {
48
+ throw new Error(`context key resolves to a blocked root: ${JSON.stringify(key)}`);
49
+ }
50
+ }
51
+
52
+ /**
53
+ * Real-filesystem check on a directory the broker has just renamed into its
54
+ * per-box back room, confirming it's safe to hand to Docker as a bind source.
55
+ * `dir` must exist, must NOT be a symlink, and its canonical real path must
56
+ * stay inside the canonical `root` (the back-room box dir). Throws otherwise.
57
+ *
58
+ * This is the post-rename half of the seal. If the caller planted a symlink at
59
+ * `scratch/<key>`, the broker's `rename` moved the *symlink object* in, so the
60
+ * `lstat` here catches it. A swap racing the rename either landed before it
61
+ * (caught here) or after (on a path no longer in scratch — this inode is
62
+ * already sealed). Docker follows symlinks in a bind source at mount time, so
63
+ * an un-caught symlink (`-> /` or `-> /var/run/docker.sock`) would be a full
64
+ * host escape; rejecting it here, on a path the caller has no mount into, is
65
+ * what closes the window between validation and mount.
66
+ */
67
+ export async function assertSealedDir(dir: string, root: string): Promise<void> {
68
+ const realRoot = await Deno.realPath(root);
69
+ let info: Deno.FileInfo;
70
+ try {
71
+ info = await Deno.lstat(dir);
72
+ } catch {
73
+ throw new Error(`sealed context not found: ${JSON.stringify(dir)}`);
74
+ }
75
+ if (info.isSymlink) {
76
+ throw new Error(`sealed context must not be a symlink: ${JSON.stringify(dir)}`);
77
+ }
78
+ const real = await Deno.realPath(dir);
79
+ if (real !== realRoot && !real.startsWith(`${realRoot}/`)) {
80
+ throw new Error(`sealed context escapes the box dir: ${JSON.stringify(dir)}`);
81
+ }
82
+ }
@@ -0,0 +1,492 @@
1
+ // Worker-side `spawn.sock` broker. Binds a unix socket; a granted app
2
+ // connects, sends one request line, and gets back a JSON Lines stream of
3
+ // its box's output plus one terminal line. One connection == one build.
4
+ //
5
+ // The broker is the one path that bypasses Home eval, so it re-imposes
6
+ // every guarantee the compose path gets from Home: it CONSTRUCTS a fixed,
7
+ // fully-hardened container spec (spec.ts) from a tiny allowlist of params
8
+ // and never accepts a HostConfig, an absolute mount, or a network name
9
+ // from the caller. `spawn.sock` is strictly less powerful than
10
+ // `docker.sock`. Treat this as the highest-scrutiny code in the worker.
11
+
12
+ import { dirname, join } from "@std/path";
13
+ import { KINDS, LABELS, ode } from "@local/docker";
14
+ import {
15
+ spawnArtifactsPath,
16
+ spawnPrivatePath,
17
+ spawnScratchPath,
18
+ } from "../../../worker-protocol/spawn-paths.ts";
19
+ import { UNTRUSTED_NET } from "../../../worker-protocol/networks.ts";
20
+ import { dechunkedReader, streamDockerLogFrames } from "../log-frames.ts";
21
+ import {
22
+ spawnRequestSchema,
23
+ type SpawnLogLine,
24
+ type SpawnRequest,
25
+ type SpawnTerminal,
26
+ } from "./protocol.ts";
27
+ import { assertSealedDir, validateContextKeyShape } from "./context-key.ts";
28
+ import { buildSpawnSpec, type SpawnLimits } from "./spec.ts";
29
+
30
+ /**
31
+ * The slice of the Docker layer the build path uses. Threaded through
32
+ * {@link BuildConfig} (defaulting to the real `ode`) so a unit test can drive
33
+ * `runBuild` with a mock and assert the seal's ordering without a live daemon —
34
+ * a TOCTOU race can't be reliably won against real Docker, so that coverage
35
+ * lives here, not in e2e.
36
+ */
37
+ export type SpawnOde = Pick<
38
+ typeof ode,
39
+ | "inspectImage"
40
+ | "pullImage"
41
+ | "createContainer"
42
+ | "startContainer"
43
+ | "followContainerLogs"
44
+ | "waitContainer"
45
+ | "removeContainer"
46
+ >;
47
+
48
+ // Conservative per-box resource caps. Env-overridable so an operator can lift
49
+ // them for a heavy build without a code change.
50
+ const DEFAULT_MEMORY_BYTES = 2 * 1024 * 1024 * 1024; // 2 GiB
51
+ const DEFAULT_NANO_CPUS = 2_000_000_000; // 2 cores
52
+ const DEFAULT_TIMEOUT_MS = 30 * 60 * 1000; // 30 min wall-clock per build
53
+ // Caps the raw request line so a hostile caller can't exhaust memory
54
+ // before the schema runs.
55
+ const MAX_REQUEST_BYTES = 64 * 1024;
56
+ // A caller must send its (small) request line within this window; bounds
57
+ // a slow-loris that connects and stalls to pin a connection/fd. The
58
+ // build wall-clock starts only after the request is parsed, so it does
59
+ // not cover this phase.
60
+ const REQUEST_READ_TIMEOUT_MS = 30 * 1000;
61
+
62
+ const encoder = new TextEncoder();
63
+ const decoder = new TextDecoder();
64
+
65
+ interface StartOpts {
66
+ /**
67
+ * App this broker fronts. Identity is the socket the request arrived on,
68
+ * never the wire — the broker derives this app's scratch dir from the
69
+ * appId, so a caller can't reach another app's tree. Mirrors
70
+ * `home-proxy-server`'s `opts.appId`.
71
+ */
72
+ appId: string;
73
+ /**
74
+ * Host path to bind this app's spawn socket at. The worker passes the
75
+ * per-app `spawn/sock/<appId>/spawn.sock` path (the socket sits alone in its
76
+ * own per-app dir; the server mkdirs the dir before binding).
77
+ */
78
+ socketPath: string;
79
+ /** Per-box resource caps. Defaults read from env, then constants. */
80
+ limits?: SpawnLimits;
81
+ /** Per-build wall-clock budget, ms. Defaults from env, then constant. */
82
+ timeoutMs?: number;
83
+ /** Override the scratch root. Defaults to `spawnScratchPath(appId)`. For tests. */
84
+ scratchRoot?: string;
85
+ /** Override the artifacts root. Defaults to `spawnArtifactsPath(appId)`. For tests. */
86
+ artifactsRoot?: string;
87
+ /**
88
+ * Override the broker-private back-room root. Defaults to
89
+ * `spawnPrivatePath(appId)`. For tests.
90
+ */
91
+ privateRoot?: string;
92
+ /**
93
+ * Test seam: fired once in the validate→mount window — after the seal renames
94
+ * the context into the back room, before the box is created. Lets a test
95
+ * attempt a swap in that window and confirm it hits a path the box never
96
+ * mounts. Undefined in production.
97
+ */
98
+ beforeMount?: () => Promise<void>;
99
+ /** Injectable Docker layer. Defaults to the real `ode`. For tests. */
100
+ ode?: SpawnOde;
101
+ }
102
+
103
+ interface ServerHandle {
104
+ /** Stop the server: close the listener, drop active conns, remove the socket file. */
105
+ stop(): Promise<void>;
106
+ }
107
+
108
+ interface BuildConfig {
109
+ /** The app this broker fronts — stamped on each box as `realm.spawned-by`. */
110
+ appId: string;
111
+ scratchRoot: string;
112
+ artifactsRoot: string;
113
+ /** Broker-private back room: per-box subdirs hold the sealed bind sources. */
114
+ privateRoot: string;
115
+ limits: SpawnLimits;
116
+ timeoutMs: number;
117
+ ode: SpawnOde;
118
+ beforeMount?: () => Promise<void>;
119
+ }
120
+
121
+ /**
122
+ * Bind one app's spawn broker socket. The global infra (the untrusted-net DMZ
123
+ * + orphaned-box GC) is set up once at boot by {@link bootSpawnInfra}, NOT
124
+ * here — this runs per granted app on every reconcile, so it must stay free of
125
+ * host-wide side effects. Mirrors `startHomeProxyServer`.
126
+ */
127
+ export async function startSpawnServer(opts: StartOpts): Promise<ServerHandle> {
128
+ const { appId, socketPath } = opts;
129
+ const scratchRoot = opts.scratchRoot ?? spawnScratchPath(appId);
130
+ const artifactsRoot = opts.artifactsRoot ?? spawnArtifactsPath(appId);
131
+ const privateRoot = opts.privateRoot ?? spawnPrivatePath(appId);
132
+ const odeApi = opts.ode ?? ode;
133
+ const limits = opts.limits ?? {
134
+ memoryBytes: envInt("REALM_SPAWN_MEMORY_BYTES", DEFAULT_MEMORY_BYTES),
135
+ nanoCpus: envInt("REALM_SPAWN_NANO_CPUS", DEFAULT_NANO_CPUS),
136
+ };
137
+ const timeoutMs = opts.timeoutMs ?? envInt("REALM_SPAWN_TIMEOUT_MS", DEFAULT_TIMEOUT_MS);
138
+
139
+ // Per-app scratch (input) + artifacts (output) + private (the back room)
140
+ // dirs. Created if missing and otherwise left alone, contents included: the
141
+ // first two are bind-mount sources a running app holds by inode, so a rebind
142
+ // across a worker restart must find the same dirs. The worker's reconcile
143
+ // removes the whole `data/<appId>` subtree once the app is off the wire. All
144
+ // three are siblings under `data/<appId>/` so the seal's renames stay same-fs.
145
+ await Deno.mkdir(scratchRoot, { recursive: true }).catch(() => {});
146
+ await Deno.mkdir(artifactsRoot, { recursive: true }).catch(() => {});
147
+ await Deno.mkdir(privateRoot, { recursive: true }).catch(() => {});
148
+
149
+ // The socket lives alone in its own per-app dir under `spawn/sock/<appId>`
150
+ // (the app mounts the dir, not the socket file — a file bind-mount of a unix
151
+ // socket doesn't wire across containers on macOS Docker). Ensure it exists
152
+ // before binding.
153
+ await Deno.mkdir(dirname(socketPath), { recursive: true }).catch(() => {});
154
+ await Deno.remove(socketPath).catch(() => {});
155
+ const listener = Deno.listen({ transport: "unix", path: socketPath });
156
+ const conns = new Set<Deno.Conn>();
157
+
158
+ void acceptLoop(listener, conns, {
159
+ appId,
160
+ scratchRoot,
161
+ artifactsRoot,
162
+ privateRoot,
163
+ limits,
164
+ timeoutMs,
165
+ ode: odeApi,
166
+ beforeMount: opts.beforeMount,
167
+ });
168
+
169
+ return {
170
+ async stop() {
171
+ try {
172
+ listener.close();
173
+ } catch {}
174
+ for (const c of conns) {
175
+ try {
176
+ c.close();
177
+ } catch {}
178
+ }
179
+ conns.clear();
180
+ await Deno.remove(socketPath).catch(() => {});
181
+ },
182
+ };
183
+ }
184
+
185
+ /**
186
+ * Boot-once global spawn infra, independent of any app: ensure the shared
187
+ * untrusted-net DMZ exists, and force-remove every spawn box orphaned by a
188
+ * previous worker process (a restart mid-build leaves the box running, and
189
+ * every spawn-box-kind container is disposable). Split out of the per-app
190
+ * {@link startSpawnServer} so these host-wide steps run exactly once at boot,
191
+ * not once per granted app per reconcile. The per-app socket + tree (scratch +
192
+ * artifacts) is reclaimed worker-side by `reconcileSpawnSockets` once the app
193
+ * is off the wire.
194
+ */
195
+ export async function bootSpawnInfra(): Promise<void> {
196
+ // Idempotent create (mirrors management-net) keeps the broker self-sufficient
197
+ // regardless of install ordering.
198
+ await ensureUntrustedNet();
199
+ await gcStaleBoxes();
200
+ }
201
+
202
+ async function acceptLoop(
203
+ listener: Deno.Listener,
204
+ conns: Set<Deno.Conn>,
205
+ cfg: BuildConfig,
206
+ ): Promise<void> {
207
+ try {
208
+ for await (const conn of listener) {
209
+ conns.add(conn);
210
+ handleConnection(conn as Deno.UnixConn, cfg).finally(() => {
211
+ conns.delete(conn);
212
+ try {
213
+ conn.close();
214
+ } catch {}
215
+ });
216
+ }
217
+ } catch {
218
+ // Listener was closed via stop().
219
+ }
220
+ }
221
+
222
+ // One connection == one build. Any caller or build failure is turned
223
+ // into a terminal error line; nothing throws across the accept loop.
224
+ async function handleConnection(conn: Deno.UnixConn, cfg: BuildConfig): Promise<void> {
225
+ const writer = conn.writable.getWriter();
226
+ let terminal: SpawnTerminal;
227
+ try {
228
+ // Bound the read so a stalled caller can't hold the conn open forever;
229
+ // on timeout the throw lands in the catch below and the finally closes
230
+ // the conn, which unblocks the pending read.
231
+ const req = await withTimeout(
232
+ readRequest(conn),
233
+ REQUEST_READ_TIMEOUT_MS,
234
+ "spawn request read timed out",
235
+ );
236
+ terminal = await runBuild(req, cfg, (line) => writeLine(writer, line));
237
+ } catch (err) {
238
+ terminal = { exit: -1, error: (err as Error).message ?? String(err) };
239
+ }
240
+ await writeLine(writer, terminal).catch(() => {});
241
+ try {
242
+ writer.releaseLock();
243
+ } catch {}
244
+ try {
245
+ conn.close();
246
+ } catch {}
247
+ }
248
+
249
+ // Read one `\n`-terminated request line, bounded, then hardened-parse +
250
+ // Zod-validate. Mirrors the logs server's readSinceLine.
251
+ async function readRequest(conn: Deno.UnixConn): Promise<SpawnRequest> {
252
+ const buf = new Uint8Array(1);
253
+ const collected: number[] = [];
254
+ while (collected.length < MAX_REQUEST_BYTES) {
255
+ const n = await conn.read(buf);
256
+ if (n === null) break;
257
+ if (n === 0) continue;
258
+ if (buf[0] === 0x0a) break;
259
+ collected.push(buf[0]!);
260
+ }
261
+ if (collected.length >= MAX_REQUEST_BYTES) throw new Error("spawn request too large");
262
+ const raw = decoder.decode(new Uint8Array(collected)).trim();
263
+ if (!raw) throw new Error("empty spawn request");
264
+ return spawnRequestSchema.parse(JSON.parse(raw));
265
+ }
266
+
267
+ export async function runBuild(
268
+ req: SpawnRequest,
269
+ cfg: BuildConfig,
270
+ onLog: (line: SpawnLogLine) => Promise<void>,
271
+ ): Promise<SpawnTerminal> {
272
+ // Shape-check the caller's key as text only, before the try — a bad key is a
273
+ // clean terminal error that created nothing on disk.
274
+ validateContextKeyShape(req.context);
275
+
276
+ // One id per box: names the container (`realm-spawn-${boxId}`), the
277
+ // broker-private box dir, and (with output) the artifacts subdir, so the
278
+ // three correlate. A FULL uuid, because these name directories where a
279
+ // collision would cross two builds.
280
+ const boxId = crypto.randomUUID();
281
+ const boxDir = join(cfg.privateRoot, boxId);
282
+ const ctx = join(boxDir, "ctx");
283
+ const out = req.output ? join(boxDir, "out") : undefined;
284
+ // Broker-minted basename the caller reads its output back under (success
285
+ // only); the box never names it. Build-only requests get none.
286
+ const outputKey = req.output ? `output-${boxId}` : undefined;
287
+
288
+ let id: string | undefined;
289
+ let timer: ReturnType<typeof setTimeout> | undefined;
290
+ let timedOut = false;
291
+
292
+ try {
293
+ // From here the finally owns boxDir: it holds `ctx` always, and `out` until
294
+ // the success rename below moves it into the artifacts tree.
295
+ await Deno.mkdir(boxDir, { recursive: true });
296
+
297
+ // THE SEAL. Move the caller's staged context out of the shared,
298
+ // caller-writable scratch root into this broker-private box dir — a dir the
299
+ // calling app has no mount into — then re-validate the MOVED inode. rename(2)
300
+ // never follows symlinks on either path and is atomic w.r.t. concurrent fs
301
+ // ops: a symlink the caller planted at scratch/<key> moves as the symlink
302
+ // OBJECT (caught by assertSealedDir below); a swap racing the rename lands
303
+ // either before it (caught) or after (on a path no longer in scratch — this
304
+ // inode is already sealed). Nothing the caller can reach can swap `ctx`
305
+ // between here and the Docker mount, so there is no validate→mount TOCTOU.
306
+ try {
307
+ await Deno.rename(join(cfg.scratchRoot, req.context), ctx);
308
+ } catch (err) {
309
+ if (err instanceof Deno.errors.NotFound) {
310
+ throw new Error(`spawn context not found: ${JSON.stringify(req.context)}`);
311
+ }
312
+ // EXDEV: scratch and the back room are on different filesystems. NEVER
313
+ // fall back to a copy — a copy re-opens the very TOCTOU the rename closes.
314
+ // Fail loud so the operator co-locates the spawn data tree.
315
+ if (isCrossDevice(err)) {
316
+ throw new Error(
317
+ "spawn private root must be on the same filesystem as scratch — " +
318
+ "both live under spawn/data/<app>/; do not mount them separately",
319
+ );
320
+ }
321
+ throw err;
322
+ }
323
+ await assertSealedDir(ctx, boxDir);
324
+
325
+ // The box's writable output dir, also inside the back room (broker-owned,
326
+ // broker-named — the caller never names it). Created only for an `output`
327
+ // run; moved into artifacts on success below.
328
+ if (out) await Deno.mkdir(out);
329
+
330
+ // Test seam: fires in the validate→mount window the seal above closes.
331
+ await cfg.beforeMount?.();
332
+
333
+ const spec = buildSpawnSpec(req, {
334
+ scratchHostPath: ctx,
335
+ outputHostPath: out,
336
+ limits: cfg.limits,
337
+ spawnedBy: cfg.appId,
338
+ });
339
+
340
+ // Bound the pull on its own budget — the build wall-clock below can't cover
341
+ // it (no container to remove yet), so a tarpit registry behind a
342
+ // caller-chosen `image:` would otherwise hang here unbounded. A stall here
343
+ // widens no swap window: ctx/out are already sealed in the back room.
344
+ await withTimeout(ensureImage(cfg.ode, req.image), cfg.timeoutMs, "spawn image pull timed out");
345
+
346
+ const created = await cfg.ode.createContainer(`realm-spawn-${boxId}`, spec);
347
+ id = created.Id;
348
+
349
+ // Wall-clock cap on the whole run. On expiry we force-remove the box, which
350
+ // unblocks the log relay + wait below (their docker conns drop), so a
351
+ // hung/forking build can't pin a slot forever.
352
+ timer = setTimeout(() => {
353
+ timedOut = true;
354
+ cfg.ode.removeContainer(id!, { force: true }).catch(() => {});
355
+ }, cfg.timeoutMs);
356
+
357
+ await cfg.ode.startContainer(id);
358
+ await relayLogs(cfg.ode, id, onLog);
359
+ const result = await cfg.ode.waitContainer(id);
360
+ if (timedOut) return { exit: -1, error: "spawn build timed out" };
361
+ const exit = Number(result.StatusCode);
362
+
363
+ // Success: move the box's output OUT of the back room into the app's
364
+ // artifacts tree under the broker-minted key, then hand the key back. The
365
+ // box is already torn down (waitContainer resolved on its exit), so no live
366
+ // writer races this rename. On every failure path nothing reaches artifacts
367
+ // at all — the finally wipes the whole boxDir, `out` included.
368
+ if (out && outputKey) await Deno.rename(out, join(cfg.artifactsRoot, outputKey));
369
+ return { exit, outputKey, error: null };
370
+ } catch (err) {
371
+ if (timedOut) return { exit: -1, error: "spawn build timed out" };
372
+ throw err;
373
+ } finally {
374
+ clearTimeout(timer);
375
+ // Disposable teardown: remove the box (it holds the binds), THEN wipe the
376
+ // whole back room. boxDir always holds ctx; it holds out too unless the
377
+ // success rename already moved it into artifacts. Nothing the caller can
378
+ // reach is touched. The box leaves untrusted-net with it; the DMZ persists.
379
+ if (id) await cfg.ode.removeContainer(id, { force: true }).catch(() => {});
380
+ await Deno.remove(boxDir, { recursive: true }).catch(() => {});
381
+ }
382
+ }
383
+
384
+ // EXDEV detection. Deno doesn't expose a `CrossDevice` error class in this
385
+ // version, so match the OS error a cross-device rename(2) surfaces ("Invalid
386
+ // cross-device link" / raw os error 18) by message. Used to fail the seal loud
387
+ // rather than silently copy (a copy would reintroduce the TOCTOU).
388
+ function isCrossDevice(err: unknown): boolean {
389
+ return err instanceof Error && /cross-device|os error 18/i.test(err.message);
390
+ }
391
+
392
+ async function ensureImage(odeApi: SpawnOde, image: string): Promise<void> {
393
+ try {
394
+ await odeApi.inspectImage(image);
395
+ return; // already present
396
+ } catch {
397
+ // Not present — pull it.
398
+ }
399
+ await odeApi.pullImage(image);
400
+ }
401
+
402
+ // Follow the box's logs and relay each line back over the socket. Uses
403
+ // timestamps:true to match the shared frame demux (which strips the
404
+ // leading RFC3339); we keep only message + stream. Returns when the
405
+ // stream ends — i.e. the container exited.
406
+ async function relayLogs(
407
+ odeApi: SpawnOde,
408
+ id: string,
409
+ onLog: (line: SpawnLogLine) => Promise<void>,
410
+ ): Promise<void> {
411
+ let conn: Deno.Conn;
412
+ try {
413
+ ({ conn } = await odeApi.followContainerLogs(id, {
414
+ stdout: true,
415
+ stderr: true,
416
+ timestamps: true,
417
+ }));
418
+ } catch {
419
+ return; // can't follow — fall through to waitContainer
420
+ }
421
+ try {
422
+ for await (const frame of streamDockerLogFrames(dechunkedReader(conn))) {
423
+ await onLog({ log: frame.message, stream: frame.stream });
424
+ }
425
+ } catch {
426
+ // Stream ended or errored — proceed to wait for the exit code.
427
+ } finally {
428
+ try {
429
+ conn.close();
430
+ } catch {}
431
+ }
432
+ }
433
+
434
+ async function writeLine(
435
+ writer: WritableStreamDefaultWriter<Uint8Array>,
436
+ obj: SpawnLogLine | SpawnTerminal,
437
+ ): Promise<void> {
438
+ await writer.write(encoder.encode(JSON.stringify(obj) + "\n"));
439
+ }
440
+
441
+ // Idempotent create of the standing DMZ bridge. "already exists" is the
442
+ // expected steady state.
443
+ async function ensureUntrustedNet(): Promise<void> {
444
+ try {
445
+ await ode.createNetwork({ Name: UNTRUSTED_NET });
446
+ } catch (err) {
447
+ if (!(err instanceof Error && err.message.toLowerCase().includes("already exists"))) {
448
+ throw err;
449
+ }
450
+ }
451
+ }
452
+
453
+ async function gcStaleBoxes(): Promise<void> {
454
+ let boxes: Awaited<ReturnType<typeof ode.listContainers>>;
455
+ try {
456
+ boxes = await ode.listContainers({
457
+ all: true,
458
+ filters: { label: [`${LABELS.KIND}=${KINDS.SPAWN_BOX}`] },
459
+ });
460
+ } catch (err) {
461
+ console.warn(`spawn boot GC: list failed (${(err as Error).message})`);
462
+ return;
463
+ }
464
+ for (const c of boxes) {
465
+ if (!c.Id) continue;
466
+ await ode.removeContainer(c.Id, { force: true }).catch(() => {});
467
+ }
468
+ }
469
+
470
+ // Race `p` against a wall-clock budget. The loser keeps running in the
471
+ // background (the ode calls hold their own conns); callers that need the
472
+ // abandoned work torn down do so separately (e.g. force-removing the box).
473
+ async function withTimeout<T>(p: Promise<T>, ms: number, message: string): Promise<T> {
474
+ let timer: ReturnType<typeof setTimeout> | undefined;
475
+ try {
476
+ return await Promise.race([
477
+ p,
478
+ new Promise<never>((_, reject) => {
479
+ timer = setTimeout(() => reject(new Error(message)), ms);
480
+ }),
481
+ ]);
482
+ } finally {
483
+ clearTimeout(timer);
484
+ }
485
+ }
486
+
487
+ function envInt(name: string, fallback: number): number {
488
+ const raw = Deno.env.get(name);
489
+ if (!raw) return fallback;
490
+ const n = Number(raw);
491
+ return Number.isFinite(n) && n > 0 ? n : fallback;
492
+ }
@@ -0,0 +1,70 @@
1
+ // The `spawn.sock` wire contract. Newline-delimited JSON over a unix
2
+ // conn, mirroring `logs.sock`: a single request line in, a stream of log
3
+ // lines + one terminal line out.
4
+ //
5
+ // The request carries only the *payload* (image / argv / env) plus a
6
+ // context key and an optional output flag. It carries NO envelope config
7
+ // — the broker constructs the hardened spec itself (see spec.ts). The
8
+ // broker's read buffer caps the raw bytes; these schemas cap the parsed
9
+ // shape.
10
+
11
+ import { z } from "zod";
12
+
13
+ const MAX_IMAGE = 1024;
14
+ const MAX_KEY = 255;
15
+ const MAX_ARGV = 1024;
16
+ const MAX_ARG = 16384;
17
+ const MAX_ENV_KEYS = 512;
18
+
19
+ /**
20
+ * One build request. `image`/`argv`/`env` are the payload to run inside
21
+ * the fixed envelope, never envelope config. `context` is a relative,
22
+ * single-segment key resolved under the worker scratch root (see
23
+ * context-key.ts). `output` asks the broker for a writable per-box output
24
+ * dir, bind-mounted RW at OUTPUT_MOUNT: a generic "give me a place to write
25
+ * artifacts and tell me where it is" flag. The broker stays
26
+ * image-agnostic — it never parses `argv`, so what the box writes to that
27
+ * dir (a kaniko `--tar-path` image, say) and what the caller does with it
28
+ * are entirely the caller's concern. The broker only hands back the dir's
29
+ * basename via `outputKey`; it never reads a box-authored file.
30
+ */
31
+ export const spawnRequestSchema = z
32
+ .object({
33
+ image: z.string().min(1).max(MAX_IMAGE),
34
+ context: z.string().min(1).max(MAX_KEY),
35
+ argv: z.array(z.string().max(MAX_ARG)).max(MAX_ARGV).optional(),
36
+ env: z
37
+ .record(z.string().max(MAX_KEY), z.string().max(MAX_ARG))
38
+ .refine((e) => Object.keys(e).length <= MAX_ENV_KEYS, {
39
+ message: `too many env keys (max ${MAX_ENV_KEYS})`,
40
+ })
41
+ .optional(),
42
+ output: z.literal(true).optional(),
43
+ })
44
+ .strict();
45
+
46
+ export type SpawnRequest = z.infer<typeof spawnRequestSchema>;
47
+
48
+ /** One line of the box's output, relayed back over the socket. */
49
+ export const spawnLogLineSchema = z.object({
50
+ log: z.string(),
51
+ stream: z.enum(["stdout", "stderr"]),
52
+ });
53
+
54
+ export type SpawnLogLine = z.infer<typeof spawnLogLineSchema>;
55
+
56
+ /**
57
+ * The final line before the broker closes the conn (close == teardown
58
+ * complete). `outputKey` is the basename of the broker-owned output dir
59
+ * (`output-<boxId>`) when `output` was requested — the caller reads its
60
+ * artifacts back from `${artifactsRoot}/${outputKey}`. It is broker-authored
61
+ * (a basename the broker minted), never a path or value the box wrote.
62
+ * `error` is set on any failure.
63
+ */
64
+ export const spawnTerminalSchema = z.object({
65
+ exit: z.number().int(),
66
+ outputKey: z.string().optional(),
67
+ error: z.string().nullable().optional(),
68
+ });
69
+
70
+ export type SpawnTerminal = z.infer<typeof spawnTerminalSchema>;