@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.
- 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
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { validatePublicWebServices } from "./service-network.js";
|
|
1
2
|
import { openshellGatewayImage, verifyRuntimeImage } from "./images.js";
|
|
2
3
|
import { ensureOwnedVolume } from "./volumes.js";
|
|
3
4
|
import { prepareModelGateway, prepareModelCredential, } from "./model-gateway.js";
|
|
@@ -8,29 +9,35 @@ import { prepareRelay } from "./relay.js";
|
|
|
8
9
|
import { prepareRuntimePolicy } from "./policy.js";
|
|
9
10
|
import { prepareBrowser, initializeBrowserVolume } from "./browser.js";
|
|
10
11
|
import { readTeamMaterials, prepareTeamFiles } from "./team.js";
|
|
11
|
-
import { readFile, lstat } from "node:fs/promises";
|
|
12
|
+
import { readFile, lstat, stat } from "node:fs/promises";
|
|
12
13
|
import { join, resolve } from "node:path";
|
|
13
|
-
import { prepareInitialModels, withInitialModels, } from "./models.js";
|
|
14
|
-
import { ensureLocalNetworks } from "./networks.js";
|
|
14
|
+
import { prepareInitialModels, loadInitialModels, withInitialModels, } from "./models.js";
|
|
15
|
+
import { ensureLocalNetworks, observedControllerAddress } from "./networks.js";
|
|
15
16
|
import { nodeEntrypoint } from "./entrypoint.js";
|
|
16
17
|
import { ensureCertificates } from "./certificates.js";
|
|
17
18
|
import { z } from "zod";
|
|
18
19
|
import { PostgresAccessStore } from "../../services/access/repo/postgres.js";
|
|
19
20
|
import { parseLocalInput, generateLocalConfiguration, } from "./configuration.js";
|
|
20
21
|
import { withPreparedDatabase } from "./database.js";
|
|
21
|
-
import { initializeState, writePrivate, ensurePrivateFile, resourceNames, } from "./state.js";
|
|
22
|
+
import { initializeState, requirePrepared, writePrivate, ensurePrivateFile, resourceNames, } from "./state.js";
|
|
22
23
|
import { composeConfiguration } from "./compose.js";
|
|
23
24
|
import { run, LocalSetupError } from "./process.js";
|
|
24
25
|
import { verifyLocalExecutables, verifyLocalPorts } from "./preflight.js";
|
|
25
|
-
import { requireNoUpgrade } from "./upgrade-state.js";
|
|
26
26
|
/** Internal operation: the caller holds the installation lock for its full lifetime. */
|
|
27
27
|
export async function prepareLocal(directoryInput, inputValue, capabilities) {
|
|
28
28
|
const directory = resolve(directoryInput);
|
|
29
29
|
const input = parseLocalInput(inputValue);
|
|
30
30
|
const connections = await loadInitialConnections(input.connections);
|
|
31
|
+
await validatePublicWebServices(input.publicWeb, {
|
|
32
|
+
models: input.publicWeb && !input.modelGateway
|
|
33
|
+
? (await loadInitialModels(input.models))?.network
|
|
34
|
+
: undefined,
|
|
35
|
+
connections: connections?.endpoint.network,
|
|
36
|
+
});
|
|
31
37
|
const teamMaterials = await readTeamMaterials(input.team);
|
|
32
|
-
if (
|
|
33
|
-
|
|
38
|
+
if (!["darwin", "linux"].includes(process.platform) ||
|
|
39
|
+
!["arm64", "x64"].includes(process.arch))
|
|
40
|
+
throw new LocalSetupError("platform_unqualified", "Use macOS or Linux (including WSL2) with Linux Docker containers.");
|
|
34
41
|
for (const image of [
|
|
35
42
|
input.runtimeImage,
|
|
36
43
|
input.openshellClientImage,
|
|
@@ -51,17 +58,12 @@ export async function prepareLocal(directoryInput, inputValue, capabilities) {
|
|
|
51
58
|
await verifyRuntimeImage(input.runtimeImage);
|
|
52
59
|
const state = await initializeState(directory, input);
|
|
53
60
|
try {
|
|
54
|
-
|
|
55
|
-
ownerId: z.literal(state.ownerId),
|
|
56
|
-
settingsCandidate: z.string().optional(),
|
|
57
|
-
settingsReapply: z.enum(["models", "connections"]).optional(),
|
|
58
|
-
}).parse(JSON.parse(await readFile(join(directory, "prepared.json"), "utf8")));
|
|
61
|
+
await requirePrepared(directory);
|
|
59
62
|
}
|
|
60
63
|
catch (error) {
|
|
61
64
|
if (!(error instanceof Error && "code" in error && error.code === "ENOENT"))
|
|
62
65
|
throw new LocalSetupError("configuration_changed", "A retained settings change is unconfirmed. Inspect it before preparing or starting this installation.");
|
|
63
66
|
}
|
|
64
|
-
await requireNoUpgrade(directory);
|
|
65
67
|
if (capabilities)
|
|
66
68
|
await ensurePrivateFile(join(directory, "inputs.sha256"), capabilities.inputFingerprint);
|
|
67
69
|
await verifyLocalExecutables(state);
|
|
@@ -71,6 +73,7 @@ export async function prepareLocal(directoryInput, inputValue, capabilities) {
|
|
|
71
73
|
const connectionsEndpoint = await prepareInitialConnections(directory, connections);
|
|
72
74
|
await prepareModelGateway(directory, state);
|
|
73
75
|
await ensureLocalNetworks(directory, state);
|
|
76
|
+
const controllerAddress = await observedControllerAddress(directory, state);
|
|
74
77
|
const browser = await prepareBrowser(directory, state);
|
|
75
78
|
const browserMachine = browser
|
|
76
79
|
? await prepareBrowserNode(directory, state, browser.token)
|
|
@@ -86,10 +89,12 @@ export async function prepareLocal(directoryInput, inputValue, capabilities) {
|
|
|
86
89
|
await ensureOwnedVolume(names.browserVolume, state.ownerId);
|
|
87
90
|
await initializeBrowserVolume(state, browser.token);
|
|
88
91
|
}
|
|
89
|
-
await ensurePrivateFile(join(directory, "compose.json"), JSON.stringify(composeConfiguration(state, directory, browser?.address, browserMachine
|
|
92
|
+
await ensurePrivateFile(join(directory, "compose.json"), JSON.stringify(composeConfiguration(state, directory, controllerAddress, browser?.address, browserMachine, process.platform === "linux"
|
|
93
|
+
? (await stat("/var/run/docker.sock")).gid
|
|
94
|
+
: 0), null, 2));
|
|
90
95
|
await prepareModelCredential(directory, state);
|
|
91
96
|
const models = await prepareInitialModels(directory, input.models);
|
|
92
|
-
await prepareRuntimePolicy(directory, models, connectionsEndpoint);
|
|
97
|
+
await prepareRuntimePolicy(directory, state.ownerId, models, connectionsEndpoint, state.input.publicWeb);
|
|
93
98
|
await withPreparedDatabase(directory, state, async (pool, runtimeUrl) => {
|
|
94
99
|
const store = new PostgresAccessStore(pool, await readFile(join(privateDirectory, "encryption.key")), {
|
|
95
100
|
issuer: input.team.issuer,
|
|
@@ -151,6 +156,8 @@ export async function prepareLocal(directoryInput, inputValue, capabilities) {
|
|
|
151
156
|
input.openshellGateway,
|
|
152
157
|
"--cli",
|
|
153
158
|
input.openshellCli,
|
|
159
|
+
"--host-gateway-ip",
|
|
160
|
+
controllerAddress,
|
|
154
161
|
"--name",
|
|
155
162
|
names.sandbox,
|
|
156
163
|
"--port",
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
import ipaddr from "ipaddr.js";
|
|
2
|
+
import definition from "../../deploy/execution/network/public-addresses.json" with { type: "json" };
|
|
3
|
+
/** Split only ranges containing an exclusion; no address enumeration. */
|
|
4
|
+
function subtract(subnet, excluded) {
|
|
5
|
+
const [address, prefix] = subnet;
|
|
6
|
+
const [blocked, blockedPrefix] = excluded;
|
|
7
|
+
if (address.kind() !== blocked.kind())
|
|
8
|
+
return [subnet];
|
|
9
|
+
if (prefix >= blockedPrefix)
|
|
10
|
+
return address.match(blocked, blockedPrefix) ? [] : [subnet];
|
|
11
|
+
if (!blocked.match(address, prefix))
|
|
12
|
+
return [subnet];
|
|
13
|
+
const bytes = address.toByteArray();
|
|
14
|
+
const index = Math.floor(prefix / 8);
|
|
15
|
+
bytes[index] = (bytes[index] ?? 0) | (1 << (7 - (prefix % 8)));
|
|
16
|
+
return [
|
|
17
|
+
...subtract([address, prefix + 1], excluded),
|
|
18
|
+
...subtract([ipaddr.fromByteArray(bytes), prefix + 1], excluded),
|
|
19
|
+
];
|
|
20
|
+
}
|
|
21
|
+
export const publicWebAddresses = definition.excluded
|
|
22
|
+
.reduce((ranges, excluded) => ranges.flatMap((range) => subtract(range, ipaddr.parseCIDR(excluded))), definition.families.map((cidr) => ipaddr.parseCIDR(cidr)))
|
|
23
|
+
.map(([address, prefix]) => `${address.toString()}/${String(prefix)}`);
|
|
24
|
+
export function isPublicAddress(value) {
|
|
25
|
+
const address = ipaddr.parse(value);
|
|
26
|
+
return publicWebAddresses.some((cidr) => {
|
|
27
|
+
const [network, prefix] = ipaddr.parseCIDR(cidr);
|
|
28
|
+
return address.kind() === network.kind() && address.match(network, prefix);
|
|
29
|
+
});
|
|
30
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { isDeepStrictEqual } from "node:util";
|
|
2
|
+
import { z } from "zod";
|
|
3
|
+
import { LocalSetupError } from "./process.js";
|
|
4
|
+
import { publicWebAddresses } from "./public-addresses.js";
|
|
5
|
+
export { publicWebAddresses } from "./public-addresses.js";
|
|
6
|
+
export function publicWebPolicy(enabled) {
|
|
7
|
+
return enabled
|
|
8
|
+
? {
|
|
9
|
+
name: "Public web",
|
|
10
|
+
endpoints: [
|
|
11
|
+
{ ports: [80, 443], allowed_ips: publicWebAddresses, tls: "skip" },
|
|
12
|
+
],
|
|
13
|
+
binaries: [{ path: "/**" }],
|
|
14
|
+
}
|
|
15
|
+
: null;
|
|
16
|
+
}
|
|
17
|
+
const endpointSchema = z.looseObject({
|
|
18
|
+
host: z.string().optional(),
|
|
19
|
+
port: z.number().int().optional(),
|
|
20
|
+
ports: z.array(z.number().int()).optional(),
|
|
21
|
+
allowed_ips: z.array(z.string()).optional(),
|
|
22
|
+
});
|
|
23
|
+
const ruleSchema = z.looseObject({ endpoints: z.array(endpointSchema) });
|
|
24
|
+
export const adjustmentsSchema = z.record(z.string(), z.strictObject({
|
|
25
|
+
before: z.array(endpointSchema),
|
|
26
|
+
after: z.array(endpointSchema),
|
|
27
|
+
}));
|
|
28
|
+
const rule = z.record(z.string(), z.unknown());
|
|
29
|
+
export const networkChangesSchema = z.strictObject({
|
|
30
|
+
connections_broker: rule.nullable().optional(),
|
|
31
|
+
public_web: rule.nullable().optional(),
|
|
32
|
+
});
|
|
33
|
+
const ports = (endpoint) => endpoint.ports?.length
|
|
34
|
+
? endpoint.ports
|
|
35
|
+
: endpoint.port
|
|
36
|
+
? [endpoint.port]
|
|
37
|
+
: [];
|
|
38
|
+
const webPort = (port) => port === 80 || port === 443;
|
|
39
|
+
function conflict() {
|
|
40
|
+
throw new LocalSetupError("invalid_runtime_policy", "Public web conflicts with an operator's service policy. Keep public web off or reconcile the policy explicitly; existing restrictions were not changed.");
|
|
41
|
+
}
|
|
42
|
+
/** Only a recorded before/after edit can be undone. Equal address lists do not establish ownership. */
|
|
43
|
+
export function composeNetworkRules(current, changes, owned) {
|
|
44
|
+
const rules = { ...current };
|
|
45
|
+
const adjustments = { ...owned };
|
|
46
|
+
const toggle = Object.hasOwn(changes, "public_web");
|
|
47
|
+
const selected = new Set(Object.keys(changes));
|
|
48
|
+
if (toggle)
|
|
49
|
+
for (const key of Object.keys(adjustments))
|
|
50
|
+
selected.add(key);
|
|
51
|
+
for (const key of selected) {
|
|
52
|
+
const adjustment = adjustments[key];
|
|
53
|
+
if (adjustment) {
|
|
54
|
+
const actual = ruleSchema.safeParse(rules[key]);
|
|
55
|
+
if (actual.success &&
|
|
56
|
+
isDeepStrictEqual(actual.data.endpoints, adjustment.after))
|
|
57
|
+
rules[key] = { ...actual.data, endpoints: adjustment.before };
|
|
58
|
+
Reflect.deleteProperty(adjustments, key);
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
for (const [key, replacement] of Object.entries(changes)) {
|
|
62
|
+
if (replacement === null) {
|
|
63
|
+
Reflect.deleteProperty(rules, key);
|
|
64
|
+
continue;
|
|
65
|
+
}
|
|
66
|
+
if (key === "connections_broker" && rules[key]) {
|
|
67
|
+
const before = ruleSchema.parse(rules[key]);
|
|
68
|
+
const after = ruleSchema.parse(replacement);
|
|
69
|
+
// Selected service updates retain explicit address restrictions on the same endpoint.
|
|
70
|
+
for (const endpoint of before.endpoints.filter((entry) => entry.allowed_ips?.length)) {
|
|
71
|
+
const target = after.endpoints.find((entry) => entry.host === endpoint.host &&
|
|
72
|
+
isDeepStrictEqual(ports(entry), ports(endpoint)));
|
|
73
|
+
if (!target)
|
|
74
|
+
conflict();
|
|
75
|
+
target.allowed_ips = endpoint.allowed_ips;
|
|
76
|
+
}
|
|
77
|
+
rules[key] = after;
|
|
78
|
+
}
|
|
79
|
+
else
|
|
80
|
+
rules[key] = replacement;
|
|
81
|
+
}
|
|
82
|
+
if (rules.public_web !== undefined) {
|
|
83
|
+
for (const key of ["connections_broker", "model_gateway"]) {
|
|
84
|
+
if (!toggle && !selected.has(key))
|
|
85
|
+
continue;
|
|
86
|
+
if (rules[key] === undefined)
|
|
87
|
+
continue;
|
|
88
|
+
const parsed = ruleSchema.parse(rules[key]);
|
|
89
|
+
const after = parsed.endpoints.flatMap((endpoint) => {
|
|
90
|
+
const covered = ports(endpoint).filter(webPort);
|
|
91
|
+
if (!covered.length)
|
|
92
|
+
return [endpoint];
|
|
93
|
+
if (endpoint.allowed_ips?.length) {
|
|
94
|
+
if (!isDeepStrictEqual([...endpoint.allowed_ips].sort(), [...publicWebAddresses].sort()))
|
|
95
|
+
conflict();
|
|
96
|
+
return [endpoint];
|
|
97
|
+
}
|
|
98
|
+
const other = ports(endpoint).filter((port) => !webPort(port));
|
|
99
|
+
if (!other.length)
|
|
100
|
+
return [{ ...endpoint, allowed_ips: publicWebAddresses }];
|
|
101
|
+
const { port: _port, ports: _ports, ...rest } = endpoint;
|
|
102
|
+
return [
|
|
103
|
+
{ ...rest, ports: other },
|
|
104
|
+
{ ...rest, ports: covered, allowed_ips: publicWebAddresses },
|
|
105
|
+
];
|
|
106
|
+
});
|
|
107
|
+
if (!isDeepStrictEqual(parsed.endpoints, after)) {
|
|
108
|
+
adjustments[key] = { before: parsed.endpoints, after };
|
|
109
|
+
rules[key] = { ...parsed, endpoints: after };
|
|
110
|
+
}
|
|
111
|
+
}
|
|
112
|
+
// The wildcard overlaps every host on these ports. Never rewrite unrelated rules to make it fit.
|
|
113
|
+
if (toggle || selected.has("connections_broker")) {
|
|
114
|
+
for (const [key, value] of Object.entries(rules)) {
|
|
115
|
+
if (!toggle && key !== "connections_broker")
|
|
116
|
+
continue;
|
|
117
|
+
const parsed = ruleSchema.safeParse(value);
|
|
118
|
+
if (!parsed.success)
|
|
119
|
+
continue;
|
|
120
|
+
for (const endpoint of parsed.data.endpoints) {
|
|
121
|
+
if (!ports(endpoint).some(webPort))
|
|
122
|
+
continue;
|
|
123
|
+
if (endpoint.tls !== "skip" ||
|
|
124
|
+
endpoint.protocol === "tcp" ||
|
|
125
|
+
!isDeepStrictEqual([...(endpoint.allowed_ips ?? [])].sort(), [...publicWebAddresses].sort()))
|
|
126
|
+
conflict();
|
|
127
|
+
}
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
return { rules, adjustments };
|
|
132
|
+
}
|
|
@@ -26,12 +26,23 @@ async function readOptional(path) {
|
|
|
26
26
|
throw error;
|
|
27
27
|
}
|
|
28
28
|
}
|
|
29
|
-
function uncertain() {
|
|
30
|
-
|
|
29
|
+
function uncertain(error) {
|
|
30
|
+
const failure = error instanceof LocalSetupError ? error : undefined;
|
|
31
|
+
throw new LocalSetupError("runtime_outcome_unknown", "Runtime creation was attempted but its owned target is absent. Inspect this installation; setup will not create another runtime automatically." +
|
|
32
|
+
(failure ? ` Creation failure: ${failure.message}` : ""), failure?.commandFailure);
|
|
31
33
|
}
|
|
32
34
|
function changed() {
|
|
33
35
|
throw new LocalSetupError("runtime_identity_changed", "The runtime identity or ownership differs from this installation. No further runtime command was sent.");
|
|
34
36
|
}
|
|
37
|
+
export function runtimeEnvironment(directory) {
|
|
38
|
+
const controller = join(directory, "controller");
|
|
39
|
+
return {
|
|
40
|
+
...process.env,
|
|
41
|
+
XDG_CONFIG_HOME: join(controller, "config"),
|
|
42
|
+
XDG_STATE_HOME: join(controller, "state"),
|
|
43
|
+
XDG_DATA_HOME: join(controller, "data"),
|
|
44
|
+
};
|
|
45
|
+
}
|
|
35
46
|
export function runtimeManager(directory, state, env, command) {
|
|
36
47
|
const name = resourceNames(state).sandbox;
|
|
37
48
|
const intent = {
|
|
@@ -183,10 +194,10 @@ export async function ensureRuntime(directory, state, env, command = run) {
|
|
|
183
194
|
try {
|
|
184
195
|
await command(state.input.openshellCli, args, { env, timeout: 180000 });
|
|
185
196
|
}
|
|
186
|
-
catch {
|
|
197
|
+
catch (error) {
|
|
187
198
|
target = await control.observe();
|
|
188
199
|
if (!target)
|
|
189
|
-
uncertain();
|
|
200
|
+
uncertain(error);
|
|
190
201
|
}
|
|
191
202
|
target ??= await control.observe();
|
|
192
203
|
if (!target)
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { lookup } from "node:dns/promises";
|
|
2
|
+
import { isPublicAddress } from "./public-addresses.js";
|
|
3
|
+
import { LocalSetupError, run } from "./process.js";
|
|
4
|
+
import { loadInitialModels } from "./models.js";
|
|
5
|
+
import { loadInitialConnections } from "./connections.js";
|
|
6
|
+
import { resourceNames } from "./state.js";
|
|
7
|
+
/** Host DNS is an early compatibility check, not proof of runtime connectivity. */
|
|
8
|
+
export async function validatePublicWebServices(enabled, services, resolve = (host) => lookup(host, { all: true })) {
|
|
9
|
+
if (!enabled)
|
|
10
|
+
return;
|
|
11
|
+
for (const name of ["models", "connections"]) {
|
|
12
|
+
const endpoint = services[name];
|
|
13
|
+
if (!endpoint || ![80, 443].includes(endpoint.port))
|
|
14
|
+
continue;
|
|
15
|
+
let addresses;
|
|
16
|
+
try {
|
|
17
|
+
addresses = await resolve(endpoint.host);
|
|
18
|
+
}
|
|
19
|
+
catch {
|
|
20
|
+
throw new LocalSetupError("invalid_runtime_policy", `The ${name} endpoint could not be resolved for public-web validation. Check its DNS configuration before continuing.`);
|
|
21
|
+
}
|
|
22
|
+
if (!addresses.length ||
|
|
23
|
+
addresses.some(({ address }) => !isPublicAddress(address)))
|
|
24
|
+
throw new LocalSetupError("invalid_runtime_policy", `The ${name} endpoint resolves to private or reserved addresses on port ${String(endpoint.port)}. Turn Public web off or use a service port outside 80/443. This OpenShell version cannot combine those policies.`);
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
// Runs inside the protected runtime. A CONNECT success proves the actual proxy route,
|
|
28
|
+
// without sending service credentials or treating TLS/application health as established.
|
|
29
|
+
const proxyProbe = `
|
|
30
|
+
import http from 'node:http';
|
|
31
|
+
const [host, port] = process.argv.slice(1);
|
|
32
|
+
const outcome = await new Promise(resolve => {
|
|
33
|
+
const request = http.request(process.env.HTTPS_PROXY, {
|
|
34
|
+
method: 'CONNECT', path: host + ':' + port, agent: false,
|
|
35
|
+
});
|
|
36
|
+
request.setTimeout(8000, () => request.destroy(new Error('timeout')));
|
|
37
|
+
request.on('error', () => resolve('unavailable'));
|
|
38
|
+
request.on('connect', (response, socket) => {
|
|
39
|
+
socket.destroy();
|
|
40
|
+
resolve(response.statusCode === 200 ? 'connected' : response.statusCode === 403 ? 'blocked' : 'unavailable');
|
|
41
|
+
});
|
|
42
|
+
request.end();
|
|
43
|
+
});
|
|
44
|
+
console.log(outcome);
|
|
45
|
+
`;
|
|
46
|
+
export async function verifyServiceRoutes(state, env, command = run) {
|
|
47
|
+
const services = {
|
|
48
|
+
models: (await loadInitialModels(state.input.models))?.network,
|
|
49
|
+
connections: (await loadInitialConnections(state.input.connections))
|
|
50
|
+
?.endpoint.network,
|
|
51
|
+
};
|
|
52
|
+
const name = resourceNames(state).sandbox;
|
|
53
|
+
for (const service of ["models", "connections"]) {
|
|
54
|
+
const endpoint = services[service];
|
|
55
|
+
if (!endpoint)
|
|
56
|
+
continue;
|
|
57
|
+
let outcome;
|
|
58
|
+
try {
|
|
59
|
+
outcome = (await command(state.input.openshellCli, [
|
|
60
|
+
"sandbox",
|
|
61
|
+
"exec",
|
|
62
|
+
"--name",
|
|
63
|
+
name,
|
|
64
|
+
"--gateway",
|
|
65
|
+
name,
|
|
66
|
+
"--no-tty",
|
|
67
|
+
"--timeout",
|
|
68
|
+
"15",
|
|
69
|
+
"--",
|
|
70
|
+
"node",
|
|
71
|
+
"--input-type=module",
|
|
72
|
+
"-e",
|
|
73
|
+
proxyProbe,
|
|
74
|
+
endpoint.host,
|
|
75
|
+
String(endpoint.port),
|
|
76
|
+
], { env, timeout: 25000 })).trim();
|
|
77
|
+
}
|
|
78
|
+
catch (error) {
|
|
79
|
+
throw new LocalSetupError("native_unavailable", `The runtime ${service} network check did not complete. Inspect the runtime before retrying.`, error instanceof LocalSetupError ? error.commandFailure : undefined);
|
|
80
|
+
}
|
|
81
|
+
if (outcome !== "connected")
|
|
82
|
+
throw new LocalSetupError("invalid_runtime_policy", outcome === "blocked"
|
|
83
|
+
? `The runtime network policy blocks the configured ${service} endpoint. Review Public web and the service policy before starting.`
|
|
84
|
+
: `The configured ${service} endpoint is unreachable through the runtime proxy. Check the service and its DNS/network settings before starting.`);
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -13,6 +13,22 @@ const identitySchema = z.object({
|
|
|
13
13
|
const stateSchema = identitySchema
|
|
14
14
|
.extend({ input: z.unknown().transform(parseLocalInput) })
|
|
15
15
|
.strict();
|
|
16
|
+
const preparationSchema = z.strictObject({
|
|
17
|
+
ownerId: z.uuid(),
|
|
18
|
+
settingsCandidate: z.string().optional(),
|
|
19
|
+
settingsPending: z.string().optional(),
|
|
20
|
+
settingsReapply: z.enum(["models", "connections"]).optional(),
|
|
21
|
+
});
|
|
22
|
+
export async function readPreparation(directory) {
|
|
23
|
+
const record = preparationSchema.parse(JSON.parse(await readFile(join(directory, "prepared.json"), "utf8")));
|
|
24
|
+
if (record.ownerId !== (await readInstallationIdentity(directory)).ownerId)
|
|
25
|
+
throw new LocalSetupError("configuration_changed", "Preparation belongs to another installation.");
|
|
26
|
+
return record;
|
|
27
|
+
}
|
|
28
|
+
export async function requirePrepared(directory) {
|
|
29
|
+
if ((await readPreparation(directory)).settingsPending)
|
|
30
|
+
throw new LocalSetupError("configuration_changed", "A retained settings change is unconfirmed. Run configure to review and resume it before starting.");
|
|
31
|
+
}
|
|
16
32
|
export async function writePrivate(path, value) {
|
|
17
33
|
const temporary = path + "." + randomUUID();
|
|
18
34
|
try {
|
|
@@ -12,7 +12,6 @@ import { readJson } from "./files.js";
|
|
|
12
12
|
import { startInstallation, controlInstallation, installationLogs, } from "./lifecycle.js";
|
|
13
13
|
import { doctorInstallation } from "./doctor.js";
|
|
14
14
|
import { localLogNames } from "../deployment/logs.js";
|
|
15
|
-
import { upgradeLocal } from "../deployment/upgrade.js";
|
|
16
15
|
import { installationCatalog } from "./recipes/catalog.js";
|
|
17
16
|
import { defaultInstallationDirectory } from "./location.js";
|
|
18
17
|
import { progress } from "./installer/prompts.js";
|
|
@@ -20,12 +19,13 @@ import { runConfiguration } from "./installer/run.js";
|
|
|
20
19
|
import { installationOptions } from "./options.js";
|
|
21
20
|
import { InstallationError } from "./errors.js";
|
|
22
21
|
import { administratorSetup } from "./administrator.js";
|
|
23
|
-
export function installationCommand() {
|
|
22
|
+
export function installationCommand(observation) {
|
|
24
23
|
const program = new Command("clawscarf")
|
|
25
24
|
.description("Install and operate a protected OpenClaw team server.")
|
|
26
25
|
.option("--json", "Print machine-readable results; diagnostics go to stderr");
|
|
27
26
|
program.addCommand(peopleCommand(program));
|
|
28
27
|
const output = (value, human) => {
|
|
28
|
+
observation?.result(value);
|
|
29
29
|
writeResult(program, value, human);
|
|
30
30
|
};
|
|
31
31
|
installationOptions(program.command("configure"))
|
|
@@ -43,9 +43,11 @@ export function installationCommand() {
|
|
|
43
43
|
const result = await runConfiguration({
|
|
44
44
|
...options,
|
|
45
45
|
...program.opts(),
|
|
46
|
-
});
|
|
46
|
+
}, (mode) => observation?.configurationMode(mode));
|
|
47
47
|
if (options.nonInteractive)
|
|
48
48
|
output(result);
|
|
49
|
+
else
|
|
50
|
+
observation?.result(result);
|
|
49
51
|
});
|
|
50
52
|
program
|
|
51
53
|
.command("recipes")
|
|
@@ -91,7 +93,7 @@ export function installationCommand() {
|
|
|
91
93
|
});
|
|
92
94
|
const directory = await resolveLocation(options);
|
|
93
95
|
const result = await progress("Deleting installation", (signal) => deleteInstallation(directory, signal), program.opts());
|
|
94
|
-
output(result, "Installation deleted from Docker.
|
|
96
|
+
output(result, "Installation deleted from Docker. The local folder still contains configuration and credentials. To install again, move or remove that folder, or configure a new --directory.");
|
|
95
97
|
return;
|
|
96
98
|
}
|
|
97
99
|
if (options.confirmDelete !== undefined || options.acceptDataLoss)
|
|
@@ -125,16 +127,6 @@ export function installationCommand() {
|
|
|
125
127
|
}
|
|
126
128
|
output(await doctorInstallation(config));
|
|
127
129
|
});
|
|
128
|
-
withLocation(program.command("upgrade"))
|
|
129
|
-
.requiredOption("--runtime-image <digest>")
|
|
130
|
-
.requiredOption("--python <executable>")
|
|
131
|
-
.requiredOption("--yes")
|
|
132
|
-
.action(async (options) => {
|
|
133
|
-
await upgradeLocal(await resolveLocation(options), options.runtimeImage, options.python, (message) => {
|
|
134
|
-
process.stderr.write(message + "\n");
|
|
135
|
-
});
|
|
136
|
-
output({ state: "upgraded" }, "Upgrade completed.");
|
|
137
|
-
});
|
|
138
130
|
const connections = program
|
|
139
131
|
.command("connections")
|
|
140
132
|
.description("Manage connected accounts and their agent access");
|
|
@@ -22,6 +22,7 @@ export const installationSchema = z
|
|
|
22
22
|
.strictObject({
|
|
23
23
|
schemaVersion: z.literal(1),
|
|
24
24
|
name: localInput.shape.name,
|
|
25
|
+
agentName: localInput.shape.agentName,
|
|
25
26
|
recipe: z
|
|
26
27
|
.strictObject({
|
|
27
28
|
id: z.string().min(1),
|
|
@@ -65,6 +66,7 @@ export const installationSchema = z
|
|
|
65
66
|
}),
|
|
66
67
|
]),
|
|
67
68
|
resources: z.strictObject({ runtime: resources }),
|
|
69
|
+
publicWeb: z.boolean().default(false),
|
|
68
70
|
browser: z.strictObject({ enabled: z.boolean() }),
|
|
69
71
|
models: z.discriminatedUnion("mode", [
|
|
70
72
|
externalLiteLlmSchema,
|
|
@@ -117,6 +117,7 @@ export async function configurationChanges(before, after) {
|
|
|
117
117
|
return {
|
|
118
118
|
models: !isDeepStrictEqual(await models(before), await models(after)),
|
|
119
119
|
connections: !isDeepStrictEqual(before.connections, after.connections),
|
|
120
|
+
publicWeb: before.publicWeb !== after.publicWeb,
|
|
120
121
|
packs: !isDeepStrictEqual(await packs(before), await packs(after)),
|
|
121
122
|
};
|
|
122
123
|
}
|
|
@@ -7,7 +7,7 @@ export function registerWithBrowser(configFile, ui, task, register = registerClo
|
|
|
7
7
|
return task("Connecting selected cloud services", (signal) => register(configFile, (url, file, administrator) => authorizeCloud(url, file, {
|
|
8
8
|
wait: true,
|
|
9
9
|
present: async (link, code, expiresAt) => {
|
|
10
|
-
ui.note(`${administrator ? "
|
|
10
|
+
ui.note(`${administrator ? "Create an account or sign in to set up this installation and become its first administrator." : "Create an account or sign in to authorize the selected cloud services."}\n\n${terminalLink(link)}\n\nApproval code: ${code} — check that it matches the website.\n\nExpires at ${expiresAt}. Return to this terminal after approval.`, styleText(["bold", "yellow"], "ACTION REQUIRED — Create an account or sign in"));
|
|
11
11
|
await ui.openBrowser(link);
|
|
12
12
|
},
|
|
13
13
|
signal,
|
|
@@ -127,9 +127,11 @@ export async function collectInstallation(ui, options, retained) {
|
|
|
127
127
|
case "identity": {
|
|
128
128
|
const name = await field(ui, "Installation name", localInput.shape.name, config.name);
|
|
129
129
|
const administratorName = await field(ui, "Administrator display name", localInput.shape.administratorName, config.access.administratorName);
|
|
130
|
+
const agentName = await field(ui, "Default agent name", installationSchema.shape.agentName, config.agentName);
|
|
130
131
|
config = {
|
|
131
132
|
...config,
|
|
132
133
|
name,
|
|
134
|
+
agentName,
|
|
133
135
|
access: { ...config.access, administratorName },
|
|
134
136
|
};
|
|
135
137
|
break;
|
|
@@ -175,6 +177,21 @@ export async function collectInstallation(ui, options, retained) {
|
|
|
175
177
|
case "resources":
|
|
176
178
|
config.resources = await collectResources(ui, config.resources);
|
|
177
179
|
break;
|
|
180
|
+
case "public-web":
|
|
181
|
+
config.publicWeb =
|
|
182
|
+
(await ui.select("Public web access", [
|
|
183
|
+
{
|
|
184
|
+
value: "on",
|
|
185
|
+
label: "On",
|
|
186
|
+
hint: "Public HTTP(S); agents can send team data to public services",
|
|
187
|
+
},
|
|
188
|
+
{
|
|
189
|
+
value: "off",
|
|
190
|
+
label: "Off",
|
|
191
|
+
hint: "Only configured services and explicit policies",
|
|
192
|
+
},
|
|
193
|
+
], config.publicWeb ? "on" : "off")) === "on";
|
|
194
|
+
break;
|
|
178
195
|
case "browser":
|
|
179
196
|
ui.note("Explicit browser node use works. Ordinary model-selected browsing is not qualified because of an upstream routing issue.", "Experimental browser");
|
|
180
197
|
config.browser.enabled =
|
|
@@ -20,11 +20,12 @@ export function installationMenu(config, directory, browserAvailable, packIssues
|
|
|
20
20
|
: "Credentials come next",
|
|
21
21
|
},
|
|
22
22
|
row("location", "Location", location.length > 40 ? "…" + location.slice(-39) : location, directory),
|
|
23
|
-
row("identity", "
|
|
23
|
+
row("identity", "Names", `${config.name} · ${config.agentName} · ${config.access.administratorName}`),
|
|
24
24
|
row("access", "Access", config.access.mode === "hosted" ? "ClawScarf login" : "Custom OIDC", "Configure your own OIDC provider (optional)"),
|
|
25
25
|
row("exposure", "Network", config.exposure.mode === "local"
|
|
26
26
|
? "This computer"
|
|
27
27
|
: config.exposure.applicationOrigin),
|
|
28
|
+
row("public-web", "Public web", config.publicWeb ? "On" : "Off", "HTTP(S) for agents and tools; private destinations blocked"),
|
|
28
29
|
row("models", "Models", modelSummary ?? "Choose a model"),
|
|
29
30
|
row("connections", "Connections", config.connections.mode === "disabled" ? "Off" : "On"),
|
|
30
31
|
row("packs", "Packs", packIssues.length
|
|
@@ -42,7 +43,7 @@ export function installationMenu(config, directory, browserAvailable, packIssues
|
|
|
42
43
|
];
|
|
43
44
|
return existing
|
|
44
45
|
? [
|
|
45
|
-
...choices.filter(({ value }) => ["review", "models", "connections", "packs"].includes(value)),
|
|
46
|
+
...choices.filter(({ value }) => ["review", "models", "connections", "packs", "public-web"].includes(value)),
|
|
46
47
|
{ value: "model-credentials", label: "Change LLM API keys" },
|
|
47
48
|
]
|
|
48
49
|
: choices;
|
|
@@ -66,7 +66,11 @@ export const terminalPrompts = {
|
|
|
66
66
|
note: clack.note,
|
|
67
67
|
async openBrowser(url) {
|
|
68
68
|
try {
|
|
69
|
-
await promisify(execFile)(process.platform === "darwin"
|
|
69
|
+
await promisify(execFile)(process.platform === "darwin"
|
|
70
|
+
? "open"
|
|
71
|
+
: process.env.WSL_DISTRO_NAME
|
|
72
|
+
? "explorer.exe"
|
|
73
|
+
: "xdg-open", [url], { timeout: 5000 });
|
|
70
74
|
}
|
|
71
75
|
catch {
|
|
72
76
|
process.stderr.write("Could not open a browser. Open the link above manually.\n");
|
|
@@ -91,7 +95,7 @@ export async function progress(message, work, options = {}) {
|
|
|
91
95
|
const report = (detail) => {
|
|
92
96
|
if (spinner && detail.includes("\n")) {
|
|
93
97
|
spinner.stop(message);
|
|
94
|
-
|
|
98
|
+
clack.log.info(detail, { output: process.stderr });
|
|
95
99
|
spinner.start(message);
|
|
96
100
|
}
|
|
97
101
|
else if (spinner)
|