@clawscarf/cli 0.1.0-alpha.1 → 0.1.0-alpha.11

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 (64) hide show
  1. package/README.md +119 -2
  2. package/THIRD_PARTY_NOTICES.md +18 -11
  3. package/deploy/execution/network/node-ingress.cfg +1 -1
  4. package/deploy/execution/network/public-addresses.json +24 -0
  5. package/package.json +2 -1
  6. package/packs/README.md +4 -11
  7. package/pnpm-lock.yaml +212 -3567
  8. package/recipes/README.md +10 -6
  9. package/recipes/team-server/recipe.json +5 -4
  10. package/release/README.md +122 -58
  11. package/release/components.json +26 -2
  12. package/release/operator.md +27 -53
  13. package/release/telemetry.json +4 -0
  14. package/runtime/configuration.js +15 -1
  15. package/runtime/current.json +63 -0
  16. package/scripts/clawscarf.js +9 -1
  17. package/scripts/controller.js +6 -0
  18. package/scripts/deployment/browser-node-compose.js +0 -1
  19. package/scripts/deployment/certificates.js +2 -1
  20. package/scripts/deployment/compose.js +3 -5
  21. package/scripts/deployment/configuration.js +8 -7
  22. package/scripts/deployment/connection-settings.js +0 -4
  23. package/scripts/deployment/controller-compose.js +14 -6
  24. package/scripts/deployment/entrypoint.js +4 -1
  25. package/scripts/deployment/launch.js +12 -29
  26. package/scripts/deployment/model-gateway-compose.js +6 -0
  27. package/scripts/deployment/model-gateway.js +9 -1
  28. package/scripts/deployment/models.js +5 -1
  29. package/scripts/deployment/network-policy.js +147 -0
  30. package/scripts/deployment/networks.js +21 -0
  31. package/scripts/deployment/policy.js +27 -35
  32. package/scripts/deployment/prepare.js +22 -15
  33. package/scripts/deployment/public-addresses.js +30 -0
  34. package/scripts/deployment/public-web.js +132 -0
  35. package/scripts/deployment/runtime.js +15 -4
  36. package/scripts/deployment/service-network.js +86 -0
  37. package/scripts/deployment/state.js +16 -0
  38. package/scripts/installation/command.js +6 -14
  39. package/scripts/installation/configuration.js +2 -0
  40. package/scripts/installation/configure.js +1 -0
  41. package/scripts/installation/installer/cloud.js +1 -1
  42. package/scripts/installation/installer/collect.js +17 -0
  43. package/scripts/installation/installer/menu.js +3 -2
  44. package/scripts/installation/installer/prompts.js +6 -2
  45. package/scripts/installation/installer/run.js +21 -11
  46. package/scripts/installation/installer/sections/models.js +2 -12
  47. package/scripts/installation/installer/settings.js +10 -47
  48. package/scripts/installation/installer/summary.js +2 -0
  49. package/scripts/installation/options.js +11 -1
  50. package/scripts/installation/plan.js +4 -33
  51. package/scripts/installation/prerequisites.js +10 -5
  52. package/scripts/installation/recipes/definition.js +2 -0
  53. package/scripts/installation/reconfigure.js +69 -18
  54. package/scripts/installation/resolve.js +8 -5
  55. package/scripts/installation/runtime.js +3 -3
  56. package/scripts/installation/setup.js +2 -0
  57. package/scripts/release/create.js +8 -3
  58. package/scripts/release/definition.js +34 -3
  59. package/scripts/telemetry.js +279 -0
  60. package/runtime/releases/0.1.0-alpha.1.json +0 -37
  61. package/scripts/deployment/connection-policy.js +0 -80
  62. package/scripts/deployment/upgrade-rpc.py +0 -148
  63. package/scripts/deployment/upgrade-state.js +0 -47
  64. package/scripts/deployment/upgrade.js +0 -298
@@ -11,6 +11,7 @@ const settingsSchema = z.strictObject({
11
11
  port: z.number().int().min(1024).max(65535),
12
12
  gateway: z.string().min(1),
13
13
  cli: z.string().min(1),
14
+ hostGatewayIp: z.ipv4().optional(),
14
15
  });
15
16
  const releaseInput = JSON.parse(await readFile(new URL("../release/components.json", import.meta.url), "utf8"));
16
17
  const release = z
@@ -75,6 +76,7 @@ gateway_id = ${JSON.stringify(settings.name)}
75
76
  ttl_secs = 0
76
77
  [openshell.drivers.docker]
77
78
  network_name = ${JSON.stringify(settings.name)}
79
+ ${settings.hostGatewayIp ? `host_gateway_ip = ${JSON.stringify(settings.hostGatewayIp)}` : ""}
78
80
  sandbox_namespace = ${JSON.stringify(settings.name)}
79
81
  supervisor_image = ${JSON.stringify(release.supervisorImage)}
80
82
  image_pull_policy = "IfNotPresent"
@@ -91,6 +93,7 @@ command
91
93
  .requiredOption("--directory <path>", "New private controller directory")
92
94
  .requiredOption("--gateway <path>", "Verified openshell-gateway executable")
93
95
  .requiredOption("--cli <path>", "Verified openshell executable")
96
+ .option("--host-gateway-ip <address>", "Controller address on the runtime bridge")
94
97
  .option("--name <name>", "Isolated controller name", "clawscarf")
95
98
  .option("--port <port>", "Loopback controller port", "17671")
96
99
  .action(async (options) => {
@@ -101,6 +104,7 @@ command
101
104
  port: Number(options.port),
102
105
  gateway: resolve(options.gateway),
103
106
  cli: resolve(options.cli),
107
+ hostGatewayIp: options.hostGatewayIp,
104
108
  });
105
109
  await verifyExecutable(settings.gateway);
106
110
  await verifyExecutable(settings.cli);
@@ -115,6 +119,8 @@ command
115
119
  "localhost",
116
120
  "--server-san",
117
121
  "host.openshell.internal",
122
+ "--server-san",
123
+ "controller.clawscarf.internal",
118
124
  ], { timeout: 30000 });
119
125
  const mtls = join(directory, "config/openshell/gateways", settings.name, "mtls");
120
126
  await mkdir(mtls, { recursive: true, mode: 0o700 });
@@ -41,7 +41,6 @@ export function browserNodeServices(state, directory, browserAddress, prepared)
41
41
  image: state.input.relayImage,
42
42
  user: String(process.getuid?.() ?? 1000),
43
43
  networks: { machine: { ipv4_address: addresses.ingress }, default: {} },
44
- extra_hosts: ["host.docker.internal:host-gateway"],
45
44
  environment: {
46
45
  CLAWSCARF_NODE_INGRESS_ADDRESS: addresses.ingress,
47
46
  CLAWSCARF_NODE_GATEWAY_PORT: String(state.input.ports.native),
@@ -32,6 +32,7 @@ export async function ensureCertificates(directory) {
32
32
  const key = createPrivateKey(await readFile(join(directory, "management-key.pem")));
33
33
  if (!cert.checkPrivateKey(key) ||
34
34
  !cert.checkHost("host.docker.internal") ||
35
+ !cert.checkHost("companion") ||
35
36
  !cert.checkIP("127.0.0.1") ||
36
37
  !cert.raw.equals(ca.raw) ||
37
38
  !cert.ca ||
@@ -64,7 +65,7 @@ export async function ensureCertificates(directory) {
64
65
  "-subj",
65
66
  "/CN=ClawScarf local management",
66
67
  "-addext",
67
- "subjectAltName=DNS:localhost,DNS:host.docker.internal,IP:127.0.0.1",
68
+ "subjectAltName=DNS:localhost,DNS:companion,DNS:host.docker.internal,DNS:models.clawscarf.internal,IP:127.0.0.1",
68
69
  "-addext",
69
70
  "basicConstraints=critical,CA:TRUE",
70
71
  ]);
@@ -6,7 +6,7 @@ import { resourceNames } from "./state.js";
6
6
  import { run } from "./process.js";
7
7
  import { modelGatewayServices } from "./model-gateway-compose.js";
8
8
  import { postgresImage } from "./images.js";
9
- export function composeConfiguration(state, directory, browserAddress, browserMachine) {
9
+ export function composeConfiguration(state, directory, controllerAddress, browserAddress, browserMachine, controllerSocketGroup = 0) {
10
10
  const { input, ownerId } = state;
11
11
  const names = resourceNames(state);
12
12
  const privateDirectory = join(directory, "private");
@@ -23,7 +23,7 @@ export function composeConfiguration(state, directory, browserAddress, browserMa
23
23
  const configuration = {
24
24
  name: names.project,
25
25
  services: {
26
- ...controllerServices(directory, state),
26
+ ...controllerServices(directory, state, controllerAddress, controllerSocketGroup),
27
27
  ...modelGatewayServices(directory, state),
28
28
  ...(input.browser && browserAddress && browserMachine
29
29
  ? browserNodeServices(state, directory, browserAddress, browserMachine)
@@ -153,9 +153,7 @@ export function composeConfiguration(state, directory, browserAddress, browserMa
153
153
  },
154
154
  },
155
155
  networks: {
156
- ...(input.relayImage
157
- ? { runtime: { external: true, name: names.sandbox } }
158
- : {}),
156
+ runtime: { external: true, name: names.sandbox },
159
157
  default: { external: true, name: `${names.project}_default` },
160
158
  ...(input.browser
161
159
  ? {
@@ -1,7 +1,7 @@
1
1
  import { OperatorError } from "../errors.js";
2
2
  import { isAbsolute, join, resolve } from "node:path";
3
3
  import { z } from "zod";
4
- import { initialConfiguration } from "../../runtime/configuration.js";
4
+ import { initialConfiguration, configurationInput, } from "../../runtime/configuration.js";
5
5
  import { networkRequirementSchema } from "../packs/policy.js";
6
6
  const absolutePath = z
7
7
  .string()
@@ -105,6 +105,7 @@ const teamInput = z
105
105
  export const localInput = z
106
106
  .strictObject({
107
107
  team: teamInput,
108
+ agentName: configurationInput.shape.agentName,
108
109
  name: z
109
110
  .string()
110
111
  .max(30)
@@ -139,6 +140,7 @@ export const localInput = z
139
140
  .optional(),
140
141
  cpu,
141
142
  memory,
143
+ publicWeb: z.boolean().default(false),
142
144
  relayImage: image.optional(),
143
145
  browser: z
144
146
  .strictObject({
@@ -187,9 +189,6 @@ export const localInput = z
187
189
  export function parseLocalInput(value) {
188
190
  return localInput.parse(value);
189
191
  }
190
- export function managementOrigin(input) {
191
- return `https://host.docker.internal:${String(input.ports.management)}`;
192
- }
193
192
  function mountedPrivateFile(directory, path, name) {
194
193
  absolutePath.parse(path);
195
194
  if (resolve(path) !== join(directory, name))
@@ -224,10 +223,11 @@ export function generateLocalConfiguration(options) {
224
223
  port: 18801,
225
224
  },
226
225
  runtime: {
227
- origin: `http://host.docker.internal:${String(input.ports.native)}`,
228
- managementOrigin: managementOrigin(input),
226
+ origin: `http://application:${String(input.ports.native)}`,
227
+ // Native proxy attribution needs the real Docker peer, not a loopback client.
228
+ managementOrigin: "https://companion:18801",
229
229
  widgetOrigin,
230
- widgetUpstream: `http://host.docker.internal:${String(input.ports.nativeWidgets)}`,
230
+ widgetUpstream: `http://widgets:${String(input.ports.nativeWidgets)}`,
231
231
  },
232
232
  ...(input.team.certificateFile
233
233
  ? {
@@ -247,6 +247,7 @@ export function generateLocalConfiguration(options) {
247
247
  },
248
248
  };
249
249
  const native = initialConfiguration({
250
+ agentName: input.agentName,
250
251
  publicOrigin,
251
252
  widgetOrigin,
252
253
  administratorIdentity: identity,
@@ -5,7 +5,6 @@ import { companionConfiguration } from "./configuration.js";
5
5
  import { loadInitialConnections, prepareInitialConnections, readInitialConnectionToken, } from "./connections.js";
6
6
  import { configureConnectionsVolume } from "./connections-runtime.js";
7
7
  import { writePrivate } from "./state.js";
8
- import { initialRuntimePolicy } from "./policy.js";
9
8
  /** Explicit capability edit, with the installation stopped and its operator lock held. */
10
9
  export async function applyConnectionSettings(directory, state, previous, credentialFile) {
11
10
  const loaded = await loadInitialConnections(state.input.connections);
@@ -54,7 +53,4 @@ export async function applyConnectionSettings(directory, state, previous, creden
54
53
  if (loaded?.managementKey)
55
54
  config.services.companion.volumes.push(mount);
56
55
  await writePrivate(file, JSON.stringify(config));
57
- const rule = initialRuntimePolicy("network_policies: {}", undefined, endpoint)
58
- .network_policies.connections_broker ?? null;
59
- await writePrivate(join(directory, "private/connection-policy-change.json"), JSON.stringify({ ownerId: state.ownerId, rule }));
60
56
  }
@@ -2,13 +2,14 @@ import { join } from "node:path";
2
2
  import { openshellGatewayImage } from "./images.js";
3
3
  import { resourceNames } from "./state.js";
4
4
  /** OpenShell alone receives Docker authority. Forwarders receive only client credentials. */
5
- export function controllerServices(directory, state) {
5
+ export function controllerServices(directory, state, address, socketGroup) {
6
6
  const controller = join(directory, "controller");
7
7
  const { input } = state;
8
8
  const name = resourceNames(state).sandbox;
9
9
  const port = input.ports.controller;
10
10
  const forward = (target, port) => ({
11
11
  image: input.openshellClientImage,
12
+ user: `${String(process.getuid?.() ?? 1000)}:${String(process.getgid?.() ?? 1000)}`,
12
13
  init: true,
13
14
  read_only: true,
14
15
  cap_drop: ["ALL"],
@@ -21,23 +22,31 @@ export function controllerServices(directory, state) {
21
22
  XDG_DATA_HOME: "/tmp/data",
22
23
  },
23
24
  volumes: [`${join(controller, "config")}:/controller/config:ro`],
24
- extra_hosts: ["host.openshell.internal:host-gateway"],
25
25
  command: [
26
26
  "forward",
27
- "start",
27
+ "service",
28
+ "--target-port",
29
+ String(port),
30
+ "--local",
28
31
  `0.0.0.0:${String(port)}`,
29
32
  target,
30
33
  "--gateway",
31
34
  name,
32
35
  "--gateway-endpoint",
33
- `https://host.openshell.internal:${String(input.ports.controller)}`,
36
+ `https://controller.clawscarf.internal:${String(input.ports.controller)}`,
34
37
  ],
35
38
  ports: [`127.0.0.1:${String(port)}:${String(port)}`],
36
39
  });
37
40
  return {
38
41
  controller: {
39
42
  image: openshellGatewayImage,
40
- user: "0:0",
43
+ user: `${String(process.getuid?.() ?? 1000)}:${String(process.getgid?.() ?? 1000)}`,
44
+ // Desktop exposes a root-group socket; Linux Engine uses the host socket's group.
45
+ group_add: [...new Set(["0", String(socketGroup)])],
46
+ networks: {
47
+ default: { aliases: ["controller.clawscarf.internal"] },
48
+ runtime: { ipv4_address: address },
49
+ },
41
50
  command: ["--config", join(controller, "gateway.toml")],
42
51
  environment: {
43
52
  XDG_CONFIG_HOME: join(controller, "config"),
@@ -51,7 +60,6 @@ export function controllerServices(directory, state) {
51
60
  "/var/run/docker.sock:/var/run/docker.sock",
52
61
  ],
53
62
  ports: [`127.0.0.1:${String(port)}:${String(port)}`],
54
- extra_hosts: ["host.openshell.internal:host-gateway"],
55
63
  },
56
64
  application: forward(name, input.ports.native),
57
65
  widgets: forward(name, input.ports.nativeWidgets),
@@ -3,5 +3,8 @@ import { fileURLToPath } from "node:url";
3
3
  export function nodeEntrypoint(relative) {
4
4
  const source = import.meta.url.endsWith(".ts");
5
5
  const file = new URL(relative + (source ? ".ts" : ".js"), import.meta.url);
6
- return [...(source ? ["--import", "tsx"] : []), fileURLToPath(file)];
6
+ return [
7
+ ...(source ? ["--import", import.meta.resolve("tsx")] : []),
8
+ fileURLToPath(file),
9
+ ];
7
10
  }
@@ -1,42 +1,29 @@
1
- import { applyConnectionPolicy } from "./connection-policy.js";
1
+ import { verifyServiceRoutes } from "./service-network.js";
2
+ import { applyNetworkPolicy } from "./network-policy.js";
2
3
  import { verifyRuntimeImage } from "./images.js";
3
4
  import { startBrowserNode } from "./browser-node-pairing.js";
4
5
  import { verifyBrowserNode } from "./browser-node.js";
5
6
  import { verifyRelayConfiguration } from "./relay.js";
6
7
  import { verifyBrowserListener, verifyBrowserConfiguration, } from "./browser.js";
7
8
  import { probeTeamAccess } from "./team.js";
8
- import { readFile } from "node:fs/promises";
9
- import { join, resolve } from "node:path";
9
+ import { resolve } from "node:path";
10
10
  import { setTimeout as delay } from "node:timers/promises";
11
- import { z } from "zod";
12
- import { readState, resourceNames } from "./state.js";
11
+ import { readState, resourceNames, requirePrepared } from "./state.js";
13
12
  import { verifyLocalNetworks } from "./networks.js";
14
13
  import { compose } from "./compose.js";
15
- import { ensureRuntime, stopRuntime } from "./runtime.js";
14
+ import { ensureRuntime, stopRuntime, runtimeEnvironment } from "./runtime.js";
16
15
  import { LocalSetupError, run } from "./process.js";
17
16
  import { verifyLocalExecutables, verifyLocalPorts } from "./preflight.js";
18
17
  import { verifyRuntimeBinding } from "./runtime-binding.js";
19
- import { requireNoUpgrade } from "./upgrade-state.js";
20
18
  /** Internal operation: the caller holds the installation lock for its full lifetime. */
21
19
  export async function launchLocal(directoryInput, report, control = {}) {
22
20
  const directory = resolve(directoryInput);
23
21
  const state = await readState(directory);
24
- z.strictObject({
25
- ownerId: z.literal(state.ownerId),
26
- settingsCandidate: z.string().optional(),
27
- settingsReapply: z.enum(["models", "connections"]).optional(),
28
- }).parse(JSON.parse(await readFile(join(directory, "prepared.json"), "utf8")));
22
+ await requirePrepared(directory);
29
23
  if (Object.keys(process.env).some((key) => key.startsWith("OPENSHELL_")))
30
24
  throw new LocalSetupError("configuration_changed", "Unset OPENSHELL_* overrides before starting this installation.");
31
- const controller = join(directory, "controller");
32
- const env = {
33
- ...process.env,
34
- XDG_CONFIG_HOME: join(controller, "config"),
35
- XDG_STATE_HOME: join(controller, "state"),
36
- XDG_DATA_HOME: join(controller, "data"),
37
- };
25
+ const env = runtimeEnvironment(directory);
38
26
  const name = resourceNames(state).sandbox;
39
- await requireNoUpgrade(directory);
40
27
  await verifyRuntimeImage(state.input.runtimeImage);
41
28
  await verifyLocalExecutables(state);
42
29
  await verifyLocalPorts(state);
@@ -83,7 +70,7 @@ export async function launchLocal(directoryInput, report, control = {}) {
83
70
  await waitFor(async () => {
84
71
  await run(state.input.openshellCli, ["sandbox", "list", "--gateway", name, "--output", "json"], { env, timeout: 5000 });
85
72
  });
86
- await applyConnectionPolicy(directory, state, env);
73
+ await applyNetworkPolicy(directory, state, env);
87
74
  if (state.input.modelGateway) {
88
75
  report("Starting model gateway…");
89
76
  await compose(directory, [
@@ -113,7 +100,9 @@ export async function launchLocal(directoryInput, report, control = {}) {
113
100
  await response.body?.cancel();
114
101
  });
115
102
  await verifyRuntimeBinding(state, runtime);
116
- await applyConnectionPolicy(directory, state, env, true);
103
+ await waitFor(() => applyNetworkPolicy(directory, state, env, true));
104
+ report("Checking runtime service routes…");
105
+ await verifyServiceRoutes(state, env);
117
106
  if (state.input.browser) {
118
107
  report("Starting native browser node…");
119
108
  await startBrowserNode(directory, state, cancellation.signal);
@@ -135,13 +124,7 @@ export async function launchLocal(directoryInput, report, control = {}) {
135
124
  /** Stop entry first, then the owned runtime, and only then its controller. */
136
125
  export async function stopLocal(directory) {
137
126
  const state = await readState(directory);
138
- const controller = join(directory, "controller");
139
- const env = {
140
- ...process.env,
141
- XDG_CONFIG_HOME: join(controller, "config"),
142
- XDG_STATE_HOME: join(controller, "state"),
143
- XDG_DATA_HOME: join(controller, "data"),
144
- };
127
+ const env = runtimeEnvironment(directory);
145
128
  await compose(directory, [
146
129
  "stop",
147
130
  "companion",
@@ -28,6 +28,12 @@ export function modelGatewayServices(directory, state) {
28
28
  },
29
29
  models: {
30
30
  image: input.image,
31
+ user: `${String(process.getuid?.() ?? 1000)}:${String(process.getgid?.() ?? 1000)}`,
32
+ environment: { HOME: "/tmp" },
33
+ networks: {
34
+ default: {},
35
+ runtime: { aliases: ["models.clawscarf.internal"] },
36
+ },
31
37
  init: true,
32
38
  read_only: true,
33
39
  cap_drop: ["ALL"],
@@ -43,6 +43,14 @@ export async function prepareModelGateway(directory, state, options = {}) {
43
43
  return;
44
44
  const loaded = await loadGatewayConfiguration(input.configurationFile, input.upstreamEnvironmentFile);
45
45
  const root = join(directory, "private/models");
46
+ let baseUrl = "https://models.clawscarf.internal:4000/v1";
47
+ if (options.replaceConfiguration) {
48
+ const current = configurationSchema.parse(JSON.parse(await readFile(join(root, "native.json"), "utf8")));
49
+ if (current.mode !== "litellm")
50
+ throw new LocalSetupError("invalid_model_setup", "Expected an existing model gateway.");
51
+ // Model choices do not change the installation's retained network endpoint.
52
+ baseUrl = current.baseUrl;
53
+ }
46
54
  await mkdir(root, { recursive: true, mode: 0o700 });
47
55
  for (const [name, prefix] of [
48
56
  ["master-key", "sk-"],
@@ -74,7 +82,7 @@ export async function prepareModelGateway(directory, state, options = {}) {
74
82
  await publish(join(root, "models.json"), JSON.stringify(liteLlmConfiguration(loaded.configuration)));
75
83
  await publish(join(root, "native.json"), JSON.stringify({
76
84
  ...loaded.configuration,
77
- baseUrl: `https://host.docker.internal:${String(input.port)}/v1`,
85
+ baseUrl,
78
86
  }));
79
87
  }
80
88
  export async function prepareModelCredential(directory, state) {
@@ -70,7 +70,11 @@ export function withInitialModels(native, models) {
70
70
  },
71
71
  },
72
72
  agents: {
73
- defaults: nativeModelDefaults(models.configuration),
73
+ ...native.agents,
74
+ defaults: {
75
+ ...native.agents.defaults,
76
+ ...nativeModelDefaults(models.configuration),
77
+ },
74
78
  },
75
79
  };
76
80
  }
@@ -0,0 +1,147 @@
1
+ import { isDeepStrictEqual } from "node:util";
2
+ import { readFile, rm } from "node:fs/promises";
3
+ import { join } from "node:path";
4
+ import { z } from "zod";
5
+ import { run, LocalSetupError } from "./process.js";
6
+ import { resourceNames, writePrivate } from "./state.js";
7
+ import { composeNetworkRules, adjustmentsSchema, networkChangesSchema, } from "./public-web.js";
8
+ import { runtimeEnvironment, runtimeManager } from "./runtime.js";
9
+ const policySchema = z.looseObject({
10
+ network_policies: z.record(z.string(), z.unknown()),
11
+ });
12
+ const pendingSchema = z.strictObject({
13
+ ownerId: z.uuid(),
14
+ requested: networkChangesSchema,
15
+ changes: z.record(z.string(), z.strictObject({ before: z.unknown(), after: z.unknown() })),
16
+ adjustments: adjustmentsSchema,
17
+ });
18
+ async function optionalJson(path) {
19
+ try {
20
+ return JSON.parse(await readFile(path, "utf8"));
21
+ }
22
+ catch (error) {
23
+ if (error instanceof Error && "code" in error && error.code === "ENOENT")
24
+ return undefined;
25
+ throw error;
26
+ }
27
+ }
28
+ async function observe(directory, state, env, command) {
29
+ const name = resourceNames(state).sandbox;
30
+ const control = runtimeManager(directory, state, env, command);
31
+ const { receipt } = await control.recorded();
32
+ if (!receipt)
33
+ return {
34
+ policy: policySchema.parse(JSON.parse(await readFile(join(directory, "private/runtime-policy.json"), "utf8"))),
35
+ effective: false,
36
+ recorded: false,
37
+ };
38
+ await control.confirm(receipt.id);
39
+ const observed = z
40
+ .object({
41
+ sandbox: z.literal(name),
42
+ policy_source: z.literal("sandbox"),
43
+ version: z.number().int(),
44
+ active_version: z.number().int(),
45
+ status: z.string(),
46
+ policy: policySchema,
47
+ })
48
+ .parse(JSON.parse(await command(state.input.openshellCli, [
49
+ "policy",
50
+ "get",
51
+ name,
52
+ "--gateway",
53
+ name,
54
+ "--base",
55
+ "--output",
56
+ "json",
57
+ ], { env })));
58
+ return {
59
+ policy: observed.policy,
60
+ effective: observed.active_version === observed.version &&
61
+ observed.status === "effective",
62
+ recorded: true,
63
+ };
64
+ }
65
+ /** Validate the whole selected change before service mutation. Caller holds the installation lock and starts its controller. */
66
+ export async function planNetworkPolicyChange(directory, state, requested, command = run) {
67
+ const file = join(directory, "private/network-policy-change.json");
68
+ const saved = await optionalJson(file);
69
+ if (saved !== undefined) {
70
+ const pending = pendingSchema
71
+ .extend({ ownerId: z.literal(state.ownerId) })
72
+ .parse(saved);
73
+ if (!isDeepStrictEqual(pending.requested, requested))
74
+ throw new LocalSetupError("configuration_changed", "Resume the pending network change before selecting another policy.");
75
+ return pending;
76
+ }
77
+ const { policy } = await observe(directory, state, runtimeEnvironment(directory), command);
78
+ const owned = z
79
+ .strictObject({
80
+ ownerId: z.literal(state.ownerId),
81
+ rules: adjustmentsSchema,
82
+ })
83
+ .parse(JSON.parse(await readFile(join(directory, "private/network-policy-adjustments.json"), "utf8")));
84
+ const composed = composeNetworkRules(policy.network_policies, requested, owned.rules);
85
+ const keys = new Set([
86
+ ...Object.keys(policy.network_policies),
87
+ ...Object.keys(composed.rules),
88
+ ]);
89
+ const changes = Object.fromEntries([...keys]
90
+ .filter((key) => !isDeepStrictEqual(policy.network_policies[key], composed.rules[key]))
91
+ .map((key) => [
92
+ key,
93
+ {
94
+ before: policy.network_policies[key] ?? null,
95
+ after: composed.rules[key] ?? null,
96
+ },
97
+ ]));
98
+ return {
99
+ ownerId: state.ownerId,
100
+ requested,
101
+ changes,
102
+ adjustments: composed.adjustments,
103
+ };
104
+ }
105
+ export async function stageNetworkPolicyChange(directory, change) {
106
+ await writePrivate(join(directory, "private/network-policy-change.json"), JSON.stringify(change));
107
+ }
108
+ /** Compare selected rules with their reviewed values; preserve unrelated edits and reconcile uncertain policy-set outcomes. */
109
+ export async function applyNetworkPolicy(directory, state, env, verify = false, command = run) {
110
+ const file = join(directory, "private/network-policy-change.json");
111
+ const saved = await optionalJson(file);
112
+ if (saved === undefined)
113
+ return;
114
+ const pending = pendingSchema
115
+ .extend({ ownerId: z.literal(state.ownerId) })
116
+ .parse(saved);
117
+ const observed = await observe(directory, state, env, command);
118
+ const rules = { ...observed.policy.network_policies };
119
+ let changed = false;
120
+ for (const [key, { before, after }] of Object.entries(pending.changes)) {
121
+ const actual = rules[key] ?? null;
122
+ if (isDeepStrictEqual(actual, after))
123
+ continue;
124
+ if (!isDeepStrictEqual(actual, before))
125
+ throw new LocalSetupError("configuration_changed", "A selected network rule changed after review. No policy was overwritten; inspect the pending change.");
126
+ changed = true;
127
+ if (after === null)
128
+ Reflect.deleteProperty(rules, key);
129
+ else
130
+ rules[key] = after;
131
+ }
132
+ if (verify) {
133
+ if (changed || !observed.effective)
134
+ throw new LocalSetupError("invalid_runtime_policy", "The selected network policy has not become effective in the runtime.");
135
+ await writePrivate(join(directory, "private/network-policy-adjustments.json"), JSON.stringify({ ownerId: state.ownerId, rules: pending.adjustments }));
136
+ await rm(file);
137
+ return;
138
+ }
139
+ if (!changed)
140
+ return;
141
+ const base = join(directory, "private/runtime-policy.json");
142
+ await writePrivate(base, JSON.stringify({ ...observed.policy, network_policies: rules }));
143
+ if (observed.recorded) {
144
+ const name = resourceNames(state).sandbox;
145
+ await command(state.input.openshellCli, ["policy", "set", name, "--gateway", name, "--policy", base], { env });
146
+ }
147
+ }
@@ -264,6 +264,10 @@ export async function ensureLocalNetworks(directory, state, command = run) {
264
264
  "create",
265
265
  "--driver",
266
266
  "bridge",
267
+ // Docker chooses a free subnet; declaring its size permits static endpoints.
268
+ ...(intent.purpose !== "companion"
269
+ ? ["--subnet", "0.0.0.0/24"]
270
+ : []),
267
271
  ...(intent.purpose === "runtime" ? ["--attachable"] : []),
268
272
  ...(["browser", "machine"].includes(intent.purpose)
269
273
  ? [
@@ -352,3 +356,20 @@ export async function observedBrowserMachineAddresses(directory, state, command
352
356
  changed();
353
357
  return { node: observed.isolated.address, ingress, dns };
354
358
  }
359
+ /** Stable address on the owned runtime bridge for the containerized OpenShell controller. */
360
+ export async function observedControllerAddress(directory, state) {
361
+ await verifyLocalNetworks(directory, state);
362
+ const configs = z
363
+ .array(z.object({ Subnet: z.string(), Gateway: z.string() }))
364
+ .parse(JSON.parse(await run("docker", [
365
+ "network",
366
+ "inspect",
367
+ resourceNames(state).sandbox,
368
+ "--format",
369
+ "{{json .IPAM.Config}}",
370
+ ])));
371
+ const config = configs.find((item) => isIP(item.Gateway) === 4);
372
+ if (!config)
373
+ changed();
374
+ return reservedAddress(config).address;
375
+ }
@@ -4,7 +4,17 @@ import { parseDocument } from "yaml";
4
4
  import { z } from "zod";
5
5
  import { LocalSetupError } from "./process.js";
6
6
  import { ensurePrivateFile } from "./state.js";
7
- export function initialRuntimePolicy(source, models, connections) {
7
+ import { publicWebPolicy, composeNetworkRules } from "./public-web.js";
8
+ export function serviceNetworkRule(name, network) {
9
+ return network
10
+ ? {
11
+ name,
12
+ endpoints: [{ host: network.host, port: network.port, tls: "skip" }],
13
+ binaries: [{ path: network.binary }],
14
+ }
15
+ : null;
16
+ }
17
+ export function initialRuntimePolicy(source, models, connections, publicWeb = false) {
8
18
  const document = parseDocument(source);
9
19
  if (document.errors.length || document.warnings.length)
10
20
  throw new LocalSetupError("invalid_runtime_policy", "The shipped runtime policy is invalid.");
@@ -14,43 +24,25 @@ export function initialRuntimePolicy(source, models, connections) {
14
24
  .safeParse(raw);
15
25
  if (!parsed.success)
16
26
  throw new LocalSetupError("invalid_runtime_policy", "Initial model setup requires the shipped deny-by-default runtime policy.");
27
+ const services = {
28
+ ...(models
29
+ ? { model_gateway: serviceNetworkRule("Model gateway", models.network) }
30
+ : {}),
31
+ ...(connections
32
+ ? {
33
+ connections_broker: serviceNetworkRule("Connections broker", connections.network),
34
+ }
35
+ : {}),
36
+ };
17
37
  return {
18
38
  ...parsed.data,
19
- network_policies: {
20
- ...(connections
21
- ? {
22
- connections_broker: {
23
- name: "Connections broker",
24
- endpoints: [
25
- {
26
- host: connections.network.host,
27
- port: connections.network.port,
28
- tls: "skip",
29
- },
30
- ],
31
- binaries: [{ path: connections.network.binary }],
32
- },
33
- }
34
- : {}),
35
- ...(models
36
- ? {
37
- model_gateway: {
38
- name: "Model gateway",
39
- endpoints: [
40
- {
41
- host: models.network.host,
42
- port: models.network.port,
43
- tls: "skip",
44
- },
45
- ],
46
- binaries: [{ path: models.network.binary }],
47
- },
48
- }
49
- : {}),
50
- },
39
+ network_policies: composeNetworkRules(services, { public_web: publicWebPolicy(publicWeb) }, {}).rules,
51
40
  };
52
41
  }
53
- export async function prepareRuntimePolicy(directory, models, connections) {
54
- const policy = initialRuntimePolicy(await readFile(new URL("../../deploy/openshell/policy.yaml", import.meta.url), "utf8"), models, connections);
42
+ export async function prepareRuntimePolicy(directory, ownerId, models, connections, publicWeb = false) {
43
+ const policy = initialRuntimePolicy(await readFile(new URL("../../deploy/openshell/policy.yaml", import.meta.url), "utf8"), models, connections, false);
44
+ const composed = composeNetworkRules(policy.network_policies, { public_web: publicWebPolicy(publicWeb) }, {});
45
+ await ensurePrivateFile(join(directory, "private/network-policy-adjustments.json"), JSON.stringify({ ownerId, rules: composed.adjustments }));
46
+ policy.network_policies = composed.rules;
55
47
  await ensurePrivateFile(join(directory, "private/runtime-policy.json"), JSON.stringify(policy));
56
48
  }