@clawscarf/cli 0.1.0-alpha.1 → 0.1.0-alpha.10
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.
- package/README.md +119 -2
- package/THIRD_PARTY_NOTICES.md +18 -11
- package/deploy/execution/network/node-ingress.cfg +1 -1
- package/deploy/execution/network/public-addresses.json +24 -0
- package/package.json +2 -1
- package/packs/README.md +4 -11
- package/pnpm-lock.yaml +212 -3567
- package/recipes/README.md +10 -6
- package/recipes/team-server/recipe.json +5 -4
- package/release/README.md +122 -58
- package/release/components.json +26 -2
- package/release/operator.md +27 -53
- package/release/telemetry.json +4 -0
- package/runtime/configuration.js +15 -1
- package/runtime/current.json +63 -0
- package/scripts/clawscarf.js +9 -1
- package/scripts/controller.js +6 -0
- package/scripts/deployment/browser-node-compose.js +0 -1
- package/scripts/deployment/certificates.js +2 -1
- package/scripts/deployment/compose.js +3 -5
- package/scripts/deployment/configuration.js +8 -7
- package/scripts/deployment/connection-settings.js +0 -4
- package/scripts/deployment/controller-compose.js +14 -6
- package/scripts/deployment/entrypoint.js +4 -1
- package/scripts/deployment/launch.js +12 -29
- package/scripts/deployment/model-gateway-compose.js +6 -0
- package/scripts/deployment/model-gateway.js +9 -1
- package/scripts/deployment/models.js +5 -1
- package/scripts/deployment/network-policy.js +147 -0
- package/scripts/deployment/networks.js +21 -0
- package/scripts/deployment/policy.js +27 -35
- package/scripts/deployment/prepare.js +22 -15
- package/scripts/deployment/public-addresses.js +30 -0
- package/scripts/deployment/public-web.js +132 -0
- package/scripts/deployment/runtime.js +15 -4
- package/scripts/deployment/service-network.js +86 -0
- package/scripts/deployment/state.js +16 -0
- package/scripts/installation/command.js +6 -14
- package/scripts/installation/configuration.js +2 -0
- package/scripts/installation/configure.js +1 -0
- package/scripts/installation/installer/cloud.js +1 -1
- package/scripts/installation/installer/collect.js +17 -0
- package/scripts/installation/installer/menu.js +3 -2
- package/scripts/installation/installer/prompts.js +6 -2
- package/scripts/installation/installer/run.js +21 -11
- package/scripts/installation/installer/sections/models.js +2 -12
- package/scripts/installation/installer/settings.js +10 -47
- package/scripts/installation/installer/summary.js +2 -0
- package/scripts/installation/options.js +11 -1
- package/scripts/installation/plan.js +4 -33
- package/scripts/installation/prerequisites.js +10 -5
- package/scripts/installation/recipes/definition.js +2 -0
- package/scripts/installation/reconfigure.js +69 -18
- package/scripts/installation/resolve.js +8 -5
- package/scripts/installation/runtime.js +3 -3
- package/scripts/installation/setup.js +2 -0
- package/scripts/release/create.js +8 -3
- package/scripts/release/definition.js +34 -3
- package/scripts/telemetry.js +279 -0
- package/runtime/releases/0.1.0-alpha.1.json +0 -37
- package/scripts/deployment/connection-policy.js +0 -80
- package/scripts/deployment/upgrade-rpc.py +0 -148
- package/scripts/deployment/upgrade-state.js +0 -47
- package/scripts/deployment/upgrade.js +0 -298
package/scripts/controller.js
CHANGED
|
@@ -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
|
-
|
|
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://
|
|
228
|
-
|
|
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://
|
|
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
|
-
"
|
|
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://
|
|
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:
|
|
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 [
|
|
6
|
+
return [
|
|
7
|
+
...(source ? ["--import", import.meta.resolve("tsx")] : []),
|
|
8
|
+
fileURLToPath(file),
|
|
9
|
+
];
|
|
7
10
|
}
|
|
@@ -1,42 +1,29 @@
|
|
|
1
|
-
import {
|
|
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 {
|
|
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 {
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
}
|