@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
@@ -7,7 +7,7 @@ import { savedSetup, prepareConfiguration, rejectNewSelections, } from "../confi
7
7
  import * as clack from "@clack/prompts";
8
8
  import { styleText } from "node:util";
9
9
  import { setTimeout as delay } from "node:timers/promises";
10
- import { writeFile, access } from "node:fs/promises";
10
+ import { access } from "node:fs/promises";
11
11
  import { dirname, resolve, join } from "node:path";
12
12
  import { collectInstallation } from "./collect.js";
13
13
  import { InstallerCancelled, SectionCancelled, progress, requireTerminal, terminalPrompts, unattendedPrompts, terminalLink, } from "./prompts.js";
@@ -65,7 +65,6 @@ export async function installFromAnswers(options, ui, operator = operations, tas
65
65
  throw new InstallationError("invalid_configuration", "No installation settings were collected.");
66
66
  const { configFile, config } = setup;
67
67
  const directory = dirname(configFile);
68
- const planFile = join(directory, "preview.json");
69
68
  const stateDirectory = resolve(directory, config.stateDirectory);
70
69
  try {
71
70
  await task("Preparing required software", (signal, report) => operator.prerequisites(configFile, { acquire: true, signal, report }));
@@ -74,10 +73,7 @@ export async function installFromAnswers(options, ui, operator = operations, tas
74
73
  else
75
74
  await registerWithBrowser(configFile, ui, task, operator.register);
76
75
  const plan = await task("Checking installation settings", () => operator.plan(configFile));
77
- await writeFile(planFile, JSON.stringify(plan, null, 2) + "\n", {
78
- mode: 0o600,
79
- });
80
- await task("Installing ClawScarf", () => operator.apply(configFile, planFile));
76
+ await task("Installing ClawScarf", () => operator.apply(configFile, plan));
81
77
  if (!(options.start ??
82
78
  (options.nonInteractive ? true : await ui.confirm("Start now?", true)))) {
83
79
  ui.note(`clawscarf start --directory ${quote(directory)}`, "Start later");
@@ -161,7 +157,7 @@ function quote(value) {
161
157
  return `'${value.replaceAll("'", "'\\''")}'`;
162
158
  }
163
159
  /** The command selects the workflow; users never manipulate preparation files. */
164
- export async function runConfiguration(options) {
160
+ export async function runConfiguration(options, observeMode) {
165
161
  options = {
166
162
  ...options,
167
163
  directory: options.directory ?? defaultInstallationDirectory,
@@ -174,8 +170,6 @@ export async function runConfiguration(options) {
174
170
  }
175
171
  const ui = options.nonInteractive ? unattendedPrompts : terminalPrompts;
176
172
  try {
177
- if (options.reapply)
178
- throw new InstallationError("invalid_configuration", "--reapply requires an existing installation.");
179
173
  const saved = await savedSetup(options);
180
174
  const state = saved
181
175
  ? resolve(dirname(saved.configFile), saved.config.stateDirectory)
@@ -193,6 +187,19 @@ export async function runConfiguration(options) {
193
187
  throw error;
194
188
  }
195
189
  }
190
+ if (existing && state) {
191
+ try {
192
+ await access(join(state, "prepared.json"));
193
+ }
194
+ catch (error) {
195
+ if (!(error instanceof Error &&
196
+ "code" in error &&
197
+ error.code === "ENOENT"))
198
+ throw error;
199
+ throw new InstallationError("invalid_configuration", `This folder retains settings but the installation is no longer prepared. If deletion failed, finish it with clawscarf stop --directory ${quote(options.directory ?? defaultInstallationDirectory)} --delete. After successful deletion, move or remove the installation folder before configuring it again, or choose a new --directory.`);
200
+ }
201
+ }
202
+ observeMode?.(existing ? "edit" : "new");
196
203
  let result = existing && state
197
204
  ? await editInstallationSettings(state, ui, options)
198
205
  : await installFromAnswers(options, ui, operations, (message, work) => progress(message, work, options));
@@ -218,14 +225,17 @@ export async function runConfiguration(options) {
218
225
  : undefined;
219
226
  if (result.state === "cancelled")
220
227
  clack.cancel("Cancelled.");
221
- else
228
+ else {
229
+ if ("ready" in result && result.ready)
230
+ ui.note(`clawscarf status --directory ${quote(options.directory ?? defaultInstallationDirectory)}\nclawscarf stop --directory ${quote(options.directory ?? defaultInstallationDirectory)}`, "Manage this installation");
222
231
  clack.outro("ready" in result
223
232
  ? result.ready
224
- ? `ClawScarf is ready.${applicationUrl ? ` Visit ${terminalLink(applicationUrl)}` : ""}\n\nManage this installation:\n clawscarf status --directory ${quote(options.directory ?? defaultInstallationDirectory)}\n clawscarf stop --directory ${quote(options.directory ?? defaultInstallationDirectory)}`
233
+ ? `ClawScarf is ready.${applicationUrl ? ` Visit ${terminalLink(applicationUrl)}` : ""}`
225
234
  : "ClawScarf needs attention. Check status for details."
226
235
  : result.state === "unchanged"
227
236
  ? "No changes. Server left as it was."
228
237
  : "Configuration saved. Server stopped.");
238
+ }
229
239
  if ("ready" in result && result.ready && applicationUrl)
230
240
  await ui.openBrowser(applicationUrl);
231
241
  }
@@ -1,7 +1,6 @@
1
1
  import { selectModel } from "../../models.js";
2
2
  import { gatewayRoutesSchema, configurationSchema, } from "../../../models/configuration.js";
3
3
  import { secretInput } from "../secrets.js";
4
- import { inputFile } from "../inputs.js";
5
4
  import { InstallationError } from "../../errors.js";
6
5
  export async function modelRoutes(file, inputs) {
7
6
  return gatewayRoutesSchema.parse(await inputs.readJson(file));
@@ -91,15 +90,6 @@ export async function collectModelCredentials(ui, current, inputs) {
91
90
  const routes = await modelRoutes(current.configurationFile, inputs);
92
91
  const selected = routes.models.find((model) => model.id === routes.defaultModel);
93
92
  ui.note(`${selected?.name ?? routes.defaultModel}${routes.thinkingDefault ? " · " + routes.thinkingDefault : ""}\nProvider: ${selected?.route ? providerLabel(selected.route) : "Custom"}`, "LLM credentials");
94
- const mode = await ui.select("LLM API keys", [
95
- { value: "paste", label: "Enter keys (hidden)" },
96
- { value: "file", label: "Import private credentials file" },
97
- ], "paste");
98
- if (mode === "file")
99
- return {
100
- ...current,
101
- upstreamEnvironmentFile: await inputFile(ui, "Provider credentials file (.env)", true),
102
- };
103
93
  const values = new Map();
104
94
  for (const model of routes.models.filter((item) => item.enabled)) {
105
95
  const route = model.route;
@@ -107,9 +97,9 @@ export async function collectModelCredentials(ui, current, inputs) {
107
97
  throw new InstallationError("invalid_configuration", "An enabled model has no provider route.");
108
98
  if (values.has(route.apiKeyEnv))
109
99
  continue;
110
- const key = await ui.password(`${providerLabel(route)} LLM API key`);
100
+ const key = await ui.password(`${providerLabel(route)} API key`);
111
101
  if (!key.trim() || /[\r\n'"`]/.test(key))
112
- throw new InstallationError("invalid_configuration", "Use a single-line provider key without quote characters, or import its credentials file.");
102
+ throw new InstallationError("invalid_configuration", "Use a single-line provider key without quote characters.");
113
103
  values.set(route.apiKeyEnv, key);
114
104
  }
115
105
  return {
@@ -1,5 +1,4 @@
1
1
  import { checkHost, checkInstallationPrerequisites } from "../prerequisites.js";
2
- import { z } from "zod";
3
2
  import { selectedDraft } from "../options.js";
4
3
  import { setupContext } from "../setup.js";
5
4
  import { configurationChanges, resolveConfigurationInputs, validateSelections, } from "../configure.js";
@@ -13,27 +12,22 @@ import { progress } from "./prompts.js";
13
12
  import { SetupInputs, saveConfiguration } from "../save.js";
14
13
  import { readJson } from "../files.js";
15
14
  import { installationSchema } from "../configuration.js";
16
- import { planSettingsChange, reconfigureInstallation } from "../reconfigure.js";
15
+ import { planSettingsChange, reconfigureInstallation, retainSettingsAuthorization, discardSettingsCandidate, } from "../reconfigure.js";
17
16
  import { controlInstallation, startInstallation } from "../lifecycle.js";
18
17
  import { InstallationError } from "../errors.js";
19
- import { withInstallationLock, writePrivate } from "../../deployment/state.js";
18
+ import { readPreparation } from "../../deployment/state.js";
20
19
  /** The menu only gathers answers; the same plan/apply operations serve unattended callers. */
21
20
  export async function editInstallationSettings(directory, ui, options = {}, prerequisites = { host: checkHost, check: checkInstallationPrerequisites }) {
22
21
  directory = resolve(directory);
23
22
  if (options.recipe || options.cloudUrl)
24
- throw new InstallationError("change_unsupported", "Existing installations keep their recipe, software release and login service. Change models, keys, Connections or packs instead.");
23
+ throw new InstallationError("change_unsupported", "Existing installations keep their recipe, software release and login service. Change models, keys, Connections, public web or packs instead.");
25
24
  if (options.nonInteractive && !options.yes)
26
25
  throw new InstallationError("invalid_configuration", "Changing an existing installation noninteractively requires --yes. The server may restart and deselected packs may be removed.");
27
26
  const task = (message, work) => progress(message, work, options);
28
27
  const settingsFile = join(directory, "settings.json");
29
28
  const before = await readFile(settingsFile, "utf8");
30
29
  const config = installationSchema.parse(JSON.parse(before));
31
- const record = z
32
- .object({
33
- settingsCandidate: z.string().optional(),
34
- settingsReapply: z.enum(["models", "connections"]).optional(),
35
- })
36
- .parse(await readJson(join(directory, "prepared.json")));
30
+ const record = await readPreparation(directory);
37
31
  const pending = record.settingsCandidate;
38
32
  if (record.settingsReapply &&
39
33
  options.reapply &&
@@ -68,22 +62,7 @@ export async function editInstallationSettings(directory, ui, options = {}, prer
68
62
  if (pending &&
69
63
  !options.nonInteractive &&
70
64
  !(await ui.confirm("Resume this interrupted change?"))) {
71
- await withInstallationLock(directory, async () => {
72
- const prepared = z
73
- .object({
74
- ownerId: z.string(),
75
- settingsCandidate: z.string().optional(),
76
- settingsReapply: z.enum(["models", "connections"]).optional(),
77
- settingsPending: z.string().optional(),
78
- })
79
- .parse(await readJson(join(directory, "prepared.json")));
80
- if (prepared.settingsCandidate !== pending)
81
- throw new InstallationError("stale_plan", "The pending change has changed. Reopen configure.");
82
- if (!prepared.settingsPending) {
83
- await writePrivate(join(directory, "prepared.json"), JSON.stringify({ ownerId: prepared.ownerId }));
84
- retained = false;
85
- }
86
- });
65
+ retained = !(await discardSettingsCandidate(directory, pending));
87
66
  return { state: "cancelled" };
88
67
  }
89
68
  if (!pending &&
@@ -114,6 +93,9 @@ export async function editInstallationSettings(directory, ui, options = {}, prer
114
93
  `Connections: ${plan.changes.connections.to === "disabled" ? "Off" : "On"}`,
115
94
  ]
116
95
  : []),
96
+ ...(plan.scopes.publicWeb
97
+ ? [`Public web: ${plan.changes.publicWeb.to ? "On" : "Off"}`]
98
+ : []),
117
99
  ...(plan.scopes.packs
118
100
  ? [`Packs: ${plan.changes.packs.selected.join(", ") || "None"}`]
119
101
  : []),
@@ -159,27 +141,8 @@ export async function editInstallationSettings(directory, ui, options = {}, prer
159
141
  }
160
142
  catch (error) {
161
143
  if (authorizing && !pending) {
162
- const candidate = authorizing;
163
- await withInstallationLock(directory, async () => {
164
- if ((await readFile(settingsFile, "utf8")) !== before)
165
- throw new InstallationError("stale_plan", "Settings changed during authorization. Reopen configure before applying.");
166
- const prepared = z
167
- .object({
168
- ownerId: z.string(),
169
- settingsCandidate: z.string().optional(),
170
- settingsReapply: z.enum(["models", "connections"]).optional(),
171
- settingsPending: z.string().optional(),
172
- })
173
- .parse(await readJson(join(directory, "prepared.json")));
174
- if (prepared.settingsCandidate || prepared.settingsPending)
175
- throw new InstallationError("stale_plan", "Another configuration is pending. Resume it before applying these settings.");
176
- await writePrivate(join(directory, "prepared.json"), JSON.stringify({
177
- ...prepared,
178
- settingsCandidate: candidate,
179
- ...(reapply ? { settingsReapply: reapply } : {}),
180
- }));
181
- retained = true;
182
- });
144
+ await retainSettingsAuthorization(directory, authorizing, before, reapply);
145
+ retained = true;
183
146
  }
184
147
  if (attempted)
185
148
  ui.note("Check status, then run configure --directory again to review and resume the interrupted change.", "Retained settings");
@@ -36,9 +36,11 @@ export async function installationSummary(config, inputs) {
36
36
  : config.exposure.applicationOrigin;
37
37
  return [
38
38
  `${config.name} — ${origin}`,
39
+ `Agent: ${config.agentName}`,
39
40
  `Administrator: ${config.access.administratorName} · ${config.access.mode === "hosted" ? "ClawScarf login" : "Custom OIDC"}`,
40
41
  `Models: ${model}`,
41
42
  `Connections: ${config.connections.mode === "disabled" ? "Off" : "On"}`,
43
+ `Public web: ${config.publicWeb ? "On" : "Off"} (private destinations blocked)`,
42
44
  `Browser: ${config.browser.enabled ? "Experimental" : "Off"} · Packs: ${config.packs.flatMap((pack) => pack.members).join(", ") || "None"}`,
43
45
  ].join("\n");
44
46
  }
@@ -18,6 +18,7 @@ const text = z.string().min(1);
18
18
  /** Public selections only; internal paths and bootstrap state are never CLI overrides. */
19
19
  export const selectionSchema = z.object({
20
20
  name: installationSchema.shape.name.optional(),
21
+ agentName: installationSchema.shape.agentName.optional(),
21
22
  administratorName: text.optional(),
22
23
  port: port.optional(),
23
24
  widgetPort: port.optional(),
@@ -36,6 +37,7 @@ export const selectionSchema = z.object({
36
37
  connections: z.boolean().optional(),
37
38
  connectionsCloudUrl: cloudUrlSchema.optional(),
38
39
  browser: z.boolean().optional(),
40
+ publicWeb: z.boolean().optional(),
39
41
  model: text.optional(),
40
42
  provider: text.optional(),
41
43
  reasoning: z.enum(["low", "medium", "high"]).optional(),
@@ -56,6 +58,7 @@ export const selectionSchema = z.object({
56
58
  export function installationOptions(command) {
57
59
  return command
58
60
  .option("--name <name>", "Installation name")
61
+ .option("--agent-name <name>", "Initial default agent name")
59
62
  .option("--administrator-name <name>", "First administrator display name")
60
63
  .option("--port <number>", "Local application port (default: 18800)")
61
64
  .option("--widget-port <number>", "Local widgets port (default: 18802)")
@@ -79,6 +82,8 @@ export function installationOptions(command) {
79
82
  .option("--connections-cloud-url <url>", "Use a separate Connections service")
80
83
  .option("--browser", "Enable experimental browser capability")
81
84
  .option("--no-browser", "Disable browser capability")
85
+ .option("--public-web", "Allow public HTTP(S) from agents and tools")
86
+ .option("--no-public-web", "Restrict runtime egress to configured services")
82
87
  .option("--model <id>", "Default model from the model catalog")
83
88
  .option("--provider <id>", "Model provider, for example openai or openrouter")
84
89
  .addOption(new Option("--reasoning <level>", "Model reasoning level").choices([
@@ -116,6 +121,7 @@ export async function selectedDraft(context, recipe, selections, inputs, retaine
116
121
  "modelGatewayKeyFile",
117
122
  "modelGatewayCaFile",
118
123
  "connections",
124
+ "publicWeb",
119
125
  "connectionsCloudUrl",
120
126
  "pack",
121
127
  "packs",
@@ -123,11 +129,13 @@ export async function selectedDraft(context, recipe, selections, inputs, retaine
123
129
  "packPython",
124
130
  ]);
125
131
  if (Object.entries(o).some(([key, value]) => value !== undefined && !mutable.has(key)))
126
- invalid("Existing installations support changes to models, keys, Connections and packs only.");
132
+ invalid("Existing installations support changes to models, keys, Connections, public web and packs only.");
127
133
  }
128
134
  const config = structuredClone(retained ?? recipeConfiguration(context, recipe));
129
135
  if (o.name !== undefined)
130
136
  config.name = o.name;
137
+ if (o.agentName !== undefined)
138
+ config.agentName = o.agentName;
131
139
  if (o.administratorName !== undefined)
132
140
  config.access.administratorName = o.administratorName;
133
141
  if (o.cpu !== undefined)
@@ -136,6 +144,8 @@ export async function selectedDraft(context, recipe, selections, inputs, retaine
136
144
  config.resources.runtime.memory = o.memory;
137
145
  if (o.browser !== undefined)
138
146
  config.browser.enabled = o.browser;
147
+ if (o.publicWeb !== undefined)
148
+ config.publicWeb = o.publicWeb;
139
149
  if (o.connections !== undefined)
140
150
  config.connections.mode = o.connections ? "hosted" : "disabled";
141
151
  if (o.connectionsCloudUrl)
@@ -1,7 +1,6 @@
1
1
  import { dirname, join, resolve } from "node:path";
2
2
  import { readFile } from "node:fs/promises";
3
3
  import { isDeepStrictEqual } from "node:util";
4
- import { z } from "zod";
5
4
  import { prepareLocal } from "../deployment/prepare.js";
6
5
  import { readState, ensurePrivateFile, withInstallationLock, } from "../deployment/state.js";
7
6
  import { fingerprint, readJson } from "./files.js";
@@ -9,21 +8,6 @@ import { InstallationError } from "./errors.js";
9
8
  import { allocatePorts, resolveInstallation } from "./resolve.js";
10
9
  import { resolveConfigurationInputs } from "./configure.js";
11
10
  import { installationSchema } from "./configuration.js";
12
- export const planSchema = z.strictObject({
13
- schemaVersion: z.literal(1),
14
- stateDirectory: z.string(),
15
- fingerprint: z.string(),
16
- observedState: z.string().nullable(),
17
- internalPorts: z.array(z.number().int().min(1024).max(65535)).length(7),
18
- action: z.enum(["prepare", "resume"]),
19
- release: z.string(),
20
- browser: z.boolean(),
21
- capabilities: z.strictObject({
22
- models: z.enum(["external", "litellm"]),
23
- connections: z.enum(["disabled", "hosted"]),
24
- packs: z.array(z.strictObject({ directory: z.string(), members: z.array(z.string()) })),
25
- }),
26
- });
27
11
  async function observation(directory) {
28
12
  try {
29
13
  await readState(directory);
@@ -54,29 +38,16 @@ export async function planInstallation(configFile) {
54
38
  : await allocatePorts();
55
39
  const resolved = await resolveInstallation(configFile, internalPorts);
56
40
  if (state && !isDeepStrictEqual(state.input, resolved.input))
57
- throw new InstallationError("change_unsupported", "This change requires an explicit configuration or upgrade operation. Prepare never replaces retained settings.");
41
+ throw new InstallationError("change_unsupported", "Preparation cannot replace retained settings. Run configure to review supported changes.");
58
42
  await verifyRetainedInputs(directory, resolved.fingerprint);
59
- return planSchema.parse({
60
- schemaVersion: 1,
43
+ return {
61
44
  stateDirectory: directory,
62
45
  fingerprint: resolved.fingerprint,
63
46
  observedState: observed,
64
47
  internalPorts,
65
- action: state ? "resume" : "prepare",
66
- release: resolved.release.version,
67
- browser: config.browser.enabled,
68
- capabilities: {
69
- models: config.models.mode,
70
- connections: config.connections.mode,
71
- packs: resolved.packSelection.packs.map(({ directory, members }) => ({
72
- directory,
73
- members,
74
- })),
75
- },
76
- });
48
+ };
77
49
  }
78
- export async function applyInstallation(configFile, planFile) {
79
- const plan = planSchema.parse(await readJson(planFile));
50
+ export async function applyInstallation(configFile, plan) {
80
51
  const resolved = await resolveInstallation(configFile, plan.internalPorts);
81
52
  if (resolved.fingerprint !== plan.fingerprint ||
82
53
  resolved.stateDirectory !== plan.stateDirectory)
@@ -12,8 +12,9 @@ import { acquireRuntimeTools } from "./runtime.js";
12
12
  import { InstallationError } from "./errors.js";
13
13
  /** No configuration, credentials or cloud account is needed for these checks. */
14
14
  export async function checkHost(command = run) {
15
- if (process.platform !== "darwin" || process.arch !== "arm64")
16
- throw new InstallationError("unsupported_platform", "This release requires macOS on Apple Silicon with Docker Desktop.");
15
+ if (!["darwin", "linux"].includes(process.platform) ||
16
+ !["arm64", "x64"].includes(process.arch))
17
+ throw new InstallationError("unsupported_platform", "Use macOS or Linux (including WSL2), with an ARM64 or x86-64 processor and Linux Docker containers. Run the Windows CLI inside WSL2.");
17
18
  let os;
18
19
  try {
19
20
  os = await command("docker", ["info", "--format", "{{.OSType}}"]);
@@ -23,8 +24,8 @@ export async function checkHost(command = run) {
23
24
  error.commandFailure?.reason === "spawn" &&
24
25
  error.commandFailure.systemCode === "ENOENT";
25
26
  throw new InstallationError("unavailable", missing
26
- ? "Docker is not installed or is not on PATH. Install Docker Desktop, start it, then run configure again."
27
- : "Cannot reach Docker. Start Docker Desktop and wait until it is running, then try again.");
27
+ ? "Docker is not installed or is not on PATH. Install Docker Engine with Compose or Docker Desktop, then run configure again."
28
+ : "Cannot reach Docker. Start Docker and check that your user can access its socket, then try again.");
28
29
  }
29
30
  if (os.trim() !== "linux")
30
31
  throw new InstallationError("unsupported_platform", "Docker must run Linux containers.");
@@ -32,8 +33,12 @@ export async function checkHost(command = run) {
32
33
  await command("docker", ["compose", "version"]);
33
34
  }
34
35
  catch {
35
- throw new InstallationError("unavailable", "Docker Compose is unavailable. Install or update Docker Desktop, then try again.");
36
+ throw new InstallationError("unavailable", "Docker Compose is unavailable. Install the Docker Compose plugin or update Docker Desktop, then try again.");
36
37
  }
38
+ const version = (await command("docker", ["version", "--format", "{{.Server.Version}}"])).trim();
39
+ const major = Number(/^([0-9]+)\./.exec(version)?.[1]);
40
+ if (!Number.isInteger(major) || major < 29)
41
+ throw new InstallationError("unsupported_platform", "Docker Engine 29 or newer is required for automatic subnet allocation with fixed service addresses. Update Docker Engine or Docker Desktop, then try again.");
37
42
  }
38
43
  /** Stream only Docker's layer progress, never registry error payloads or credentials. */
39
44
  export function pullImage(image, report, signal) {
@@ -9,8 +9,10 @@ export const recipeSchema = z.strictObject({
9
9
  description: z.string().min(1).max(500),
10
10
  maturity: z.enum(["example", "supported"]),
11
11
  defaults: z.strictObject({
12
+ agentName: installationSchema.shape.agentName,
12
13
  resources: installationSchema.shape.resources,
13
14
  browser: installationSchema.shape.browser,
15
+ publicWeb: z.boolean().default(false),
14
16
  connections: z.strictObject({ enabled: z.boolean() }).optional(),
15
17
  }),
16
18
  models: z.strictObject({
@@ -1,3 +1,7 @@
1
+ import { validatePublicWebServices } from "../deployment/service-network.js";
2
+ import { serviceNetworkRule } from "../deployment/policy.js";
3
+ import { publicWebPolicy, } from "../deployment/public-web.js";
4
+ import { stageNetworkPolicyChange, planNetworkPolicyChange, } from "../deployment/network-policy.js";
1
5
  import { applyConnectionSettings } from "../deployment/connection-settings.js";
2
6
  import { readFile } from "node:fs/promises";
3
7
  import { dirname, join, resolve } from "node:path";
@@ -11,9 +15,8 @@ import { loadGatewayConfiguration, prepareModelGateway, } from "../deployment/mo
11
15
  import { readInitialConnectionsEndpoint, loadInitialConnections, prepareInitialConnections, } from "../deployment/connections.js";
12
16
  import { configureStoppedRuntimeModels } from "../models/runtime.js";
13
17
  import { loadInitialModels } from "../deployment/models.js";
14
- import { readState, resourceNames, withInstallationLock, writePrivate, } from "../deployment/state.js";
15
- import { requireNoUpgrade } from "../deployment/upgrade-state.js";
16
- import { run } from "../deployment/process.js";
18
+ import { readState, readPreparation, resourceNames, withInstallationLock, writePrivate, } from "../deployment/state.js";
19
+ import { run, LocalSetupError } from "../deployment/process.js";
17
20
  import { selectionSchema } from "./packs.js";
18
21
  import { installationSchema } from "./configuration.js";
19
22
  import { resolveInstallation } from "./resolve.js";
@@ -41,8 +44,8 @@ export async function planSettingsChange(configFile, reapply) {
41
44
  const previousPacks = selectionSchema.parse(await readJson(join(directory, "packs.json")));
42
45
  if (!desired.packSelection.python && previousPacks.python)
43
46
  desired.packSelection.python = previousPacks.python;
44
- const { models: _oldModels, modelGateway: oldGateway, connections: oldConnections, ...oldFixed } = old;
45
- const { models: _nextModels, modelGateway: nextGateway, connections: nextConnections, ...nextFixed } = next;
47
+ const { models: _oldModels, modelGateway: oldGateway, connections: oldConnections, publicWeb: oldPublicWeb, ...oldFixed } = old;
48
+ const { models: _nextModels, modelGateway: nextGateway, connections: nextConnections, publicWeb: nextPublicWeb, ...nextFixed } = next;
46
49
  if (!isDeepStrictEqual(oldFixed, nextFixed) ||
47
50
  !isDeepStrictEqual(await readJson(join(directory, "release.json")), desired.release))
48
51
  unsupported("Settings changes cannot replace the release, identity, addresses, access, resources or execution protection.");
@@ -67,14 +70,7 @@ export async function planSettingsChange(configFile, reapply) {
67
70
  const modelConfiguration = nextGateway
68
71
  ? (await loadGatewayConfiguration(nextGateway.configurationFile, nextGateway.upstreamEnvironmentFile)).configuration
69
72
  : (await loadInitialModels(next.models))?.configuration;
70
- const pending = z
71
- .strictObject({
72
- ownerId: z.literal(state.ownerId),
73
- settingsPending: z.string().optional(),
74
- settingsCandidate: z.string().optional(),
75
- settingsReapply: z.enum(["models", "connections"]).optional(),
76
- })
77
- .parse(await readJson(join(directory, "prepared.json")));
73
+ const pending = await readPreparation(directory);
78
74
  if (pending.settingsPending &&
79
75
  pending.settingsPending !== desired.fingerprint)
80
76
  unsupported("Finish the pending settings change with its original candidate before selecting another configuration.");
@@ -84,6 +80,14 @@ export async function planSettingsChange(configFile, reapply) {
84
80
  const scopes = await configurationChanges(installationSchema.parse(await readJson(join(directory, "settings.json"))), resolveConfigurationInputs(config, dirname(resolve(configFile))));
85
81
  if (reapply)
86
82
  scopes[reapply] = true;
83
+ await validatePublicWebServices(next.publicWeb, {
84
+ models: next.publicWeb && !nextGateway && (scopes.models || scopes.publicWeb)
85
+ ? (await loadInitialModels(next.models))?.network
86
+ : undefined,
87
+ connections: scopes.connections || scopes.publicWeb
88
+ ? connections?.endpoint.network
89
+ : undefined,
90
+ });
87
91
  return {
88
92
  directory,
89
93
  state,
@@ -92,6 +96,7 @@ export async function planSettingsChange(configFile, reapply) {
92
96
  scopes,
93
97
  reapply,
94
98
  changes: {
99
+ publicWeb: { from: oldPublicWeb, to: nextPublicWeb },
95
100
  models: modelConfiguration
96
101
  ? {
97
102
  enabled: modelConfiguration.models
@@ -126,15 +131,16 @@ export async function planSettingsChange(configFile, reapply) {
126
131
  }
127
132
  /** Explicit retained capability changes; native users and individual model overrides remain native-owned. */
128
133
  export async function reconfigureInstallation(configFile, expectedFingerprint, reapply) {
129
- const planned = await planSettingsChange(configFile, reapply);
130
- return withInstallationLock(planned.directory, async () => {
134
+ const config = installationSchema.parse(await readJson(configFile));
135
+ const location = resolve(dirname(resolve(configFile)), config.stateDirectory);
136
+ return withInstallationLock(location, async () => {
131
137
  const checked = await planSettingsChange(configFile, reapply);
132
- if (checked.fingerprint !== expectedFingerprint)
138
+ if (checked.directory !== location ||
139
+ checked.fingerprint !== expectedFingerprint)
133
140
  throw new InstallationError("stale_plan", "Settings changed after preview. Review them again.");
134
141
  const { directory, state, desired, scopes } = checked;
135
142
  if (!checked.resuming && !Object.values(scopes).some(Boolean))
136
143
  return { state: "unchanged", restartRequired: false, directory };
137
- await requireNoUpgrade(directory);
138
144
  const names = resourceNames(state);
139
145
  for (const filter of [
140
146
  `label=com.docker.compose.project=${names.project}`,
@@ -187,6 +193,18 @@ export async function reconfigureInstallation(configFile, expectedFingerprint, r
187
193
  credential: models.credential,
188
194
  apply: false,
189
195
  });
196
+ // Reconcile native policy before any native/service settings are written.
197
+ const network = {};
198
+ if (scopes.connections)
199
+ network.connections_broker = serviceNetworkRule("Connections broker", (await loadInitialConnections(desired.input.connections))?.endpoint
200
+ .network);
201
+ if (scopes.publicWeb)
202
+ network.public_web = publicWebPolicy(desired.input.publicWeb);
203
+ let networkChange;
204
+ if (Object.keys(network).length) {
205
+ await compose(directory, ["up", "-d", "--wait", "controller"]);
206
+ networkChange = await planNetworkPolicyChange(directory, state, network);
207
+ }
190
208
  // Startup already requires this exact prepared record. An interrupted mutation must not announce readiness.
191
209
  await writePrivate(prepared, JSON.stringify({
192
210
  ownerId: state.ownerId,
@@ -194,8 +212,11 @@ export async function reconfigureInstallation(configFile, expectedFingerprint, r
194
212
  settingsCandidate: resolve(configFile),
195
213
  ...(checked.reapply ? { settingsReapply: checked.reapply } : {}),
196
214
  }));
197
- let stage = "model gateway configuration";
215
+ let stage = "network policy staging";
198
216
  try {
217
+ if (networkChange)
218
+ await stageNetworkPolicyChange(directory, networkChange);
219
+ stage = "model gateway configuration";
199
220
  if (scopes.models) {
200
221
  await prepareModelGateway(directory, { ...state, input: desired.input }, { replaceConfiguration: true });
201
222
  if (gateway && loaded)
@@ -249,7 +270,37 @@ export async function reconfigureInstallation(configFile, expectedFingerprint, r
249
270
  return { state: "configured", restartRequired: true, directory };
250
271
  }
251
272
  catch (error) {
273
+ if (error instanceof LocalSetupError)
274
+ throw new LocalSetupError(error.code, `Settings were not confirmed during ${stage}; the installation remains stopped. ${error.message} Run clawscarf configure to review and resume the change.`, error.commandFailure);
252
275
  throw new InstallationError("unavailable", `Settings were not confirmed during ${stage}; the installation remains stopped. Run configure --directory again to review and explicitly resume the same change. The gateway key is observed first and matching native settings are not repeated.${error instanceof ModelConfigurationError ? ` Native result: ${error.code}.` : ""}`);
253
276
  }
254
277
  });
255
278
  }
279
+ /** Retain an authorization draft without blocking startup; no service mutation has begun. */
280
+ export async function retainSettingsAuthorization(directory, candidate, acceptedSettings, reapply) {
281
+ await withInstallationLock(directory, async () => {
282
+ if ((await readFile(join(directory, "settings.json"), "utf8")) !==
283
+ acceptedSettings)
284
+ throw new InstallationError("stale_plan", "Settings changed during authorization. Reopen configure before applying.");
285
+ const prepared = await readPreparation(directory);
286
+ if (prepared.settingsCandidate || prepared.settingsPending)
287
+ throw new InstallationError("stale_plan", "Another configuration is pending. Resume it before applying these settings.");
288
+ await writePrivate(join(directory, "prepared.json"), JSON.stringify({
289
+ ...prepared,
290
+ settingsCandidate: candidate,
291
+ ...(reapply ? { settingsReapply: reapply } : {}),
292
+ }));
293
+ });
294
+ }
295
+ /** A draft can be discarded only before service mutation begins. */
296
+ export async function discardSettingsCandidate(directory, candidate) {
297
+ return withInstallationLock(directory, async () => {
298
+ const prepared = await readPreparation(directory);
299
+ if (prepared.settingsCandidate !== candidate)
300
+ throw new InstallationError("stale_plan", "The pending change has changed. Reopen configure.");
301
+ if (prepared.settingsPending)
302
+ return false;
303
+ await writePrivate(join(directory, "prepared.json"), JSON.stringify({ ownerId: prepared.ownerId }));
304
+ return true;
305
+ });
306
+ }
@@ -3,7 +3,7 @@ import { hostedOidc, hostedConnections } from "../cloud/registration.js";
3
3
  import { dirname, resolve, join } from "node:path";
4
4
  import { loadGatewayConfiguration } from "../deployment/model-gateway.js";
5
5
  import { createServer } from "node:net";
6
- import { releaseSchema } from "../release/definition.js";
6
+ import { releaseSchema, releaseTools } from "../release/definition.js";
7
7
  import { parseLocalInput, } from "../deployment/configuration.js";
8
8
  import { loadInitialModels } from "../deployment/models.js";
9
9
  import { loadInitialConnections, readInitialConnectionToken, } from "../deployment/connections.js";
@@ -49,10 +49,11 @@ export async function resolveInstallation(configFile, internalPorts) {
49
49
  if (!release.platforms.some((platform) => platform === `${process.platform}-${process.arch}`))
50
50
  throw new InstallationError("unsupported_platform", "This release does not support this host platform.");
51
51
  const toolBase = dirname(releasePath);
52
- const cli = resolve(toolBase, release.tools.openshell.cli.file);
53
- const gateway = resolve(toolBase, release.tools.openshell.gateway.file);
54
- await verifyReleaseTool(cli, release.tools.openshell.cli.sha256);
55
- await verifyReleaseTool(gateway, release.tools.openshell.gateway.sha256);
52
+ const tools = releaseTools(release);
53
+ const cli = resolve(toolBase, tools.cli.file);
54
+ const gateway = resolve(toolBase, tools.gateway.file);
55
+ await verifyReleaseTool(cli, tools.cli.sha256);
56
+ await verifyReleaseTool(gateway, tools.gateway.sha256);
56
57
  const [controller, management, native, nativeWidgets, database, browser, models,] = internalPorts;
57
58
  const inputs = {};
58
59
  async function inputFile(value, secret) {
@@ -70,8 +71,10 @@ export async function resolveInstallation(configFile, internalPorts) {
70
71
  const cloudConnections = await hostedConnections(config, configFile);
71
72
  const input = parseLocalInput({
72
73
  name: config.name,
74
+ agentName: config.agentName,
73
75
  administratorName: access.administratorName,
74
76
  runtimeImage: release.images.gateway,
77
+ publicWeb: config.publicWeb,
75
78
  companionImage: release.images.companion,
76
79
  ...(config.browser.enabled ? { relayImage: release.images.relay } : {}),
77
80
  openshellCli: cli,
@@ -1,7 +1,7 @@
1
1
  import { randomUUID } from "node:crypto";
2
2
  import { chmod, copyFile, mkdir, open, rename, rm, writeFile, } from "node:fs/promises";
3
3
  import { dirname, join, resolve } from "node:path";
4
- import { releaseSchema } from "../release/definition.js";
4
+ import { releaseSchema, releaseTools } from "../release/definition.js";
5
5
  import { readJson, verifyReleaseTool } from "./files.js";
6
6
  import { InstallationError } from "./errors.js";
7
7
  /** Installation-owned paths survive npm cache cleanup and later CLI updates. */
@@ -10,7 +10,7 @@ export async function retainRuntime(source, directory) {
10
10
  const target = join(directory, "runtime");
11
11
  await mkdir(join(target, "tools"), { recursive: true, mode: 0o700 });
12
12
  for (const name of ["cli", "gateway"]) {
13
- const tool = release.tools.openshell[name];
13
+ const tool = releaseTools(release)[name];
14
14
  const original = resolve(dirname(source), tool.file);
15
15
  tool.file = `tools/openshell${name === "gateway" ? "-gateway" : ""}`;
16
16
  try {
@@ -34,7 +34,7 @@ export async function acquireRuntimeTools(releaseFile, options) {
34
34
  const release = releaseSchema.parse(await readJson(releaseFile));
35
35
  for (const name of ["cli", "gateway"]) {
36
36
  options.signal?.throwIfAborted();
37
- const tool = release.tools.openshell[name];
37
+ const tool = releaseTools(release)[name];
38
38
  const file = resolve(dirname(releaseFile), tool.file);
39
39
  try {
40
40
  await verifyReleaseTool(file, tool.sha256);