@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
@@ -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 (process.platform !== "darwin" || process.arch !== "arm64")
33
- throw new LocalSetupError("platform_unqualified", "This local assembly currently requires macOS arm64 with Docker Desktop; other platforms need qualification.");
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
- z.strictObject({
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), null, 2));
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
- 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.");
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. You can now remove its installation folder.");
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 ? "Sign in or create an account to set up this installation and become its first administrator." : "Sign in or create an account 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 — Sign in to ClawScarf"));
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", "Name and administrator", `${config.name} · ${config.access.administratorName}`),
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" ? "open" : "xdg-open", [url], { timeout: 5000 });
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
- process.stderr.write(detail + "\n");
98
+ clack.log.info(detail, { output: process.stderr });
95
99
  spinner.start(message);
96
100
  }
97
101
  else if (spinner)