@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,792 @@
1
+ // host.* permission factories + per-kind Home-side mechanics.
2
+ //
3
+ // Each factory is built via `hostable(...)` (the local binding of
4
+ // `makeHostable(basePermission)`), which calls basePermission AND tags
5
+ // the kind's hooks (iterateUses / toValue / grantSchema) so the host
6
+ // registry derived in grants.ts can dispatch through them. The hooks
7
+ // are colocated with the factory: adding a host kind is one declaration.
8
+
9
+ import { z } from "zod";
10
+ import type { Network } from "../identifiers.ts";
11
+ import { basePermission } from "../factory.ts";
12
+ import { makeHostable } from "../host-registry.ts";
13
+ import { RealmCompileError, SiloRequiresError } from "../errors.ts";
14
+ import type { ResolveArgs } from "../resolve-context.ts";
15
+ import { REALM_LOGS_MOUNT } from "../worker-protocol/logs-socket-path.ts";
16
+ import { REALM_STATS_MOUNT } from "../worker-protocol/stats-socket-path.ts";
17
+ import { SPAWN_PLACEHOLDER, spawnMounts, type SpawnValue } from "../worker-protocol/spawn-paths.ts";
18
+ import { MANAGEMENT_NET, siloNetwork } from "../worker-protocol/networks.ts";
19
+ import { speculativeSilo } from "../speculative-silo.ts";
20
+
21
+ const hostable = makeHostable(basePermission);
22
+
23
+ /**
24
+ * Resolved shape of a `host.management()` grant. The network is fixed
25
+ * (the SDK-side constant); the rest this kind's `resolve` hook reads off the
26
+ * eval context — `home_state` identity plus the running worker. Empty strings
27
+ * stand in for "unconfigured" so authors can guard with
28
+ * `if (config.management.baseDomain)`.
29
+ */
30
+ export interface ManagementValue {
31
+ readonly network: Network;
32
+ readonly baseDomain: string;
33
+ readonly homeSlug: string;
34
+ readonly workerId: string;
35
+ // acme-dns credentials Home provisions from Realm DNS, or null when no
36
+ // DNS provider is configured (fresh install / BYO-domain offline mode).
37
+ // Injected at eval time, never stored in the app config.
38
+ readonly acmeDns: {
39
+ readonly serverUrl: string;
40
+ readonly username: string;
41
+ readonly apiKey: string;
42
+ // ACME directory URL. Empty = the proxy's default (Let's Encrypt prod).
43
+ // Set to a staging endpoint, or to a test CA (Pebble) in the e2e.
44
+ readonly ca: string;
45
+ // PEM root to trust for the ACME endpoint, for a non-publicly-trusted
46
+ // test CA (Pebble). Empty for real LE / staging (publicly trusted API).
47
+ readonly caRootPem: string;
48
+ } | null;
49
+ }
50
+
51
+ const MANAGEMENT_PLACEHOLDER: ManagementValue = {
52
+ network: MANAGEMENT_NET,
53
+ baseDomain: "",
54
+ homeSlug: "",
55
+ workerId: "",
56
+ acmeDns: null,
57
+ };
58
+
59
+ /**
60
+ * Roots an operator can never point a Silo's volume under. An operator-set
61
+ * path lands verbatim in a bind-mount source on the worker, so dangerous
62
+ * roots are veto'd at write time - a Silo backed by `/etc` or
63
+ * `/var/run/docker.sock` never reaches the DB. Lives here (not in
64
+ * security.ts) to avoid the cycle schema/host.ts <-> security.ts <->
65
+ * grants.ts; security.ts re-exports for Home UI consumers.
66
+ */
67
+ export const HOST_DIRECTORY_BLOCKLIST = [
68
+ "/",
69
+ "/proc",
70
+ "/sys",
71
+ "/dev",
72
+ "/run",
73
+ "/var/run",
74
+ "/var/lib/docker",
75
+ "/etc",
76
+ "/root",
77
+ "/home",
78
+ "/opt/realm",
79
+ "/boot",
80
+ ] as const;
81
+
82
+ /**
83
+ * The one spelling of a host-absolute path, or `null` when the string can't be
84
+ * one: `//mnt///media/` and `/mnt/./media` both collapse to `/mnt/media`, and
85
+ * anything relative or carrying a `..` segment is refused outright rather than
86
+ * resolved (a path that climbs is a path whose target depends on where it was
87
+ * written, which is not something an admin-set string gets to do).
88
+ *
89
+ * Returning the canonical string is the point: two paths that name the same
90
+ * directory have to compare equal before anything can be stored or checked for
91
+ * overlap against what's already stored.
92
+ */
93
+ export function normalizeHostPath(raw: string): string | null {
94
+ if (!raw.startsWith("/")) return null;
95
+ const segments: string[] = [];
96
+ for (const segment of raw.split("/")) {
97
+ if (segment === "" || segment === ".") continue;
98
+ if (segment === "..") return null;
99
+ segments.push(segment);
100
+ }
101
+ return `/${segments.join("/")}`;
102
+ }
103
+
104
+ /**
105
+ * Whether two normalized paths name the same tree - equal, or either inside the
106
+ * other. Compared on separator boundaries, so `/mnt/nas/media` and
107
+ * `/mnt/nas/media-old` are unrelated rather than nested.
108
+ *
109
+ * Both arguments must already be through `normalizeHostPath`; a trailing slash
110
+ * or a doubled separator would read as a different tree.
111
+ */
112
+ export function pathsOverlap(a: string, b: string): boolean {
113
+ if (a === b) return true;
114
+ const contains = (parent: string, child: string) =>
115
+ child.startsWith(parent === "/" ? "/" : `${parent}/`);
116
+ return contains(a, b) || contains(b, a);
117
+ }
118
+
119
+ export function isValidHostDirectoryPath(raw: unknown): raw is string {
120
+ if (typeof raw !== "string") return false;
121
+ const normalized = normalizeHostPath(raw);
122
+ if (normalized === null) return false;
123
+ for (const blocked of HOST_DIRECTORY_BLOCKLIST) {
124
+ if (normalized === blocked) return false;
125
+ // Not `pathsOverlap`: containment here is one-directional. Everything is
126
+ // under `/`, so the root entry blocks itself and nothing else.
127
+ if (blocked === "/") continue;
128
+ if (normalized.startsWith(blocked + "/")) return false;
129
+ }
130
+ return true;
131
+ }
132
+
133
+ /**
134
+ * Pass through the host's GPU to the container.
135
+ *
136
+ * Gives the container access to the graphics card via Docker device
137
+ * requests. Used for hardware-accelerated workloads like video transcoding.
138
+ *
139
+ * @see https://yourrealm.eu/docs/realm-ts/host/gpu/
140
+ */
141
+ export const gpu = hostable(
142
+ "host.gpu",
143
+ z.literal(true).default(true),
144
+ {
145
+ iterateUses: (svc) => (svc.gpu === true ? [true] : []),
146
+ toValue: () => true as const,
147
+ },
148
+ {
149
+ name: "GPU passthrough",
150
+ phrase: "GPU passthrough (/dev/dri)",
151
+ helpText: "This app gets access to your graphics card.",
152
+ icon: "GraphicsCardIcon",
153
+ appKinds: "both",
154
+ },
155
+ );
156
+
157
+ /**
158
+ * Publish a port on the host machine.
159
+ *
160
+ * The app declares the port it wants; the admin can approve it as-is or
161
+ * change the port number on the consent screen.
162
+ *
163
+ * @see https://yourrealm.eu/docs/realm-ts/host/port/
164
+ */
165
+ export const port = hostable(
166
+ "host.port",
167
+ z.number().int().min(1).max(65535),
168
+ {
169
+ iterateUses: (svc) => (svc.hostPorts ?? []).map((hp) => hp.hostPort),
170
+ toValue: (data: number) => ({ port: data }),
171
+ // Admin-grant input arrives as a raw string from the form (we don't
172
+ // coerce in the UI so an empty input stays empty). Coerce here, then
173
+ // re-validate the int range.
174
+ grantSchema: z.coerce.number().int().min(1).max(65535),
175
+ },
176
+ {
177
+ name: "Port",
178
+ phrase: "bind a host port",
179
+ helpText:
180
+ "This port will be reachable from the internet. You can change the port number before approving.",
181
+ icon: "PlugIcon",
182
+ appKinds: "both",
183
+ },
184
+ );
185
+
186
+ /**
187
+ * Branded host-absolute path owned by a Silo. Mirrors `PeerHostPath` /
188
+ * `WorkerHostPath`: lets the Silo's operator-chosen directory flow into
189
+ * `volumes.hostPath` without widening the literal-path type to arbitrary
190
+ * `string`.
191
+ */
192
+ export type SiloHostPath = string & { readonly __siloHostPath: unique symbol };
193
+
194
+ /**
195
+ * Services a member requires from its Silo: service name -> the port it
196
+ * answers on. The richer form of the `network` affordance - `network: true`
197
+ * is membership alone, a require-map is membership plus resolution.
198
+ */
199
+ export type SiloRequires = Readonly<Record<string, number>>;
200
+
201
+ /**
202
+ * How a member uses the Silo's shared directory. Declared, never guessed:
203
+ * writability is the biggest thing a Silo hands out, so it's the app's stated
204
+ * intent and the operator's consent, not a property of the path string.
205
+ */
206
+ export type SiloVolumeMode = "read" | "readwrite";
207
+
208
+ /** The three affordances a member can ask a Silo for. */
209
+ export interface SiloGrants {
210
+ readonly network?: true | SiloRequires;
211
+ readonly volume?: SiloVolumeMode;
212
+ readonly apiKey?: true;
213
+ }
214
+
215
+ /**
216
+ * `SiloGrants` with at least one affordance set. Joining a Silo without
217
+ * asking it for anything means nothing, so `r.host.silo({})` is a compile
218
+ * error rather than a no-op grant the operator has to reason about.
219
+ */
220
+ export type SiloGrantsInput = {
221
+ [K in keyof SiloGrants]-?: Required<Pick<SiloGrants, K>> & Partial<SiloGrants>;
222
+ }[keyof SiloGrants];
223
+
224
+ /**
225
+ * The Silo's shared directory as a complete volume entry - it goes straight
226
+ * into `volumes` and is mounted verbatim, so every member sees the same bytes
227
+ * at the same `containerPath` and can exchange absolute paths at runtime.
228
+ */
229
+ export interface SiloVolume {
230
+ readonly hostPath: SiloHostPath;
231
+ readonly containerPath: string;
232
+ /**
233
+ * The Silo's group. The worker adds `group_add: [gid]` to any service that
234
+ * mounts a gid-carrying volume, so filesystem membership lands exactly where
235
+ * the mount lands - no cooperation needed from the image.
236
+ */
237
+ readonly gid: number;
238
+ /** `true` for `volume: "read"`, `false` for `volume: "readwrite"`. */
239
+ readonly readOnly: boolean;
240
+ /**
241
+ * Always `false`: a member's mount is never how a Silo gets backed up.
242
+ * Whether the shared bytes are backed up is the operator's answer on the
243
+ * Silo record, and it reaches the backup app from there - so no member
244
+ * speaks for the commons, and two members mounting the same tree can't
245
+ * enroll it twice. Stamped rather than left absent so a volume with no
246
+ * declared strategy stays a signal about volumes an author actually wrote.
247
+ */
248
+ readonly backup: false;
249
+ }
250
+
251
+ type ServicesFrom<N extends SiloRequires> = {
252
+ readonly [K in keyof N]: { readonly host: string; readonly port: number };
253
+ };
254
+
255
+ /**
256
+ * What `config.silo` resolves to, narrowed to the affordances that were
257
+ * actually asked for - reading `config.silo.apiKey` without declaring
258
+ * `apiKey: true` is a compile error, not an undefined at runtime.
259
+ *
260
+ * `services` only appears for a require-map, and contains exactly the names
261
+ * that were required: it answers "where is the thing I said I need", not
262
+ * "who else is in this Silo", so there is no roster to iterate and no
263
+ * possibly-undefined lookup to guard.
264
+ */
265
+ export type SiloValue<G extends SiloGrants> = (G extends { network: infer N }
266
+ ? N extends SiloRequires
267
+ ? { readonly network: Network; readonly services: ServicesFrom<N> }
268
+ : { readonly network: Network }
269
+ : unknown) &
270
+ (G extends { volume: SiloVolumeMode } ? { readonly volume: SiloVolume } : unknown) &
271
+ (G extends { apiKey: true } ? { readonly apiKey: string } : unknown);
272
+
273
+ /**
274
+ * The widest resolved shape - every affordance optional. This is what the
275
+ * runtime deals in (`resolveSilo` builds it per-declaration from the Silo
276
+ * record on the eval context);
277
+ * the per-declaration narrowing to `SiloValue<G>` is type-level only, applied
278
+ * by `ValueOf` in grants.ts off the author's declared flags.
279
+ */
280
+ export type SiloResolved = {
281
+ readonly network?: Network;
282
+ readonly services?: Readonly<Record<string, { readonly host: string; readonly port: number }>>;
283
+ readonly volume?: SiloVolume;
284
+ readonly apiKey?: string;
285
+ };
286
+
287
+ /**
288
+ * One require-map entry the Silo can't satisfy: the name isn't published at
289
+ * all (`offeredPort` absent) or is published on a different port.
290
+ */
291
+ export interface SiloRequireUnmet {
292
+ readonly name: string;
293
+ readonly port: number;
294
+ readonly offeredPort?: number;
295
+ }
296
+
297
+ /**
298
+ * The guided message for a require-map the Silo doesn't meet. Shared between
299
+ * Home eval and the SDK test harness so both surfaces emit byte-identical
300
+ * text (the harness asserts against it).
301
+ */
302
+ export function siloRequiresUnmetMessage(
303
+ siloName: string,
304
+ unmet: readonly SiloRequireUnmet[],
305
+ ): string {
306
+ const parts = unmet.map((u) =>
307
+ u.offeredPort === undefined
308
+ ? `doesn't offer "${u.name}" on port ${u.port}`
309
+ : `offers "${u.name}" on ${u.offeredPort}, not ${u.port}`,
310
+ );
311
+ return `Silo "${siloName}" ${parts.join("; ")}`;
312
+ }
313
+
314
+ // Type-level only, so `ValueOf` can read the shape off `toValue` (mirrors
315
+ // `host.management` / `home.apiAccess`). Every real value comes from
316
+ // `resolveSilo`, built from the Silo record on the eval context.
317
+ const SILO_PLACEHOLDER: SiloResolved = {};
318
+
319
+ /**
320
+ * Resolve a `host.silo` declaration against the Silo the operator bound the
321
+ * app to.
322
+ *
323
+ * Two sides meet here: `declared` is the author's asked-for affordances,
324
+ * `grant` is the operator's chosen Silo id. Neither alone is enough - the
325
+ * author says what they want, the operator says from where.
326
+ *
327
+ * Throws rather than resolving a partial value: an app that can't get what it
328
+ * asked for must drop with a readable message, not push an `undefined` into a
329
+ * mount and fail somewhere further downstream.
330
+ *
331
+ * The throws are `validation` issues, not `eval` ones, because every failure
332
+ * here is the operator's to fix (wrong Silo, wrong worker, a require the Silo
333
+ * doesn't meet) and every message says only what they already know - their own
334
+ * permission path, their own Silo id, their own worker. `eval` issues get
335
+ * redacted to a generic string at the HTTP boundary precisely because they can
336
+ * carry internals; these carry none, so they reach the operator intact.
337
+ */
338
+ function resolveSilo({ grant, declared, path, ctx }: ResolveArgs<unknown>): SiloResolved {
339
+ // Annotated rather than inferred: TypeScript only narrows past a
340
+ // `never`-returning call when the binding carries an explicit type.
341
+ const fail: (message: string) => never = (message) => {
342
+ throw new RealmCompileError({ kind: "validation", path: path.split("."), message });
343
+ };
344
+
345
+ if (typeof grant !== "string") {
346
+ // A speculative eval has no operator behind it, so "you didn't pick a
347
+ // Silo" answers a question nobody has been asked yet - and refusing there
348
+ // drops the app out of its own install preview.
349
+ //
350
+ // Both grant-writing routes run `sanitizeGrants`, whose `grantSchema` for
351
+ // this kind is a non-empty string, so a Silo grant with no id is a 400 long
352
+ // before it reaches here; the `fail` below is defence in depth.
353
+ if (ctx.speculative) return speculativeSilo(declared as SiloGrants);
354
+ fail("no Silo was picked for this permission");
355
+ }
356
+ const silo = ctx.silos.find((s) => s.id === grant);
357
+ if (!silo) fail(`Silo "${grant}" no longer exists`);
358
+ if (ctx.worker.internalId !== undefined && silo.workerId !== ctx.worker.internalId) {
359
+ // A Silo's bytes are a real directory on one box, so a member has to run
360
+ // on that box. Reachable by deploying a silo app to every worker.
361
+ fail(`Silo "${silo.name}" is on worker ${silo.workerSlug}, so this app must run there too`);
362
+ }
363
+
364
+ const asked = declared as SiloGrants;
365
+ const resolved: SiloResolved = {};
366
+ if (asked.network !== undefined) {
367
+ (resolved as { network: SiloResolved["network"] }).network = siloNetwork(silo.id);
368
+ if (asked.network !== true) {
369
+ // A require-map gates on the merged directory of the Silo's other
370
+ // members: every required name must be published on exactly the
371
+ // required port. Thrown as its own subclass so the pass structure can
372
+ // retry the drop when the directory later grows.
373
+ const offered = ctx.siloDirectory(silo.id);
374
+ const unmet: SiloRequireUnmet[] = [];
375
+ for (const [name, port] of Object.entries(asked.network)) {
376
+ const entry = offered.get(name);
377
+ if (entry === undefined) unmet.push({ name, port });
378
+ else if (entry.port !== port) unmet.push({ name, port, offeredPort: entry.port });
379
+ }
380
+ if (unmet.length > 0) {
381
+ throw new SiloRequiresError({
382
+ kind: "validation",
383
+ path: path.split("."),
384
+ message: siloRequiresUnmetMessage(silo.name, unmet),
385
+ });
386
+ }
387
+ const services: Record<string, { host: string; port: number }> = {};
388
+ for (const [name, port] of Object.entries(asked.network)) {
389
+ // The published name IS the host: the worker sets a matching
390
+ // network alias on the silo bridge at connect time.
391
+ services[name] = { host: name, port };
392
+ }
393
+ (resolved as { services: SiloResolved["services"] }).services = services;
394
+ }
395
+ }
396
+ if (asked.volume !== undefined) {
397
+ (resolved as { volume: SiloResolved["volume"] }).volume = {
398
+ hostPath: silo.hostPath as SiloHostPath,
399
+ containerPath: silo.containerPath,
400
+ gid: silo.gid,
401
+ // The author declared the mode and the operator approved it; a member
402
+ // that only asked to read gets a mount it cannot write through.
403
+ readOnly: asked.volume === "read",
404
+ backup: false,
405
+ };
406
+ }
407
+ if (asked.apiKey !== undefined) {
408
+ // Derived for every Silo up front, so present whenever the exists-check
409
+ // above passed.
410
+ (resolved as { apiKey: SiloResolved["apiKey"] }).apiKey = ctx.siloApiKeys.get(silo.id)!;
411
+ }
412
+ return resolved;
413
+ }
414
+
415
+ const siloRequiresSchema = z.record(z.string(), z.number().int().min(1).max(65535));
416
+
417
+ const siloGrantsSchema = z
418
+ .object({
419
+ network: z.union([z.literal(true), siloRequiresSchema]).optional(),
420
+ volume: z.enum(["read", "readwrite"]).optional(),
421
+ apiKey: z.literal(true).optional(),
422
+ })
423
+ .refine((g) => g.network !== undefined || g.volume !== undefined || g.apiKey !== undefined, {
424
+ message: "silo() needs at least one of network, volume, apiKey",
425
+ });
426
+
427
+ const siloFactory = hostable(
428
+ "host.silo",
429
+ siloGrantsSchema,
430
+ {
431
+ // The elevated use is the communal volume: a mount carrying a gid reads
432
+ // (or writes) another trust domain's bytes and joins its group. All three
433
+ // of hostPath, gid and writability have to match a Silo the operator bound
434
+ // this app to - a hand-written gid, a Silo path without the join, or a
435
+ // writable mount from an app that only asked to read, all fail the gate.
436
+ // Network membership and the apiKey have no per-service use; they fall out
437
+ // of the grant being in place.
438
+ iterateUses: function* (svc) {
439
+ for (const v of svc.volumes ?? []) {
440
+ if (v.gid !== undefined) {
441
+ yield { hostPath: v.hostPath, gid: v.gid, readOnly: v.readOnly === true };
442
+ }
443
+ }
444
+ },
445
+ // Type-level only. The grant carries a Silo id, which means nothing
446
+ // without the Silo record, so every real value comes from `resolve`
447
+ // below; this exists so `ValueOf` can read the resolved shape.
448
+ toValue: (): SiloResolved => SILO_PLACEHOLDER,
449
+ resolve: resolveSilo,
450
+ // The author declares which affordances they want; the grant carries
451
+ // which Silo the operator bound (its id).
452
+ grantSchema: z.string().min(1),
453
+ },
454
+ {
455
+ name: "Silo",
456
+ phrase: "join a Silo",
457
+ helpText:
458
+ "This app joins a Silo you pick - an operator-owned network, shared directory, and shared credential. Everything in a Silo can see everything else in it, so only put apps together that you mean to share.",
459
+ icon: "StackIcon",
460
+ // Single-only: a Silo is a directory on one box, so the grant means
461
+ // nothing spread across a fleet.
462
+ appKinds: "single",
463
+ },
464
+ );
465
+
466
+ /**
467
+ * A `silo()` record with the author's declared flags preserved on `data`.
468
+ *
469
+ * Every other permission's `data` is `z.output<schema>` - one fixed type per
470
+ * kind, which is all `FactoryFor` can express. Silo is the one kind whose
471
+ * resolved value depends on what was asked for, so the factory is re-typed
472
+ * generically and the chain methods are re-declared here. They have to be
473
+ * re-declared rather than inherited: `Omit` instantiates polymorphic `this`
474
+ * against the *source* type, so an inherited `.reason()` would silently hand
475
+ * back the wide record and undo the narrowing. Inside an interface, `this`
476
+ * survives.
477
+ */
478
+ interface SiloRecord<G extends SiloGrantsInput> extends Omit<
479
+ ReturnType<typeof siloFactory>,
480
+ "data" | "reason" | "describe" | "optional"
481
+ > {
482
+ readonly data: G;
483
+ reason(text: string): this & { readonly meta: { readonly reason: string } };
484
+ describe(text: string): this;
485
+ optional(): this & { readonly meta: { readonly isOptional: true } };
486
+ }
487
+
488
+ /**
489
+ * Join an operator-owned Silo: a shared network, a shared directory, and a
490
+ * shared credential that outlive every app in them.
491
+ *
492
+ * The operator creates the Silo and points apps at it, so a set of apps
493
+ * installed separately can share files and talk to each other without any of
494
+ * them owning the anchor. Ask for the affordances you need and nothing else -
495
+ * `config.silo` is typed to exactly what you declared.
496
+ *
497
+ * ```ts
498
+ * silo: r.host
499
+ * .silo({ network: { sabnzbd: 8080 }, volume: "readwrite" })
500
+ * .reason("Import finished downloads into your library"),
501
+ * ```
502
+ *
503
+ * A require-map (rather than `network: true`) both gates the install - the
504
+ * operator can't pick a Silo that doesn't offer those services - and resolves
505
+ * those names into `config.silo.services`.
506
+ *
507
+ * `volume` says what you do with the shared bytes: `"read"` mounts them
508
+ * read-only, `"readwrite"` lets you write. Ask for `"read"` when that's all
509
+ * you need - it's the difference the operator sees on the consent screen.
510
+ *
511
+ * For storage the app keeps to itself, don't reach for this: a relative
512
+ * `volumes` path persists with no permission and no operator decision.
513
+ *
514
+ * Only a single app (`createApp`) may declare this. A Silo is a directory on
515
+ * one box, so a member runs on that box - "every compatible worker" is the
516
+ * opposite claim.
517
+ *
518
+ * @see https://yourrealm.eu/docs/realm-ts/host/silo/
519
+ */
520
+ export const silo = siloFactory as unknown as Omit<typeof siloFactory, never> &
521
+ (<G extends SiloGrantsInput>(grants: G) => SiloRecord<G>);
522
+
523
+ /**
524
+ * Join the Home's management bridge network and read Home-side identity.
525
+ *
526
+ * Bundles the bridge network with the dashboard URL pieces every system-app
527
+ * proxy or sidecar tends to want at once - `network` (the management bridge),
528
+ * `baseDomain` and `homeSlug` (owned by Home's `home_state`, set by the
529
+ * installer or operator), and `workerId` (the running worker's short slug).
530
+ *
531
+ * The `network` half is the SDK-known constant; the rest come off the eval
532
+ * context. Empty strings stand in for "not yet configured" - guard with
533
+ * `if (config.management.baseDomain && config.management.homeSlug)` before
534
+ * deriving URLs.
535
+ *
536
+ * @see https://yourrealm.eu/docs/realm-ts/host/management/
537
+ */
538
+ export const management = hostable(
539
+ "host.management",
540
+ z.literal(true).default(true),
541
+ {
542
+ // The "use" of management is implicit — joining the network and
543
+ // reading the URL pieces falls out of the grant being in place;
544
+ // nothing in the resolved service spec distinguishes a user from a
545
+ // non-user.
546
+ iterateUses: () => [],
547
+ // Type-level only. The network is the SDK-side constant, but every other
548
+ // field is Home's, so the real value comes from `resolve`.
549
+ toValue: (): ManagementValue => MANAGEMENT_PLACEHOLDER,
550
+ resolve: ({ ctx }): ManagementValue => ({
551
+ network: MANAGEMENT_NET,
552
+ baseDomain: ctx.home.baseDomain,
553
+ homeSlug: ctx.home.homeSlug,
554
+ workerId: ctx.worker.id,
555
+ // acme-dns username is the domain slug = the first label of baseDomain.
556
+ acmeDns: ctx.home.acmeDns
557
+ ? {
558
+ serverUrl: ctx.home.acmeDns.serverUrl,
559
+ username: ctx.home.baseDomain.split(".")[0] ?? "",
560
+ apiKey: ctx.home.acmeDns.apiKey,
561
+ ca: ctx.home.acmeDns.ca,
562
+ caRootPem: ctx.home.acmeDns.caRootPem,
563
+ }
564
+ : null,
565
+ }),
566
+ },
567
+ {
568
+ name: "Management bridge + Home identity",
569
+ phrase: "join the management bridge network and read Home identity",
570
+ helpText:
571
+ "This grants access to the management bridge network and the Home's dashboard URL pieces.",
572
+ icon: "ShareNetworkIcon",
573
+ danger: true,
574
+ appKinds: "both",
575
+ },
576
+ );
577
+
578
+ /**
579
+ * Install host-level iptables and ip6tables rules from the container.
580
+ *
581
+ * Unlocks the service-level `iptables: true` field. The renderer expands
582
+ * that into the primitives the firewall needs - the `NET_ADMIN` capability
583
+ * to mutate netfilter rules, plus host network mode so the rules apply to
584
+ * host traffic and Docker's `DOCKER-USER` chain.
585
+ *
586
+ * @see https://yourrealm.eu/docs/realm-ts/host/iptables/
587
+ */
588
+ export const iptables = hostable(
589
+ "host.iptables",
590
+ z.literal(true).default(true),
591
+ {
592
+ iterateUses: (svc) => (svc.iptables === true ? [true] : []),
593
+ toValue: () => true as const,
594
+ },
595
+ {
596
+ name: "Host iptables",
597
+ phrase: "install host iptables and ip6tables rules",
598
+ helpText:
599
+ "This app installs firewall rules on the host. Required for the firewall app to lock down which ports are reachable.",
600
+ icon: "ShieldIcon",
601
+ danger: true,
602
+ appKinds: "both",
603
+ },
604
+ );
605
+
606
+ /**
607
+ * Allow outbound internet access from the container.
608
+ *
609
+ * Without this permission, app networks are created with Docker's
610
+ * `internal: true` flag - containers can talk to peers they have explicit
611
+ * permission for, but cannot reach the outside world.
612
+ *
613
+ * @see https://yourrealm.eu/docs/realm-ts/host/egress/
614
+ */
615
+ export const egress = hostable(
616
+ "host.egress",
617
+ z.literal(true).default(true),
618
+ {
619
+ iterateUses: () => [],
620
+ toValue: () => true as const,
621
+ },
622
+ {
623
+ name: "Internet access",
624
+ phrase: "outbound internet access",
625
+ helpText:
626
+ "Without this, the app can only communicate with other apps on your server. Granting this allows it to make outbound connections to the internet.",
627
+ icon: "GlobeIcon",
628
+ appKinds: "both",
629
+ },
630
+ );
631
+
632
+ /**
633
+ * Receive a stream of every container's stdout and stderr from the worker
634
+ * via a unix socket on the host.
635
+ *
636
+ * Resolves to the mount for the worker's logs socket DIRECTORY — spread it
637
+ * into `volumes` (`volumes: [config.logs]`, or override `containerPath` to put
638
+ * it elsewhere). Inside the container, connect to the `logs.sock` inside the
639
+ * mounted dir with `connectToLogsSocket` / `streamLogs` from
640
+ * `@yourrealm/sdk/runtime/logs-client`. (The mount is the directory, not the
641
+ * socket file: a file bind-mount of a unix socket doesn't wire across
642
+ * containers on macOS Docker.)
643
+ *
644
+ * `readOnly: true` and not negotiable — the directory is shared by every app
645
+ * holding this grant, so a writable mount would let any of them unlink the
646
+ * socket and put their own listener in its place. Overriding it is a grant
647
+ * violation, not a preference.
648
+ *
649
+ * The grant exempts the resolved path from the absolute-mount gate, so a
650
+ * bare `host.logs()` is enough to wire the volume.
651
+ *
652
+ * @see https://yourrealm.eu/docs/realm-ts/host/logs/
653
+ */
654
+ /**
655
+ * Probe-the-UX permission. No worker advertises this kind, so an app
656
+ * that declares it can only succeed once a (hypothetical) worker that
657
+ * supports it enrolls. Useful for exercising the
658
+ * "no worker can satisfy this required permission" path in the
659
+ * install panel without a real driver behind it.
660
+ */
661
+ export const rocketLaunch = hostable(
662
+ "host.rocketLaunch",
663
+ z.literal(true).default(true),
664
+ {
665
+ iterateUses: () => [],
666
+ toValue: () => true as const,
667
+ },
668
+ {
669
+ name: "Rocket launch",
670
+ phrase: "launch a rocket",
671
+ helpText:
672
+ "Permission with no worker that can satisfy it. Used to exercise the unsupported-permission UX.",
673
+ icon: "RocketLaunchIcon",
674
+ appKinds: "both",
675
+ },
676
+ );
677
+
678
+ export const logs = hostable(
679
+ "host.logs",
680
+ z.literal(true).default(true),
681
+ {
682
+ // The "use" of host.logs is implicit — mounting the socket dir falls out
683
+ // of the grant being in place; the resolved hostPath is exempted from the
684
+ // absolute-mount check via `collectAllowed` in the Home eval graph. That
685
+ // exemption carries the read-only requirement with it, so an app that
686
+ // mounts the shared dir writable is rejected there.
687
+ iterateUses: () => [],
688
+ toValue: () => REALM_LOGS_MOUNT,
689
+ },
690
+ {
691
+ name: "Container logs",
692
+ phrase: "stream container logs from every app",
693
+ helpText:
694
+ "This app receives the stdout and stderr of every container running on this Worker. Useful for log forwarders and audit collectors.",
695
+ icon: "TerminalWindowIcon",
696
+ danger: true,
697
+ appKinds: "both",
698
+ },
699
+ );
700
+
701
+ /**
702
+ * Ask the worker for a point-in-time sample of the host and every container
703
+ * running on it, over a unix socket.
704
+ *
705
+ * The read-only counterpart to `host.logs`: same broker shape, same
706
+ * all-or-nothing scope, but request/response instead of a stream. Resolves to
707
+ * the mount for the worker's stats socket DIRECTORY — spread it into `volumes`
708
+ * (`volumes: [config.stats]`, or override `containerPath`). Inside the
709
+ * container, connect to the `stats.sock` inside the mounted dir with
710
+ * `sampleStats` from `@yourrealm/sdk/runtime/stats-client`. (The mount is the
711
+ * directory, not the socket file: a file bind-mount of a unix socket doesn't
712
+ * wire across containers on macOS Docker.) `readOnly: true` is fixed, for the
713
+ * same shared-directory reason as `host.logs`.
714
+ *
715
+ * The grant exempts the resolved path from the absolute-mount gate, so a
716
+ * bare `host.stats()` is enough to wire the volume.
717
+ *
718
+ * @see https://yourrealm.eu/docs/realm-ts/host/stats/
719
+ */
720
+ export const stats = hostable(
721
+ "host.stats",
722
+ z.literal(true).default(true),
723
+ {
724
+ // The "use" of host.stats is implicit — mounting the socket dir falls out
725
+ // of the grant being in place; the resolved hostPath is exempted from the
726
+ // absolute-mount check via `collectAllowed` in the Home eval graph. That
727
+ // exemption carries the read-only requirement with it, so an app that
728
+ // mounts the shared dir writable is rejected there.
729
+ iterateUses: () => [],
730
+ toValue: () => REALM_STATS_MOUNT,
731
+ },
732
+ {
733
+ name: "Container stats",
734
+ phrase: "read resource usage for every app's containers",
735
+ helpText:
736
+ "This app can sample the resource usage of every container running on this Worker, plus the host's own load, memory, and disks. Read-only: it can see your fleet, but can't touch it.",
737
+ icon: "PulseIcon",
738
+ danger: true,
739
+ appKinds: "both",
740
+ },
741
+ );
742
+
743
+ /**
744
+ * Spawn disposable, fully-hardened containers on the worker - one box per
745
+ * job, torn down the moment it exits - without ever holding the Docker
746
+ * socket. The app picks what to run; the worker dictates how it runs
747
+ * (hardening, network, lifecycle). The canonical consumer is a builder
748
+ * running attacker-controlled `RUN` steps.
749
+ *
750
+ * Resolves to `{ socket, scratch, artifacts }`, three mounts Home fills per-app
751
+ * at eval (`/opt/realm/worker/spawn/sock/<appId>` + `data/<appId>/scratch` +
752
+ * `data/<appId>/artifacts`): the broker socket DIRECTORY to mount (connect to
753
+ * `spawn.sock` inside it), the scratch dir to stage a build context in (input),
754
+ * and the artifacts dir to read a build's output back from. Spread each into
755
+ * `volumes`. `socket` is `readOnly: true` and can't be overridden — connecting
756
+ * to the broker needs no write access to the directory holding it; `scratch`
757
+ * and `artifacts` are the app's own storage and stay writable. All three are
758
+ * built per-app by this kind's `resolve` hook (mirrors `home.apiAccess`).
759
+ * The grant exempts all three resolved paths from the absolute-mount gate,
760
+ * so a bare `host.spawn()` is enough to wire them. There is no network:
761
+ * the box attaches to the untrusted-zone DMZ broker-side, and no app service
762
+ * may sit on that DMZ.
763
+ *
764
+ * @see https://yourrealm.eu/docs/realm-ts/host/spawn/
765
+ */
766
+ export const spawn = hostable(
767
+ "host.spawn",
768
+ z.literal(true).default(true),
769
+ {
770
+ // The "use" of host.spawn is implicit — mounting the socket/scratch/
771
+ // artifacts dirs falls out of the grant being in place; the resolved
772
+ // paths are exempted from the absolute-mount gate via `collectAllowed`
773
+ // in the Home eval graph.
774
+ iterateUses: () => [],
775
+ // Type-level only; every real value comes from `resolve`, since all three
776
+ // paths are per-app and the grant carries only presence.
777
+ toValue: (): SpawnValue => SPAWN_PLACEHOLDER,
778
+ // `socket` is the per-app DIRECTORY the app mounts (connect to
779
+ // `spawn.sock` inside); the wire carries the socket file inside it (the
780
+ // path the worker binds). scratch/artifacts are dir mounts.
781
+ resolve: ({ ctx }): SpawnValue => spawnMounts(ctx.appId),
782
+ },
783
+ {
784
+ name: "Spawn disposable containers",
785
+ phrase: "spawn disposable hardened containers on the worker",
786
+ helpText:
787
+ "This app can start one-shot, fully-hardened containers on your server (e.g. to run builds). The worker forces the hardening, network, and lifecycle; the app never gets the Docker socket.",
788
+ icon: "CubeIcon",
789
+ danger: true,
790
+ appKinds: "both",
791
+ },
792
+ );