@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,122 @@
1
+ // The security crux. `buildSpawnSpec` *constructs* the `ode.createContainer`
2
+ // body from the tiny allowlist of caller params (`image`, `argv`, `env`,
3
+ // the resolved context path). The caller supplies no `HostConfig` — the
4
+ // broker imposes the entire hardened envelope here.
5
+ //
6
+ // This is to `spawn.sock` what `checkAppGrants` + `HARDENING` + the SDK
7
+ // authoring surface are to the compose path: every security-relevant
8
+ // field is forced, and everything in the `docker.sock` deny-list
9
+ // (privileged, host namespaces, arbitrary mounts/networks, the socket
10
+ // itself) is simply not expressible — it is never read from the caller.
11
+
12
+ import { KINDS, LABELS } from "@local/docker";
13
+ import { HARDENING } from "../compose/default-hardening.ts";
14
+ import { UNTRUSTED_NET } from "../../../worker-protocol/networks.ts";
15
+ import type { SpawnRequest } from "./protocol.ts";
16
+
17
+ /** Where the validated build context is bind-mounted, read-only, in the box. */
18
+ export const CONTEXT_MOUNT = "/workspace";
19
+
20
+ /**
21
+ * Where the broker's per-box output dir is bind-mounted, read-write, in the
22
+ * box. Mounted ONLY when `output` is requested; the box writes its build
23
+ * artifacts (e.g. a kaniko `--tar-path` image) here and the caller reads them
24
+ * back off the per-app artifacts dir. The dir is broker-owned and
25
+ * broker-created (the caller never names it), so this RW bind is as
26
+ * trusted as the RO context bind. The broker itself never reads anything
27
+ * out of this dir — it only reports the dir's basename via `outputKey`.
28
+ */
29
+ export const OUTPUT_MOUNT = "/output";
30
+
31
+ export interface SpawnLimits {
32
+ /** Hard memory ceiling, bytes. */
33
+ memoryBytes: number;
34
+ /** CPU quota in nano-CPUs (1e9 = one home). */
35
+ nanoCpus: number;
36
+ }
37
+
38
+ export function buildSpawnSpec(
39
+ req: SpawnRequest,
40
+ opts: {
41
+ scratchHostPath: string;
42
+ outputHostPath?: string;
43
+ limits: SpawnLimits;
44
+ spawnedBy: string;
45
+ },
46
+ ): Record<string, unknown> {
47
+ const env = req.env ? Object.entries(req.env).map(([k, v]) => `${k}=${v}`) : undefined;
48
+ return {
49
+ Image: req.image,
50
+ ...(req.argv ? { Cmd: req.argv } : {}),
51
+ ...(env ? { Env: env } : {}),
52
+ Labels: {
53
+ // realm.kind=spawn-box drives the reconcile / teardown / GC loops (boot
54
+ // gcStaleBoxes reaps orphans by it); it's broker-owned, not a wire
55
+ // service. realm.spawned-by records which granted app drove this build,
56
+ // as provenance for `docker ps` and forensics.
57
+ //
58
+ // Deliberately NOT realm.managed: the logs server tails every managed
59
+ // container, and a build's (attacker-controlled) stdout must not land in
60
+ // the logger — the build app (e.g. Forge) collects it over the spawn
61
+ // socket relay. spawned-by is its OWN key, distinct from realm.app
62
+ // (which would re-trigger logger attribution), so it's pure metadata:
63
+ // nothing taps or buckets by it.
64
+ [LABELS.KIND]: KINDS.SPAWN_BOX,
65
+ [LABELS.SPAWNED_BY]: opts.spawnedBy,
66
+ },
67
+ HostConfig: {
68
+ // Full HARDENING, plus the file-extraction caps a builder needs. A
69
+ // builder unpacks arbitrary base rootfs as uid 0, and real bases ship
70
+ // files owned by other uids (node:22-alpine's /home/node): chmod/utimes
71
+ // on a non-owned file needs FOWNER, writing through restrictive mode
72
+ // bits needs DAC_OVERRIDE, security.capability xattrs (setcap'd
73
+ // binaries in the tar) need SETFCAP, and FSETID keeps setgid bits
74
+ // intact — without it, chmod of a foreign-group file (Debian's 2775
75
+ // root:staff /usr/local, sgid crontab/wall) silently strips the bit
76
+ // and the built image drifts from its base. All four act only on file
77
+ // metadata inside the box; the escape-relevant fences — default
78
+ // seccomp (blocks unshare(CLONE_NEWUSER)) and no-new-privileges —
79
+ // stay exactly as HARDENING sets them.
80
+ CapDrop: [...HARDENING.cap_drop],
81
+ // DAC_OVERRIDE arrives via HARDENING.cap_add; listing it here too would
82
+ // just duplicate the entry.
83
+ CapAdd: [...HARDENING.cap_add, "FOWNER", "SETFCAP", "FSETID"],
84
+ SecurityOpt: [...HARDENING.security_opt],
85
+ PidsLimit: HARDENING.pids_limit,
86
+ Ulimits: [
87
+ { Name: "nofile", Soft: HARDENING.ulimits.nofile, Hard: HARDENING.ulimits.nofile },
88
+ { Name: "nproc", Soft: HARDENING.ulimits.nproc, Hard: HARDENING.ulimits.nproc },
89
+ ],
90
+ LogConfig: {
91
+ Type: HARDENING.logging.driver,
92
+ Config: { ...HARDENING.logging.options },
93
+ },
94
+ // One-shot: a failed build must NOT loop. The single documented
95
+ // deviation from HARDENING.restart (on-failure) — lifecycle, not
96
+ // security; the broker tears the box down on exit.
97
+ RestartPolicy: { Name: "no" },
98
+ // Resource caps beyond HARDENING — bound a single box.
99
+ Memory: opts.limits.memoryBytes,
100
+ NanoCpus: opts.limits.nanoCpus,
101
+ // Every bind is broker-constructed. The context dir is read-only —
102
+ // RO is load-bearing, the scratch root is shared host fs, so RW
103
+ // would let a build tamper with the caller's clone or plant data
104
+ // another build reads. The ONLY RW bind is the broker's own per-box
105
+ // output dir, and only when `output` is requested: the box writes its
106
+ // build artifacts there for the caller to read back off the per-app
107
+ // artifacts dir. The caller never names that dir (same trust property as
108
+ // the context dir).
109
+ Binds: [
110
+ `${opts.scratchHostPath}:${CONTEXT_MOUNT}:ro`,
111
+ ...(opts.outputHostPath ? [`${opts.outputHostPath}:${OUTPUT_MOUNT}:rw`] : []),
112
+ ],
113
+ },
114
+ // Attach at create time to untrusted-net ONLY — never the default
115
+ // bridge. Docker's DOCKER-ISOLATION-STAGE-2 DROPs FORWARD between
116
+ // user bridges, so the box physically cannot route to management-net
117
+ // or a peer realm_<app>. Segmentation by topology, not by caps.
118
+ NetworkingConfig: {
119
+ EndpointsConfig: { [UNTRUSTED_NET]: {} },
120
+ },
121
+ };
122
+ }
@@ -0,0 +1,161 @@
1
+ // One entry per real filesystem mounted on the host.
2
+ //
3
+ // The worker sees the host's root at /host (a read-only recursive bind — see
4
+ // install-script.ts). So the mount table it reads through its own
5
+ // /proc/self/mounts lists every host filesystem twice over: once at its real
6
+ // path, once under /host. The /host copies are the ones we can actually
7
+ // statfs, and stripping the prefix back off is what makes `path` read as the
8
+ // operator's real path rather than a container detail.
9
+
10
+ // `statfsSync` is a deliberate exception to the Deno-native rule in CLAUDE.md:
11
+ // there is no Deno equivalent (`Deno.statfs` does not exist), so the node
12
+ // compat path is the only way to read a filesystem's size. Needs
13
+ // --allow-sys=statfs plus --allow-read on the path.
14
+ import { statfsSync } from "node:fs";
15
+ import type { DiskSample } from "../../stats-sample.ts";
16
+
17
+ const HOST_ROOT = "/host";
18
+
19
+ /**
20
+ * Our own mount table, reached through the host bind rather than at
21
+ * `/proc/self/mounts` directly. Deno refuses ANY path under `/proc` unless the
22
+ * process holds blanket `--allow-read` — `--allow-read=/proc` is not enough
23
+ * (`/proc/self/mem` would hand over process memory, so the whole tree is
24
+ * all-or-nothing). Reading the identical file via `/host/proc/...` is covered
25
+ * by the scoped `--allow-read=/host` the worker already needs for statfs, so
26
+ * the stats broker costs no permission widening.
27
+ *
28
+ * Same bytes either way: the host's procfs resolves `self` by the reading
29
+ * task's host-side pid, and `mounts` is a property of that task's mount
30
+ * namespace — ours.
31
+ */
32
+ const MOUNTS = `${HOST_ROOT}/proc/self/mounts`;
33
+
34
+ /**
35
+ * Filesystems the kernel synthesizes rather than stores bytes on. Reporting a
36
+ * `total`/`available` for these is meaningless — tmpfs reports RAM, cgroup and
37
+ * proc report zero — so they're dropped by fstype rather than by path. The
38
+ * host's /proc, /sys and /dev all come along with the recursive bind, so
39
+ * without this the "disks" list would be mostly kernel interfaces.
40
+ */
41
+ const PSEUDO_FSTYPES = new Set([
42
+ "autofs",
43
+ "binfmt_misc",
44
+ "bpf",
45
+ "configfs",
46
+ "debugfs",
47
+ "devpts",
48
+ "devtmpfs",
49
+ "efivarfs",
50
+ "fusectl",
51
+ "hugetlbfs",
52
+ "mqueue",
53
+ "nsfs",
54
+ "overlay",
55
+ "proc",
56
+ "pstore",
57
+ "ramfs",
58
+ "rpc_pipefs",
59
+ "securityfs",
60
+ "selinuxfs",
61
+ "squashfs",
62
+ "sysfs",
63
+ "tmpfs",
64
+ "tracefs",
65
+ ]);
66
+
67
+ const isPseudo = (fstype: string): boolean =>
68
+ PSEUDO_FSTYPES.has(fstype) || fstype.startsWith("cgroup") || fstype.startsWith("fuse.lxcfs");
69
+
70
+ /**
71
+ * Mounts under `/run` are volatile runtime state, never a data disk (FHS), so
72
+ * the subtree is dropped by path — the one spot the fstype rule can't cover.
73
+ * Most `/run` submounts are already pseudo (tmpfs, nsfs), but a VM host exposes
74
+ * binary-emulation shares here that aren't: OrbStack / Docker Desktop's Rosetta
75
+ * mount (`/run/rosetta.orig`) has a non-pseudo fstype and its own anonymous
76
+ * st_dev, so neither the type filter nor the st_dev dedupe folds it, and statfs
77
+ * on it reports the whole VM host filesystem — a phantom multi-TiB "drive".
78
+ */
79
+ const isUnderRun = (path: string): boolean => path.startsWith(`${HOST_ROOT}/run/`);
80
+
81
+ /**
82
+ * `overlay` is in the pseudo list because every running container's rootfs is
83
+ * an overlay mount with its own anonymous st_dev — the dedupe below can't
84
+ * collapse them, so without the drop a busy host reports one phantom disk per
85
+ * container. But the host's own root is a real filesystem whatever its fstype,
86
+ * and on overlay-root systems (the Docker Desktop / OrbStack VM, appliance
87
+ * images) that root IS an overlay. Judge the root by position, not fstype: a
88
+ * container rootfs is never mounted at HOST_ROOT exactly.
89
+ */
90
+ const isHostRoot = (path: string): boolean => path === HOST_ROOT;
91
+
92
+ export async function readDisks(): Promise<DiskSample[]> {
93
+ let raw: string;
94
+ try {
95
+ raw = await Deno.readTextFile(MOUNTS);
96
+ } catch {
97
+ // No procfs under /host: a macOS dev host, or the host bind isn't wired.
98
+ // Report no disks rather than failing the whole sample.
99
+ return [];
100
+ }
101
+
102
+ const disks: DiskSample[] = [];
103
+ // Dedupe by filesystem identity, not by path. statfs returns no fsid, so
104
+ // st_dev is the only key available. This is what collapses docker's own
105
+ // /etc/resolv.conf, /etc/hostname and /etc/hosts file-binds: each is a
106
+ // separate mount entry whose statfs reports its *containing* filesystem, so
107
+ // without this they'd each surface as a phantom disk the size of the root
108
+ // volume. Bind-mounted subtrees of one disk collapse the same way.
109
+ const seen = new Set<number>();
110
+
111
+ for (const line of raw.split("\n")) {
112
+ const mount = parseMountLine(line);
113
+ if (!mount) continue;
114
+ if (!isHostRoot(mount.path) && !mount.path.startsWith(HOST_ROOT + "/")) continue;
115
+ if (isPseudo(mount.fstype) && !isHostRoot(mount.path)) continue;
116
+ if (isUnderRun(mount.path)) continue;
117
+
118
+ try {
119
+ const dev = Deno.statSync(mount.path).dev;
120
+ if (seen.has(dev)) continue;
121
+ seen.add(dev);
122
+
123
+ const fs = statfsSync(mount.path);
124
+ const total = fs.blocks * fs.bsize;
125
+ // A zero-capacity filesystem carries no signal — drop it rather than
126
+ // render an empty "0 B / 0 B" bar (some VM shim mounts report this).
127
+ if (total <= 0) continue;
128
+ disks.push({
129
+ path: stripHostPrefix(mount.path),
130
+ total,
131
+ available: fs.bavail * fs.bsize,
132
+ });
133
+ } catch {
134
+ // Raced an unmount, or the mount point isn't readable. Skip it.
135
+ }
136
+ }
137
+
138
+ return disks;
139
+ }
140
+
141
+ interface Mount {
142
+ path: string;
143
+ fstype: string;
144
+ }
145
+
146
+ // /proc/self/mounts: `device mountpoint fstype options dump pass`, with
147
+ // whitespace inside a field escaped octally.
148
+ function parseMountLine(line: string): Mount | null {
149
+ const fields = line.split(" ");
150
+ if (fields.length < 3) return null;
151
+ const path = unescapeOctal(fields[1]!);
152
+ const fstype = fields[2]!;
153
+ if (!path || !fstype) return null;
154
+ return { path, fstype };
155
+ }
156
+
157
+ const unescapeOctal = (s: string): string =>
158
+ s.replace(/\\([0-7]{3})/g, (_, oct: string) => String.fromCharCode(parseInt(oct, 8)));
159
+
160
+ const stripHostPrefix = (path: string): string =>
161
+ path === HOST_ROOT ? "/" : path.slice(HOST_ROOT.length);
@@ -0,0 +1,322 @@
1
+ // Worker-side stats server. Binds a unix socket; consumers connect, send a
2
+ // single line `{"sample":true}\n` (or just `\n`), receive one JSON
3
+ // `StatsSample`, and the connection closes.
4
+ //
5
+ // Request/response, not a stream — the deliberate divergence from
6
+ // logs-server. A log line is an event you can't predict, so the worker must
7
+ // push. A stats sample doesn't exist until someone asks for it, so the worker
8
+ // holds no timer, no history, and no cache: it reads the host on demand and
9
+ // answers. Cadence is entirely the consumer's choice.
10
+ //
11
+ // Everything here reads; nothing mutates. The socket has no verb that changes
12
+ // anything.
13
+
14
+ import { dirname } from "@std/path";
15
+ import { LABELS, ode } from "@local/docker";
16
+ import { z } from "zod";
17
+ import { REALM_STATS_SOCKET } from "../../../worker-protocol/stats-socket-path.ts";
18
+ import {
19
+ type ContainerSample,
20
+ type DiskSample,
21
+ type HostSample,
22
+ OTHER_APP,
23
+ type StatsSample,
24
+ } from "../../stats-sample.ts";
25
+ import { readDisks } from "./disks.ts";
26
+
27
+ export { REALM_STATS_SOCKET };
28
+ export type { StatsSample };
29
+
30
+ // Request line a consumer sends before the sample: {"sample":true}. The sample
31
+ // is all-or-nothing, so the value is ignored; the field leaves room for a
32
+ // scoped request.
33
+ const sampleRequestSchema = z.object({ sample: z.boolean().optional() });
34
+
35
+ // A well-behaved consumer sends its line immediately. This bounds one that
36
+ // connects and then never does, so its read can't park the connection (and its
37
+ // fd) open forever. Only host.stats-granted apps reach the socket, so this
38
+ // guards against a buggy grantee, not an external attacker.
39
+ const REQUEST_READ_TIMEOUT_MS = 5_000;
40
+
41
+ interface StartOpts {
42
+ /** Defaults to {@link REALM_STATS_SOCKET}. Override for tests. */
43
+ socketPath?: string;
44
+ }
45
+
46
+ interface ServerHandle {
47
+ /** Stop the server: close the listener and remove the socket file. */
48
+ stop(): Promise<void>;
49
+ }
50
+
51
+ const encoder = new TextEncoder();
52
+ const decoder = new TextDecoder();
53
+
54
+ export async function startStatsServer(opts: StartOpts = {}): Promise<ServerHandle> {
55
+ const socketPath = opts.socketPath ?? REALM_STATS_SOCKET;
56
+ // The socket lives alone in its own directory (apps mount the dir, not the
57
+ // socket file — a file bind-mount of a unix socket doesn't wire across
58
+ // containers on macOS Docker). Ensure the dir exists before binding.
59
+ await Deno.mkdir(dirname(socketPath), { recursive: true });
60
+ await Deno.remove(socketPath).catch(() => {});
61
+
62
+ const listener = Deno.listen({ transport: "unix", path: socketPath });
63
+
64
+ void acceptLoop(listener);
65
+
66
+ return {
67
+ async stop() {
68
+ try {
69
+ listener.close();
70
+ } catch {}
71
+ await Deno.remove(socketPath).catch(() => {});
72
+ },
73
+ };
74
+ }
75
+
76
+ async function acceptLoop(listener: Deno.Listener): Promise<void> {
77
+ try {
78
+ for await (const conn of listener) {
79
+ void serve(conn as Deno.UnixConn);
80
+ }
81
+ } catch {
82
+ // Listener was closed via stop().
83
+ }
84
+ }
85
+
86
+ async function serve(conn: Deno.UnixConn): Promise<void> {
87
+ try {
88
+ await readRequestLine(conn);
89
+ const sample = await collectSample();
90
+ await conn.write(encoder.encode(JSON.stringify(sample) + "\n"));
91
+ } catch (err) {
92
+ console.error("stats-server sample error:", err);
93
+ } finally {
94
+ try {
95
+ conn.close();
96
+ } catch {}
97
+ }
98
+ }
99
+
100
+ export async function collectSample(): Promise<StatsSample> {
101
+ // `all: true` and no label filter: every container on the host, running and
102
+ // exited. Anything without a `realm.app` label lands under OTHER_APP — which
103
+ // is foreign containers AND Realm's own infrastructure (the worker, and Home
104
+ // where it's a container), since the installer starts those out-of-band and
105
+ // only the reconciler labels what it creates.
106
+ const [info, containers] = await Promise.all([ode.info(), ode.listContainers({ all: true })]);
107
+
108
+ const samples = await Promise.all(
109
+ containers.map((c) =>
110
+ sampleContainer(c.Id ?? "", c.Labels ?? {}, c.State ?? "", pickName(c.Names ?? [])),
111
+ ),
112
+ );
113
+
114
+ return {
115
+ ts: new Date().toISOString(),
116
+ host: await collectHost(info.KernelVersion ?? ""),
117
+ docker: {
118
+ version: info.ServerVersion ?? "",
119
+ // Counted from the list rather than read off /info: one source, so the
120
+ // counts can't disagree with the `containers` array beside them.
121
+ containersRunning: containers.filter((c) => c.State === "running").length,
122
+ containersTotal: containers.length,
123
+ },
124
+ containers: samples.filter((s): s is ContainerSample => s !== null),
125
+ };
126
+ }
127
+
128
+ async function collectHost(kernelVersion: string): Promise<HostSample> {
129
+ const mem = Deno.systemMemoryInfo();
130
+ const load = Deno.loadavg();
131
+ return {
132
+ kernelVersion,
133
+ cores: navigator.hardwareConcurrency,
134
+ loadAvg: [load[0] ?? 0, load[1] ?? 0, load[2] ?? 0],
135
+ memoryTotal: mem.total,
136
+ memoryAvailable: mem.available,
137
+ swapTotal: mem.swapTotal,
138
+ swapUsed: mem.swapTotal - mem.swapFree,
139
+ uptime: Deno.osUptime(),
140
+ disks: await readDisks(),
141
+ };
142
+ }
143
+
144
+ // Docker reports names as a list with a leading slash ("/nginx"), because a
145
+ // container can carry several via legacy links. The first is the one `docker
146
+ // ps` shows, which is the one an operator would recognize.
147
+ function pickName(names: string[]): string {
148
+ const first = names[0] ?? "";
149
+ return first.startsWith("/") ? first.slice(1) : first;
150
+ }
151
+
152
+ async function sampleContainer(
153
+ id: string,
154
+ labels: Record<string, string>,
155
+ state: string,
156
+ name: string,
157
+ ): Promise<ContainerSample | null> {
158
+ if (!id) return null;
159
+ try {
160
+ const inspect = await ode.inspectContainer(id);
161
+ const base = {
162
+ app: labels[LABELS.APP] || OTHER_APP,
163
+ service: labels[LABELS.SERVICE] ?? "",
164
+ name,
165
+ containerId: id,
166
+ state,
167
+ health: inspect.State?.Health?.Status ?? undefined,
168
+ startedAt: inspect.State?.StartedAt ?? "",
169
+ restartCount: inspect.RestartCount ?? 0,
170
+ };
171
+
172
+ // Docker answers /stats for an exited container with HTTP 200 and
173
+ // all-zero counters, which is indistinguishable from a counter reset on a
174
+ // live container. Gate on state so a consumer diffing counters can't read
175
+ // an exit as "traffic went to zero" — skip the call entirely and report
176
+ // the zeros explicitly.
177
+ if (state !== "running") {
178
+ return {
179
+ ...base,
180
+ cpuUsage: 0,
181
+ netRx: 0,
182
+ netTx: 0,
183
+ blockRead: 0,
184
+ blockWrite: 0,
185
+ memoryUsage: 0,
186
+ memoryLimit: 0,
187
+ pids: 0,
188
+ };
189
+ }
190
+
191
+ const stats = await ode.containerStats(id, { stream: false, oneShot: true });
192
+ return { ...base, ...readCounters(stats) };
193
+ } catch {
194
+ // Container was removed between the list and the inspect/stats call.
195
+ return null;
196
+ }
197
+ }
198
+
199
+ type Counters = Pick<
200
+ ContainerSample,
201
+ | "cpuUsage"
202
+ | "netRx"
203
+ | "netTx"
204
+ | "blockRead"
205
+ | "blockWrite"
206
+ | "memoryUsage"
207
+ | "memoryLimit"
208
+ | "pids"
209
+ >;
210
+
211
+ // Docker's counters are uint64, which the generated schema parses as bigint.
212
+ // The wire is JSON, which has no bigint, so everything narrows to number here.
213
+ // Lossless below 2^53: at 100% CPU a `cpuUsage` nanosecond counter needs ~104
214
+ // days to reach that, and a container that runs that long without a restart
215
+ // resets nothing — but neither would docker's own uint64 survive a diff across
216
+ // a restart. Bytes counters are further away still.
217
+ const num = (v: bigint | number | null | undefined): number => Number(v ?? 0);
218
+
219
+ function readCounters(stats: ode.ContainerStats): Counters {
220
+ const networks = networksSchema.safeParse(stats.networks);
221
+ let netRx = 0;
222
+ let netTx = 0;
223
+ if (networks.success && networks.data) {
224
+ // Summed across interfaces: an app's container can sit on several networks
225
+ // (its own, management, an untrusted DMZ) and the consumer wants one
226
+ // number for the container.
227
+ for (const iface of Object.values(networks.data)) {
228
+ netRx += num(iface.rx_bytes);
229
+ netTx += num(iface.tx_bytes);
230
+ }
231
+ }
232
+
233
+ let blockRead = 0;
234
+ let blockWrite = 0;
235
+ for (const entry of stats.blkio_stats?.io_service_bytes_recursive ?? []) {
236
+ // `op` casing varies across docker versions and cgroup drivers ("Read" on
237
+ // cgroup v1, "read" on v2).
238
+ const op = entry?.op?.toLowerCase();
239
+ if (op === "read") blockRead += num(entry?.value);
240
+ if (op === "write") blockWrite += num(entry?.value);
241
+ }
242
+
243
+ // `usage` counts the page cache, so it reads far above what the process
244
+ // actually holds. Docker's own CLI subtracts inactive file cache before
245
+ // showing MEM USAGE; do the same so the number matches what an operator sees
246
+ // in `docker stats`.
247
+ //
248
+ // Order matters and mirrors Docker's algorithm exactly: prefer
249
+ // `total_inactive_file` (present on cgroup v1) and fall back to
250
+ // `inactive_file` (cgroup v2, which has no `total_` prefix). On a v1 host a
251
+ // container with child cgroups reports both keys with DIFFERENT values, so
252
+ // reading `inactive_file` first would subtract the wrong one and drift from
253
+ // `docker stats`.
254
+ const memStats = stats.memory_stats?.stats;
255
+ const inactiveFile = num(memStats?.total_inactive_file ?? memStats?.inactive_file);
256
+ const memoryUsage = Math.max(0, num(stats.memory_stats?.usage) - inactiveFile);
257
+
258
+ return {
259
+ cpuUsage: num(stats.cpu_stats?.cpu_usage?.total_usage),
260
+ netRx,
261
+ netTx,
262
+ blockRead,
263
+ blockWrite,
264
+ memoryUsage,
265
+ memoryLimit: num(stats.memory_stats?.limit),
266
+ pids: num(stats.pids_stats?.current),
267
+ };
268
+ }
269
+
270
+ // `networks` is typed `unknown` in the generated schema (docker keys it by
271
+ // interface name, so there's no fixed shape to generate). Parse the two fields
272
+ // we sum.
273
+ const networksSchema = z
274
+ .record(
275
+ z.string(),
276
+ z.object({
277
+ rx_bytes: z.coerce.bigint().nullish(),
278
+ tx_bytes: z.coerce.bigint().nullish(),
279
+ }),
280
+ )
281
+ .nullish();
282
+
283
+ async function readRequestLine(conn: Deno.UnixConn): Promise<void> {
284
+ // Closing the conn on deadline is what unblocks a parked read: `conn.read`
285
+ // rejects once the socket is gone, so the loop can't wait past the timeout.
286
+ let timedOut = false;
287
+ const timer = setTimeout(() => {
288
+ timedOut = true;
289
+ try {
290
+ conn.close();
291
+ } catch {}
292
+ }, REQUEST_READ_TIMEOUT_MS);
293
+ try {
294
+ const buf = new Uint8Array(1);
295
+ const collected: number[] = [];
296
+ while (collected.length < 4096) {
297
+ const n = await conn.read(buf);
298
+ if (n === null) break;
299
+ if (n === 0) continue;
300
+ if (buf[0] === 0x0a) break;
301
+ collected.push(buf[0]!);
302
+ }
303
+ const raw = decoder.decode(new Uint8Array(collected)).trim();
304
+ if (!raw) return;
305
+ try {
306
+ sampleRequestSchema.parse(JSON.parse(raw));
307
+ } catch {
308
+ // The request carries nothing this server acts on; a malformed line
309
+ // still gets a sample rather than an error.
310
+ }
311
+ } catch (err) {
312
+ // A read rejecting because our own timer closed the conn is the timeout
313
+ // firing, not a real fault — surface it as one clear message rather than a
314
+ // bare BadResource. Any other read error propagates as-is.
315
+ if (timedOut) throw new Error("stats request read timed out");
316
+ throw err;
317
+ } finally {
318
+ clearTimeout(timer);
319
+ }
320
+ }
321
+
322
+ export type { DiskSample };
@@ -0,0 +1,71 @@
1
+ // Consumer-side helper for the worker's `home-proxy.sock` broker. A
2
+ // granted app mounts the per-app socket DIRECTORY and calls `callHome` to make
3
+ // an authenticated control-plane request over the socket inside it; the worker
4
+ // relays it across the mesh to Home. App containers are NOT on the mesh, so
5
+ // this is the only channel an app has to reach Home.
6
+ //
7
+ // `Deno` is referenced only inside the function body — never at module
8
+ // load — so this stays QuickJS-safe and can be re-exported from the
9
+ // top-level `@yourrealm/sdk` barrel without dragging Deno into eval.
10
+ // Mirrors `spawn-client`/`logs-client` framing: connect, write one request
11
+ // line, read one response line, return.
12
+
13
+ import { REALM_HOME_PROXY_CONTAINER_PATH } from "../../worker-protocol/home-proxy-paths.ts";
14
+ import {
15
+ homeProxyResponseSchema,
16
+ type HomeProxyMethod,
17
+ type HomeProxyResponse,
18
+ } from "../docker/home-proxy-server/protocol.ts";
19
+
20
+ export interface CallHomeOpts {
21
+ /** Defaults to {@link REALM_HOME_PROXY_CONTAINER_PATH}. Override for tests. */
22
+ socketPath?: string;
23
+ }
24
+
25
+ /**
26
+ * Make one control-plane call through the worker's proxy socket. `path` is
27
+ * a sub-path of Home's `/api-access` surface. Resolves
28
+ * with Home's `{ status, body }`; rejects if the broker closes without a
29
+ * response line.
30
+ */
31
+ export async function callHome(
32
+ method: HomeProxyMethod,
33
+ path: string,
34
+ body?: unknown,
35
+ opts: CallHomeOpts = {},
36
+ ): Promise<HomeProxyResponse> {
37
+ const socketPath = opts.socketPath ?? REALM_HOME_PROXY_CONTAINER_PATH;
38
+ const conn = await Deno.connect({ transport: "unix", path: socketPath });
39
+ // `TextEncoder`/`TextDecoder` are constructed INSIDE the function, never
40
+ // at module load: this module is re-exported from the top-level barrel,
41
+ // which loads under QuickJS during realm.ts eval, and QuickJS provides
42
+ // neither global. Per-instance decoder also lets a multi-byte UTF-8
43
+ // sequence straddling two reads survive the boundary.
44
+ const encoder = new TextEncoder();
45
+ const decoder = new TextDecoder("utf-8");
46
+ try {
47
+ // Omit `body` from the line entirely when undefined, so a no-body call
48
+ // writes exactly `{"method":…,"path":…}`.
49
+ const line = JSON.stringify(body === undefined ? { method, path } : { method, path, body });
50
+ await conn.write(encoder.encode(line + "\n"));
51
+
52
+ const buf = new Uint8Array(8192);
53
+ let remainder = "";
54
+ while (true) {
55
+ const n = await conn.read(buf);
56
+ if (n === null) break;
57
+ remainder += decoder.decode(buf.slice(0, n), { stream: true });
58
+ const nl = remainder.indexOf("\n");
59
+ if (nl !== -1) {
60
+ const raw = remainder.slice(0, nl).trim();
61
+ const parsed = homeProxyResponseSchema.parse(JSON.parse(raw));
62
+ return { status: parsed.status, body: parsed.body };
63
+ }
64
+ }
65
+ throw new Error("home proxy closed without a response line");
66
+ } finally {
67
+ try {
68
+ conn.close();
69
+ } catch {}
70
+ }
71
+ }