@clawscarf/cli 0.1.0-alpha.1

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 (197) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +3 -0
  3. package/THIRD_PARTY_NOTICES.md +102 -0
  4. package/deploy/execution/browser/LICENSE.playwright +202 -0
  5. package/deploy/execution/browser/seccomp.json +640 -0
  6. package/deploy/execution/browser-node/configuration.js +85 -0
  7. package/deploy/execution/browser-node/operator.js +167 -0
  8. package/deploy/execution/network/node-ingress.cfg +35 -0
  9. package/deploy/models/catalog.json +275 -0
  10. package/deploy/openshell/policy.yaml +24 -0
  11. package/generated/http/LICENSE.md +21 -0
  12. package/generated/http/client/client.gen.d.ts +2 -0
  13. package/generated/http/client/client.gen.js +216 -0
  14. package/generated/http/client/index.d.ts +10 -0
  15. package/generated/http/client/index.js +6 -0
  16. package/generated/http/client/types.gen.d.ts +120 -0
  17. package/generated/http/client/types.gen.js +2 -0
  18. package/generated/http/client/utils.gen.d.ts +37 -0
  19. package/generated/http/client/utils.gen.js +228 -0
  20. package/generated/http/core/auth.gen.d.ts +25 -0
  21. package/generated/http/core/auth.gen.js +14 -0
  22. package/generated/http/core/bodySerializer.gen.d.ts +25 -0
  23. package/generated/http/core/bodySerializer.gen.js +57 -0
  24. package/generated/http/core/params.gen.d.ts +43 -0
  25. package/generated/http/core/params.gen.js +109 -0
  26. package/generated/http/core/pathSerializer.gen.d.ts +33 -0
  27. package/generated/http/core/pathSerializer.gen.js +106 -0
  28. package/generated/http/core/queryKeySerializer.gen.d.ts +18 -0
  29. package/generated/http/core/queryKeySerializer.gen.js +92 -0
  30. package/generated/http/core/serverSentEvents.gen.d.ts +71 -0
  31. package/generated/http/core/serverSentEvents.gen.js +132 -0
  32. package/generated/http/core/types.gen.d.ts +83 -0
  33. package/generated/http/core/types.gen.js +2 -0
  34. package/generated/http/core/utils.gen.d.ts +19 -0
  35. package/generated/http/core/utils.gen.js +87 -0
  36. package/package.json +37 -0
  37. package/packs/README.md +130 -0
  38. package/packs/research-team/pack.json +20 -0
  39. package/packs/research-team/researcher/CLAW.md +19 -0
  40. package/packs/research-team/researcher/package.json +8 -0
  41. package/packs/research-team/researcher/profiles/openclaw.yml +6 -0
  42. package/packs/research-team/reviewer/CLAW.md +19 -0
  43. package/packs/research-team/reviewer/package.json +8 -0
  44. package/packs/research-team/reviewer/profiles/openclaw.yml +6 -0
  45. package/pnpm-lock.yaml +7158 -0
  46. package/recipes/README.md +37 -0
  47. package/recipes/team-server/recipe.json +28 -0
  48. package/release/README.md +137 -0
  49. package/release/components.json +29 -0
  50. package/release/operator.md +63 -0
  51. package/runtime/configuration.js +135 -0
  52. package/runtime/connections-configuration.js +70 -0
  53. package/runtime/model-contract.js +92 -0
  54. package/runtime/releases/0.1.0-alpha.1.json +37 -0
  55. package/scripts/clawscarf.js +92 -0
  56. package/scripts/cloud/login.js +134 -0
  57. package/scripts/cloud/registration.js +232 -0
  58. package/scripts/cloud/url.js +9 -0
  59. package/scripts/connections.js +193 -0
  60. package/scripts/controller.js +143 -0
  61. package/scripts/deployment/browser-node-compose.js +81 -0
  62. package/scripts/deployment/browser-node-helper.js +9 -0
  63. package/scripts/deployment/browser-node-pairing.js +165 -0
  64. package/scripts/deployment/browser-node.js +154 -0
  65. package/scripts/deployment/browser.js +143 -0
  66. package/scripts/deployment/certificates.js +76 -0
  67. package/scripts/deployment/compose.js +191 -0
  68. package/scripts/deployment/configuration.js +275 -0
  69. package/scripts/deployment/connection-policy.js +80 -0
  70. package/scripts/deployment/connection-settings.js +60 -0
  71. package/scripts/deployment/connections-runtime.js +31 -0
  72. package/scripts/deployment/connections.js +213 -0
  73. package/scripts/deployment/controller-compose.js +59 -0
  74. package/scripts/deployment/database.js +199 -0
  75. package/scripts/deployment/entrypoint.js +7 -0
  76. package/scripts/deployment/images.js +17 -0
  77. package/scripts/deployment/initial-services.js +53 -0
  78. package/scripts/deployment/launch.js +177 -0
  79. package/scripts/deployment/logs.js +15 -0
  80. package/scripts/deployment/model-gateway-compose.js +74 -0
  81. package/scripts/deployment/model-gateway.js +125 -0
  82. package/scripts/deployment/models.js +82 -0
  83. package/scripts/deployment/networks.js +354 -0
  84. package/scripts/deployment/policy.js +56 -0
  85. package/scripts/deployment/preflight.js +124 -0
  86. package/scripts/deployment/prepare.js +205 -0
  87. package/scripts/deployment/process.js +70 -0
  88. package/scripts/deployment/relay.js +58 -0
  89. package/scripts/deployment/runtime-binding.js +100 -0
  90. package/scripts/deployment/runtime.js +232 -0
  91. package/scripts/deployment/state.js +154 -0
  92. package/scripts/deployment/team.js +76 -0
  93. package/scripts/deployment/upgrade-rpc.py +148 -0
  94. package/scripts/deployment/upgrade-state.js +47 -0
  95. package/scripts/deployment/upgrade.js +298 -0
  96. package/scripts/deployment/volumes.js +24 -0
  97. package/scripts/errors.js +9 -0
  98. package/scripts/installation/administrator.js +23 -0
  99. package/scripts/installation/command.js +176 -0
  100. package/scripts/installation/configuration.js +104 -0
  101. package/scripts/installation/configure.js +122 -0
  102. package/scripts/installation/delete.js +111 -0
  103. package/scripts/installation/doctor.js +8 -0
  104. package/scripts/installation/errors.js +8 -0
  105. package/scripts/installation/files.js +51 -0
  106. package/scripts/installation/installer/cloud.js +22 -0
  107. package/scripts/installation/installer/collect.js +245 -0
  108. package/scripts/installation/installer/inputs.js +59 -0
  109. package/scripts/installation/installer/menu.js +49 -0
  110. package/scripts/installation/installer/prompts.js +139 -0
  111. package/scripts/installation/installer/run.js +251 -0
  112. package/scripts/installation/installer/secrets.js +16 -0
  113. package/scripts/installation/installer/sections/access.js +69 -0
  114. package/scripts/installation/installer/sections/certificates.js +18 -0
  115. package/scripts/installation/installer/sections/connections.js +4 -0
  116. package/scripts/installation/installer/sections/external-models.js +35 -0
  117. package/scripts/installation/installer/sections/models.js +119 -0
  118. package/scripts/installation/installer/sections/packs.js +82 -0
  119. package/scripts/installation/installer/sections/resources.js +10 -0
  120. package/scripts/installation/installer/settings.js +192 -0
  121. package/scripts/installation/installer/summary.js +44 -0
  122. package/scripts/installation/lifecycle.js +127 -0
  123. package/scripts/installation/location.js +3 -0
  124. package/scripts/installation/models.js +42 -0
  125. package/scripts/installation/options.js +360 -0
  126. package/scripts/installation/packs.js +150 -0
  127. package/scripts/installation/plan.js +121 -0
  128. package/scripts/installation/prerequisites.js +170 -0
  129. package/scripts/installation/recipes/catalog.js +55 -0
  130. package/scripts/installation/recipes/definition.js +32 -0
  131. package/scripts/installation/reconfigure.js +255 -0
  132. package/scripts/installation/requirements.js +23 -0
  133. package/scripts/installation/resolve.js +217 -0
  134. package/scripts/installation/runtime.js +87 -0
  135. package/scripts/installation/save.js +95 -0
  136. package/scripts/installation/setup.js +91 -0
  137. package/scripts/models/catalog.js +9 -0
  138. package/scripts/models/configuration.js +184 -0
  139. package/scripts/models/credentials.js +132 -0
  140. package/scripts/models/runtime.js +87 -0
  141. package/scripts/output.js +34 -0
  142. package/scripts/packs/connections.js +95 -0
  143. package/scripts/packs/lifecycle.js +125 -0
  144. package/scripts/packs/model.js +66 -0
  145. package/scripts/packs/native.js +69 -0
  146. package/scripts/packs/openshell.js +148 -0
  147. package/scripts/packs/policy.js +189 -0
  148. package/scripts/packs/requirements.in +1 -0
  149. package/scripts/packs/requirements.js +11 -0
  150. package/scripts/packs/requirements.txt +116 -0
  151. package/scripts/packs/source.js +90 -0
  152. package/scripts/packs/transport.py +68 -0
  153. package/scripts/people.js +52 -0
  154. package/scripts/release/create.js +62 -0
  155. package/scripts/release/definition.js +51 -0
  156. package/scripts/session.js +27 -0
  157. package/services/access/generated/client.gen.d.ts +12 -0
  158. package/services/access/generated/client.gen.js +3 -0
  159. package/services/access/generated/fastify.gen.d.ts +49 -0
  160. package/services/access/generated/fastify.gen.js +2 -0
  161. package/services/access/generated/index.d.ts +2 -0
  162. package/services/access/generated/index.js +2 -0
  163. package/services/access/generated/sdk.gen.d.ts +28 -0
  164. package/services/access/generated/sdk.gen.js +107 -0
  165. package/services/access/generated/types.gen.d.ts +349 -0
  166. package/services/access/generated/types.gen.js +2 -0
  167. package/services/access/migrations/001_access.sql +39 -0
  168. package/services/access/migrations/002_administrator_setup.sql +16 -0
  169. package/services/access/migrations/003_local_login_completion.sql +5 -0
  170. package/services/access/migrations/004_invitations.sql +21 -0
  171. package/services/access/repo/postgres.js +341 -0
  172. package/services/access/repo/session-logout.js +28 -0
  173. package/services/access/types/credential.js +2 -0
  174. package/services/access/types/errors.js +7 -0
  175. package/services/access/types/ingress.js +1 -0
  176. package/services/access/types/model.js +1 -0
  177. package/services/access/types/native-errors.js +7 -0
  178. package/services/access/types/native.js +1 -0
  179. package/services/cloud/generated/client.gen.d.ts +12 -0
  180. package/services/cloud/generated/client.gen.js +3 -0
  181. package/services/cloud/generated/index.d.ts +2 -0
  182. package/services/cloud/generated/index.js +2 -0
  183. package/services/cloud/generated/sdk.gen.d.ts +145 -0
  184. package/services/cloud/generated/sdk.gen.js +488 -0
  185. package/services/cloud/generated/types.gen.d.ts +2034 -0
  186. package/services/cloud/generated/types.gen.js +2 -0
  187. package/services/connections/cloud/generated/client.gen.d.ts +12 -0
  188. package/services/connections/cloud/generated/client.gen.js +3 -0
  189. package/services/connections/cloud/generated/fastify.gen.d.ts +68 -0
  190. package/services/connections/cloud/generated/fastify.gen.js +2 -0
  191. package/services/connections/cloud/generated/index.d.ts +2 -0
  192. package/services/connections/cloud/generated/index.js +2 -0
  193. package/services/connections/cloud/generated/sdk.gen.d.ts +95 -0
  194. package/services/connections/cloud/generated/sdk.gen.js +210 -0
  195. package/services/connections/cloud/generated/types.gen.d.ts +1044 -0
  196. package/services/connections/cloud/generated/types.gen.js +2 -0
  197. package/services/connections/migrations/001_connections.sql +220 -0
@@ -0,0 +1,143 @@
1
+ import { OperatorError } from "./errors.js";
2
+ import { Command } from "commander";
3
+ import { execFile } from "node:child_process";
4
+ import { chmod, copyFile, mkdir, readFile, readdir, writeFile, } from "node:fs/promises";
5
+ import { join, resolve } from "node:path";
6
+ import { promisify } from "node:util";
7
+ import { z } from "zod";
8
+ const execute = promisify(execFile);
9
+ const settingsSchema = z.strictObject({
10
+ name: z.string().regex(/^[a-z][a-z0-9-]{0,40}$/),
11
+ port: z.number().int().min(1024).max(65535),
12
+ gateway: z.string().min(1),
13
+ cli: z.string().min(1),
14
+ });
15
+ const releaseInput = JSON.parse(await readFile(new URL("../release/components.json", import.meta.url), "utf8"));
16
+ const release = z
17
+ .object({
18
+ openshell: z.object({
19
+ version: z.string().min(1),
20
+ gatewayImage: z.string().regex(/@sha256:[a-f0-9]{64}$/),
21
+ supervisorImage: z.string().regex(/@sha256:[a-f0-9]{64}$/),
22
+ }),
23
+ })
24
+ .parse(releaseInput).openshell;
25
+ function environment(directory) {
26
+ if (Object.keys(process.env).some((name) => name.startsWith("OPENSHELL_")))
27
+ throw new OperatorError("Unset OPENSHELL_* environment overrides; this controller uses its private configuration.");
28
+ return {
29
+ ...process.env,
30
+ XDG_CONFIG_HOME: join(directory, "config"),
31
+ XDG_STATE_HOME: join(directory, "state"),
32
+ XDG_DATA_HOME: join(directory, "data"),
33
+ };
34
+ }
35
+ async function protect(directory) {
36
+ await chmod(directory, 0o700);
37
+ for (const entry of await readdir(directory, { withFileTypes: true })) {
38
+ const path = join(directory, entry.name);
39
+ if (entry.isDirectory())
40
+ await protect(path);
41
+ else if (entry.isFile())
42
+ await chmod(path, 0o600);
43
+ else
44
+ throw new OperatorError("Controller state must not contain symlinks or special files.");
45
+ }
46
+ }
47
+ async function verifyExecutable(path) {
48
+ const { stdout } = await execute(path, ["--version"], { timeout: 10000 });
49
+ if (!stdout.trim().split(/\s+/).includes(release.version))
50
+ throw new OperatorError(`Use the pinned OpenShell ${release.version} executable.`);
51
+ }
52
+ function toml(directory, settings) {
53
+ const path = (value) => JSON.stringify(join(directory, "tls", value));
54
+ return `[openshell]
55
+ version = 1
56
+ [openshell.gateway]
57
+ name = ${JSON.stringify(settings.name)}
58
+ bind_address = "0.0.0.0:${String(settings.port)}"
59
+ compute_drivers = ["docker"]
60
+ disable_tls = false
61
+ [openshell.gateway.tls]
62
+ cert_path = ${path("server/tls.crt")}
63
+ key_path = ${path("server/tls.key")}
64
+ client_ca_path = ${path("ca.crt")}
65
+ require_client_auth = true
66
+ [openshell.gateway.mtls_auth]
67
+ enabled = true
68
+ [openshell.gateway.auth]
69
+ allow_unauthenticated_users = false
70
+ [openshell.gateway.gateway_jwt]
71
+ signing_key_path = ${path("jwt/signing.pem")}
72
+ public_key_path = ${path("jwt/public.pem")}
73
+ kid_path = ${path("jwt/kid")}
74
+ gateway_id = ${JSON.stringify(settings.name)}
75
+ ttl_secs = 0
76
+ [openshell.drivers.docker]
77
+ network_name = ${JSON.stringify(settings.name)}
78
+ sandbox_namespace = ${JSON.stringify(settings.name)}
79
+ supervisor_image = ${JSON.stringify(release.supervisorImage)}
80
+ image_pull_policy = "IfNotPresent"
81
+ enable_bind_mounts = false
82
+ grpc_endpoint = "https://host.openshell.internal:${String(settings.port)}"
83
+ guest_tls_ca = ${path("ca.crt")}
84
+ guest_tls_cert = ${path("client/tls.crt")}
85
+ guest_tls_key = ${path("client/tls.key")}
86
+ `;
87
+ }
88
+ const command = new Command().name("clawscarf-controller");
89
+ command
90
+ .command("init")
91
+ .requiredOption("--directory <path>", "New private controller directory")
92
+ .requiredOption("--gateway <path>", "Verified openshell-gateway executable")
93
+ .requiredOption("--cli <path>", "Verified openshell executable")
94
+ .option("--name <name>", "Isolated controller name", "clawscarf")
95
+ .option("--port <port>", "Loopback controller port", "17671")
96
+ .action(async (options) => {
97
+ const directory = resolve(options.directory);
98
+ const env = environment(directory);
99
+ const settings = settingsSchema.parse({
100
+ name: options.name,
101
+ port: Number(options.port),
102
+ gateway: resolve(options.gateway),
103
+ cli: resolve(options.cli),
104
+ });
105
+ await verifyExecutable(settings.gateway);
106
+ await verifyExecutable(settings.cli);
107
+ await mkdir(directory, { mode: 0o700 });
108
+ await execute(settings.gateway, [
109
+ "generate-certs",
110
+ "--output-dir",
111
+ join(directory, "tls"),
112
+ "--server-san",
113
+ "127.0.0.1",
114
+ "--server-san",
115
+ "localhost",
116
+ "--server-san",
117
+ "host.openshell.internal",
118
+ ], { timeout: 30000 });
119
+ const mtls = join(directory, "config/openshell/gateways", settings.name, "mtls");
120
+ await mkdir(mtls, { recursive: true, mode: 0o700 });
121
+ for (const [source, destination] of [
122
+ ["ca.crt", "ca.crt"],
123
+ ["client/tls.crt", "tls.crt"],
124
+ ["client/tls.key", "tls.key"],
125
+ ]) {
126
+ if (!source || !destination)
127
+ throw new OperatorError("Invalid certificate mapping.");
128
+ await copyFile(join(directory, "tls", source), join(mtls, destination));
129
+ }
130
+ await writeFile(join(directory, "gateway.toml"), toml(directory, settings), { flag: "wx", mode: 0o600 });
131
+ await writeFile(join(directory, "controller.json"), JSON.stringify(settings, null, 2) + "\n", { flag: "wx", mode: 0o600 });
132
+ await execute(settings.cli, [
133
+ "gateway",
134
+ "add",
135
+ `https://127.0.0.1:${String(settings.port)}`,
136
+ "--name",
137
+ settings.name,
138
+ "--local",
139
+ ], { env, timeout: 15000 });
140
+ await protect(directory);
141
+ process.stdout.write("Controller initialized. Keep this directory private and persistent.\n");
142
+ });
143
+ await command.parseAsync();
@@ -0,0 +1,81 @@
1
+ import { join } from "node:path";
2
+ import { browserNodeName, } from "./browser-node.js";
3
+ import { resourceNames } from "./state.js";
4
+ export function browserNodeServices(state, directory, browserAddress, prepared) {
5
+ const input = state.input.browser;
6
+ if (!input || !state.input.relayImage)
7
+ throw Error("Browser images are required.");
8
+ const { addresses, fingerprint } = prepared;
9
+ const file = (name) => join(directory, "private", name);
10
+ const constrained = {
11
+ read_only: true,
12
+ cap_drop: ["ALL"],
13
+ security_opt: ["no-new-privileges:true"],
14
+ init: true,
15
+ labels: { "clawscarf.installation": state.ownerId },
16
+ };
17
+ return {
18
+ "browser-node": {
19
+ ...constrained,
20
+ image: input.nodeImage,
21
+ user: "1000:1000",
22
+ networks: { machine: { ipv4_address: addresses.node }, browser: {} },
23
+ extra_hosts: [`chromium:${browserAddress}`],
24
+ environment: {
25
+ CLAWSCARF_BROWSER_NODE_GATEWAY_URL: `wss://${addresses.ingress}:18803`,
26
+ CLAWSCARF_BROWSER_NODE_TLS_FINGERPRINT: fingerprint,
27
+ CLAWSCARF_BROWSER_NODE_NAME: browserNodeName(state),
28
+ },
29
+ volumes: [
30
+ "browser-node:/state",
31
+ "browser-node-config:/configuration:ro",
32
+ `${file("browser-node-resolv.conf")}:/etc/resolv.conf:ro`,
33
+ ],
34
+ tmpfs: ["/tmp:rw,noexec,nosuid,nodev,size=128m"],
35
+ mem_limit: "1g",
36
+ pids_limit: 128,
37
+ depends_on: ["browser", "browser-node-ingress", "browser-node-dns"],
38
+ },
39
+ "browser-node-ingress": {
40
+ ...constrained,
41
+ image: state.input.relayImage,
42
+ user: String(process.getuid?.() ?? 1000),
43
+ networks: { machine: { ipv4_address: addresses.ingress }, default: {} },
44
+ extra_hosts: ["host.docker.internal:host-gateway"],
45
+ environment: {
46
+ CLAWSCARF_NODE_INGRESS_ADDRESS: addresses.ingress,
47
+ CLAWSCARF_NODE_GATEWAY_PORT: String(state.input.ports.native),
48
+ },
49
+ volumes: [
50
+ `${file("browser-node.pem")}:/run/clawscarf/node-ingress.pem:ro`,
51
+ `${file("browser-node-ingress.cfg")}:/usr/local/etc/haproxy/haproxy.cfg:ro`,
52
+ ],
53
+ mem_limit: "64m",
54
+ pids_limit: 64,
55
+ },
56
+ "browser-node-dns": {
57
+ ...constrained,
58
+ image: input.dnsImage,
59
+ networks: { machine: { ipv4_address: addresses.dns }, default: {} },
60
+ sysctls: {
61
+ "net.ipv4.ip_unprivileged_port_start": "53",
62
+ "net.ipv4.ip_forward": "0",
63
+ },
64
+ volumes: [
65
+ `${file("browser-node-dns.conf")}:/etc/unbound/deployment.conf:ro`,
66
+ ],
67
+ mem_limit: "128m",
68
+ pids_limit: 32,
69
+ },
70
+ };
71
+ }
72
+ export function browserNodeVolumes(state) {
73
+ const names = resourceNames(state);
74
+ return {
75
+ "browser-node": { external: true, name: names.browserNodeVolume },
76
+ "browser-node-config": {
77
+ external: true,
78
+ name: names.browserNodeConfigVolume,
79
+ },
80
+ };
81
+ }
@@ -0,0 +1,9 @@
1
+ import { readFile } from "node:fs/promises";
2
+ import { stripTypeScriptTypes } from "node:module";
3
+ /** Execute the same checked helper from source or a built operator. */
4
+ export async function browserOperatorSource() {
5
+ const source = import.meta.url.endsWith(".ts");
6
+ const text = await readFile(new URL(`../../deploy/execution/browser-node/operator.${source ? "ts" : "js"}`, import.meta.url), "utf8");
7
+ return ((source ? stripTypeScriptTypes(text) : text) +
8
+ "\nawait runBrowserOperator();\n");
9
+ }
@@ -0,0 +1,165 @@
1
+ import { readFile, lstat } from "node:fs/promises";
2
+ import { join } from "node:path";
3
+ import { setTimeout as delay } from "node:timers/promises";
4
+ import { z } from "zod";
5
+ import { browserNodeName, pairedBrowserSchema } from "./browser-node.js";
6
+ import { browserOperatorSource } from "./browser-node-helper.js";
7
+ import { observedBrowserMachineAddresses } from "./networks.js";
8
+ import { compose } from "./compose.js";
9
+ import { ensurePrivateFile, resourceNames } from "./state.js";
10
+ import { LocalSetupError, run } from "./process.js";
11
+ import { verifyRuntimeBinding } from "./runtime-binding.js";
12
+ export const nodeObservationSchema = z.array(z.strictObject({
13
+ nodeId: z.string(),
14
+ admitted: z.boolean(),
15
+ connectedAt: z.number().nullable(),
16
+ disconnectedAt: z.number().nullable(),
17
+ }));
18
+ export function browserNodeReady(value, startedAt) {
19
+ return (value.admitted &&
20
+ value.connectedAt !== null &&
21
+ value.connectedAt >= startedAt &&
22
+ (value.disconnectedAt === null || value.disconnectedAt < value.connectedAt));
23
+ }
24
+ async function receipt(directory, state) {
25
+ try {
26
+ const path = join(directory, "browser-node-paired.json");
27
+ const stat = await lstat(path);
28
+ if (!stat.isFile() ||
29
+ stat.nlink !== 1 ||
30
+ stat.uid !== process.getuid?.() ||
31
+ (stat.mode & 0o077) !== 0)
32
+ throw Error("Unsafe receipt");
33
+ const value = pairedBrowserSchema.parse(JSON.parse(await readFile(path, "utf8")));
34
+ if (value.ownerId !== state.ownerId)
35
+ throw Error("Owner differs");
36
+ return value;
37
+ }
38
+ catch (error) {
39
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
40
+ return undefined;
41
+ throw new LocalSetupError("configuration_changed", "Browser pairing receipt is unsafe or belongs to another installation.");
42
+ }
43
+ }
44
+ /** Local SDK issuance needs no user session. A recorded attempt is never silently repeated. */
45
+ export async function startBrowserNode(directory, state, signal) {
46
+ const browser = state.input.browser;
47
+ if (!browser)
48
+ return;
49
+ const target = z
50
+ .object({
51
+ id: z.uuid(),
52
+ name: z.string(),
53
+ ownerId: z.literal(state.ownerId),
54
+ })
55
+ .parse(JSON.parse(await readFile(join(directory, "runtime.json"), "utf8")));
56
+ const binding = await verifyRuntimeBinding(state, target);
57
+ const source = await browserOperatorSource();
58
+ const native = async (input) => JSON.parse(await run("docker", [
59
+ "exec",
60
+ "-i",
61
+ "--user",
62
+ "1000",
63
+ "-e",
64
+ "HOME=/home/node",
65
+ "-e",
66
+ "SQLITE_TMPDIR=/tmp",
67
+ "--workdir",
68
+ "/app",
69
+ binding.containerId,
70
+ "node",
71
+ "--input-type=module",
72
+ "-e",
73
+ source,
74
+ ], { input: JSON.stringify(input) }));
75
+ const config = async (input) => run("docker", [
76
+ "run",
77
+ "--rm",
78
+ "-i",
79
+ "--network",
80
+ "none",
81
+ "--user",
82
+ "0",
83
+ "--workdir",
84
+ "/app",
85
+ "--mount",
86
+ `type=volume,src=${resourceNames(state).browserNodeConfigVolume},dst=/configuration`,
87
+ "--entrypoint",
88
+ "node",
89
+ browser.nodeImage,
90
+ "--input-type=module",
91
+ "-e",
92
+ source,
93
+ ], { input: JSON.stringify(input) });
94
+ const saved = await receipt(directory, state);
95
+ const observe = async () => nodeObservationSchema.parse(await native({
96
+ command: "observe",
97
+ name: browserNodeName(state),
98
+ ...(saved ? { nodeId: saved.nodeId } : {}),
99
+ }));
100
+ if (saved) {
101
+ const observed = await observe();
102
+ if (observed.length !== 1 || !observed[0]?.admitted)
103
+ throw new LocalSetupError("browser_unavailable", "The browser node's native admission was removed or changed. Startup will not re-enroll it.");
104
+ }
105
+ else {
106
+ const intent = join(directory, "browser-node-pairing-started.json");
107
+ try {
108
+ await lstat(intent);
109
+ throw new LocalSetupError("bootstrap_outcome_unknown", "Browser pairing was attempted without confirmed completion. Inspect native pairing and retained state; it will not be repeated.");
110
+ }
111
+ catch (error) {
112
+ if (!(error instanceof Error &&
113
+ "code" in error &&
114
+ error.code === "ENOENT"))
115
+ throw error;
116
+ }
117
+ if ((await observe()).length)
118
+ throw new LocalSetupError("configuration_changed", "A browser node already has the prepared name. Inspect its native identity before proceeding.");
119
+ const addresses = await observedBrowserMachineAddresses(directory, state);
120
+ if (!addresses)
121
+ throw new LocalSetupError("network_unprepared", "Browser network is not prepared.");
122
+ signal.throwIfAborted();
123
+ await ensurePrivateFile(intent, JSON.stringify({ ownerId: state.ownerId }));
124
+ const setup = z.object({ setupCode: z.string().min(1) }).parse(await native({
125
+ command: "issue",
126
+ gatewayUrl: `wss://${addresses.ingress}:18803`,
127
+ }));
128
+ await config({
129
+ command: "write-code",
130
+ ownerId: state.ownerId,
131
+ setupCode: setup.setupCode,
132
+ });
133
+ }
134
+ if (saved)
135
+ await config({ command: "remove-code", ownerId: state.ownerId });
136
+ signal.throwIfAborted();
137
+ await compose(directory, ["up", "-d", "browser-node"]);
138
+ const id = (await compose(directory, ["ps", "-q", "browser-node"])).trim();
139
+ z.string()
140
+ .regex(/^[a-f0-9]{12,64}$/)
141
+ .parse(id);
142
+ const startedAt = z.coerce
143
+ .date()
144
+ .parse((await run("docker", ["inspect", "--format", "{{.State.StartedAt}}", id])).trim())
145
+ .getTime();
146
+ const deadline = Date.now() + 90000;
147
+ while (Date.now() < deadline) {
148
+ signal.throwIfAborted();
149
+ if ((await run("docker", ["inspect", "--format", "{{.State.Running}}", id])).trim() !== "true")
150
+ throw new LocalSetupError("browser_unavailable", "The browser node stopped during startup. Inspect its container logs; pairing was not repeated.");
151
+ const values = await observe();
152
+ if (values.length > 1)
153
+ throw new LocalSetupError("configuration_changed", "The browser node name is ambiguous.");
154
+ const value = values[0];
155
+ if (value && browserNodeReady(value, startedAt)) {
156
+ if (!saved) {
157
+ await ensurePrivateFile(join(directory, "browser-node-paired.json"), JSON.stringify({ ownerId: state.ownerId, nodeId: value.nodeId }));
158
+ await config({ command: "remove-code", ownerId: state.ownerId });
159
+ }
160
+ return;
161
+ }
162
+ await delay(1000, undefined, { signal });
163
+ }
164
+ throw new LocalSetupError("browser_unavailable", "The browser node did not establish its admitted native connection before the deadline. State was retained; enrollment will not be repeated.");
165
+ }
@@ -0,0 +1,154 @@
1
+ import { browserOperatorSource } from "./browser-node-helper.js";
2
+ import { createPrivateKey, X509Certificate } from "node:crypto";
3
+ import { chmod, lstat, mkdir, readFile, rm, writeFile } from "node:fs/promises";
4
+ import { join } from "node:path";
5
+ import { z } from "zod";
6
+ import { browserNodeConfiguration } from "../../deploy/execution/browser-node/configuration.js";
7
+ import { observedBrowserMachineAddresses } from "./networks.js";
8
+ import { ensureOwnedVolume } from "./volumes.js";
9
+ import { ensurePrivateFile, resourceNames } from "./state.js";
10
+ import { LocalSetupError, run } from "./process.js";
11
+ export const browserNodeName = (state) => `${resourceNames(state).project}-browser`;
12
+ async function certificate(directory, address, create) {
13
+ const path = join(directory, "private/browser-node.pem");
14
+ try {
15
+ const info = await lstat(path);
16
+ if (!info.isFile() ||
17
+ info.nlink !== 1 ||
18
+ info.uid !== process.getuid?.() ||
19
+ (info.mode & 0o777) !== 0o600)
20
+ throw Error("unsafe");
21
+ }
22
+ catch (error) {
23
+ if (!(create &&
24
+ error instanceof Error &&
25
+ "code" in error &&
26
+ error.code === "ENOENT"))
27
+ throw new LocalSetupError("invalid_certificate", "Browser node TLS material is missing or unsafe.");
28
+ const staging = join(directory, "private/browser-node-tls");
29
+ await mkdir(staging, { mode: 0o700 });
30
+ await run("openssl", [
31
+ "req",
32
+ "-x509",
33
+ "-newkey",
34
+ "rsa:3072",
35
+ "-nodes",
36
+ "-days",
37
+ "365",
38
+ "-subj",
39
+ "/CN=ClawScarf browser node",
40
+ "-addext",
41
+ `subjectAltName=IP:${address}`,
42
+ "-keyout",
43
+ join(staging, "key.pem"),
44
+ "-out",
45
+ join(staging, "cert.pem"),
46
+ ]);
47
+ await ensurePrivateFile(path, Buffer.concat([
48
+ await readFile(join(staging, "key.pem")),
49
+ await readFile(join(staging, "cert.pem")),
50
+ ]));
51
+ await rm(staging, { recursive: true });
52
+ }
53
+ try {
54
+ const pem = await readFile(path);
55
+ const cert = new X509Certificate(pem);
56
+ if (!cert.checkPrivateKey(createPrivateKey(pem)) ||
57
+ !cert.checkIP(address) ||
58
+ Date.parse(cert.validFrom) > Date.now() ||
59
+ Date.parse(cert.validTo) <= Date.now())
60
+ throw Error("invalid");
61
+ return cert.fingerprint256;
62
+ }
63
+ catch {
64
+ throw new LocalSetupError("invalid_certificate", "The private browser node certificate is invalid or expired; it was not replaced.");
65
+ }
66
+ }
67
+ export function browserNodeFiles(addresses) {
68
+ return {
69
+ "browser-node-resolv.conf": `nameserver ${addresses.dns}\noptions timeout:2 attempts:1\n`,
70
+ "browser-node-dns.conf": `server:\n interface: ${addresses.dns}@53\n access-control: ${addresses.node}/32 allow\nforward-zone:\n name: "."\n forward-first: no\n forward-addr: 1.1.1.1@53\n forward-addr: 1.0.0.1@53\n`,
71
+ };
72
+ }
73
+ async function materialize(directory, addresses, create) {
74
+ const files = {
75
+ ...browserNodeFiles(addresses),
76
+ "browser-node-ingress.cfg": await readFile(new URL("../../deploy/execution/network/node-ingress.cfg", import.meta.url), "utf8"),
77
+ };
78
+ for (const [name, content] of Object.entries(files)) {
79
+ const path = join(directory, "private", name);
80
+ try {
81
+ const info = await lstat(path);
82
+ if (!info.isFile() ||
83
+ info.nlink !== 1 ||
84
+ info.uid !== process.getuid?.() ||
85
+ (info.mode & 0o777) !== 0o444 ||
86
+ (await readFile(path, "utf8")) !== content)
87
+ throw Error("changed");
88
+ }
89
+ catch (error) {
90
+ if (!(create &&
91
+ error instanceof Error &&
92
+ "code" in error &&
93
+ error.code === "ENOENT"))
94
+ throw new LocalSetupError("configuration_changed", "Private browser node network configuration differs from its prepared inputs.");
95
+ await writeFile(path, content, { flag: "wx", mode: 0o444 });
96
+ await chmod(path, 0o444);
97
+ }
98
+ }
99
+ }
100
+ export async function prepareBrowserNode(directory, state, token) {
101
+ const browser = state.input.browser;
102
+ if (!browser)
103
+ return undefined;
104
+ const addresses = await observedBrowserMachineAddresses(directory, state);
105
+ if (!addresses)
106
+ throw new LocalSetupError("network_unprepared", "The private browser node network is not prepared.");
107
+ const fingerprint = await certificate(directory, addresses.ingress, true);
108
+ await materialize(directory, addresses, true);
109
+ const names = resourceNames(state);
110
+ for (const name of [names.browserNodeVolume, names.browserNodeConfigVolume])
111
+ await ensureOwnedVolume(name, state.ownerId);
112
+ const configuration = browserNodeConfiguration(`http://openclaw:${token}@chromium:9223`);
113
+ await run("docker", [
114
+ "run",
115
+ "--rm",
116
+ "-i",
117
+ "--network",
118
+ "none",
119
+ "--user",
120
+ "0",
121
+ "--workdir",
122
+ "/app",
123
+ "--mount",
124
+ `type=volume,src=${names.browserNodeVolume},dst=/state,volume-nocopy`,
125
+ "--mount",
126
+ `type=volume,src=${names.browserNodeConfigVolume},dst=/configuration,volume-nocopy`,
127
+ "--entrypoint",
128
+ "node",
129
+ browser.nodeImage,
130
+ "--input-type=module",
131
+ "-e",
132
+ await browserOperatorSource(),
133
+ ], {
134
+ input: JSON.stringify({
135
+ command: "initialize",
136
+ ownerId: state.ownerId,
137
+ configuration,
138
+ }),
139
+ });
140
+ return { addresses, fingerprint };
141
+ }
142
+ export async function verifyBrowserNode(directory, state) {
143
+ if (!state.input.browser)
144
+ return;
145
+ const addresses = await observedBrowserMachineAddresses(directory, state);
146
+ if (!addresses)
147
+ throw new LocalSetupError("network_unprepared", "The private browser node network is not prepared.");
148
+ await certificate(directory, addresses.ingress, false);
149
+ await materialize(directory, addresses, false);
150
+ }
151
+ export const pairedBrowserSchema = z.strictObject({
152
+ ownerId: z.uuid(),
153
+ nodeId: z.string().min(1),
154
+ });