@clawscarf/cli 0.1.0-alpha.1 → 0.1.0-alpha.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +119 -2
- package/THIRD_PARTY_NOTICES.md +18 -11
- package/deploy/execution/network/node-ingress.cfg +1 -1
- package/deploy/execution/network/public-addresses.json +24 -0
- package/package.json +2 -1
- package/packs/README.md +4 -11
- package/pnpm-lock.yaml +212 -3567
- package/recipes/README.md +10 -6
- package/recipes/team-server/recipe.json +5 -4
- package/release/README.md +122 -58
- package/release/components.json +26 -2
- package/release/operator.md +27 -53
- package/release/telemetry.json +4 -0
- package/runtime/configuration.js +15 -1
- package/runtime/current.json +63 -0
- package/scripts/clawscarf.js +9 -1
- package/scripts/controller.js +6 -0
- package/scripts/deployment/browser-node-compose.js +0 -1
- package/scripts/deployment/certificates.js +2 -1
- package/scripts/deployment/compose.js +3 -5
- package/scripts/deployment/configuration.js +8 -7
- package/scripts/deployment/connection-settings.js +0 -4
- package/scripts/deployment/controller-compose.js +14 -6
- package/scripts/deployment/entrypoint.js +4 -1
- package/scripts/deployment/launch.js +12 -29
- package/scripts/deployment/model-gateway-compose.js +6 -0
- package/scripts/deployment/model-gateway.js +9 -1
- package/scripts/deployment/models.js +5 -1
- package/scripts/deployment/network-policy.js +147 -0
- package/scripts/deployment/networks.js +21 -0
- package/scripts/deployment/policy.js +27 -35
- package/scripts/deployment/prepare.js +22 -15
- package/scripts/deployment/public-addresses.js +30 -0
- package/scripts/deployment/public-web.js +132 -0
- package/scripts/deployment/runtime.js +15 -4
- package/scripts/deployment/service-network.js +86 -0
- package/scripts/deployment/state.js +16 -0
- package/scripts/installation/command.js +6 -14
- package/scripts/installation/configuration.js +2 -0
- package/scripts/installation/configure.js +1 -0
- package/scripts/installation/installer/cloud.js +1 -1
- package/scripts/installation/installer/collect.js +17 -0
- package/scripts/installation/installer/menu.js +3 -2
- package/scripts/installation/installer/prompts.js +6 -2
- package/scripts/installation/installer/run.js +21 -11
- package/scripts/installation/installer/sections/models.js +2 -12
- package/scripts/installation/installer/settings.js +10 -47
- package/scripts/installation/installer/summary.js +2 -0
- package/scripts/installation/options.js +11 -1
- package/scripts/installation/plan.js +4 -33
- package/scripts/installation/prerequisites.js +10 -5
- package/scripts/installation/recipes/definition.js +2 -0
- package/scripts/installation/reconfigure.js +69 -18
- package/scripts/installation/resolve.js +8 -5
- package/scripts/installation/runtime.js +3 -3
- package/scripts/installation/setup.js +2 -0
- package/scripts/release/create.js +8 -3
- package/scripts/release/definition.js +34 -3
- package/scripts/telemetry.js +279 -0
- package/runtime/releases/0.1.0-alpha.1.json +0 -37
- package/scripts/deployment/connection-policy.js +0 -80
- package/scripts/deployment/upgrade-rpc.py +0 -148
- package/scripts/deployment/upgrade-state.js +0 -47
- package/scripts/deployment/upgrade.js +0 -298
|
@@ -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 {
|
|
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
|
|
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)}` : ""}
|
|
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)}
|
|
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
|
|
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 {
|
|
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 =
|
|
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
|
|
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
|
-
|
|
163
|
-
|
|
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", "
|
|
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
|
|
60
|
-
schemaVersion: 1,
|
|
43
|
+
return {
|
|
61
44
|
stateDirectory: directory,
|
|
62
45
|
fingerprint: resolved.fingerprint,
|
|
63
46
|
observedState: observed,
|
|
64
47
|
internalPorts,
|
|
65
|
-
|
|
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,
|
|
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 (
|
|
16
|
-
|
|
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
|
|
27
|
-
: "Cannot reach Docker. Start Docker
|
|
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 {
|
|
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 =
|
|
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
|
|
130
|
-
|
|
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.
|
|
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 = "
|
|
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
|
|
53
|
-
const
|
|
54
|
-
|
|
55
|
-
await verifyReleaseTool(
|
|
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
|
|
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
|
|
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);
|