@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.
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
@@ -46,6 +46,7 @@ export function recipeConfiguration(context, recipeId) {
46
46
  return {
47
47
  schemaVersion: 1,
48
48
  name: "team",
49
+ agentName: recipe.defaults.agentName,
49
50
  releaseFile: context.releaseFile,
50
51
  stateDirectory: "./state",
51
52
  exposure: { mode: "local", applicationPort: 18800, widgetPort: 18802 },
@@ -57,6 +58,7 @@ export function recipeConfiguration(context, recipeId) {
57
58
  },
58
59
  resources: structuredClone(recipe.defaults.resources),
59
60
  browser: structuredClone(recipe.defaults.browser),
61
+ publicWeb: recipe.defaults.publicWeb,
60
62
  connections: {
61
63
  mode: recipe.defaults.connections?.enabled ? "hosted" : "disabled",
62
64
  cloudUrl: context.cloudUrl,
@@ -5,9 +5,10 @@ import { copyFile, lstat, mkdir, readFile, rm, writeFile, } from "node:fs/promis
5
5
  import { fileURLToPath } from "node:url";
6
6
  import { dirname, join, resolve } from "node:path";
7
7
  import { z } from "zod";
8
- import { releaseSchema } from "./definition.js";
8
+ import { releaseSchema, hostPlatformSchema } from "./definition.js";
9
9
  // Build inputs use the release contract, replacing tool digests with source paths.
10
10
  const inputSchema = releaseSchema.extend({
11
+ platforms: z.tuple([hostPlatformSchema]),
11
12
  tools: z.strictObject({
12
13
  openshell: releaseSchema.shape.tools.shape.openshell.extend({
13
14
  cli: z.string().min(1),
@@ -43,8 +44,12 @@ export async function createDevelopmentRelease(options) {
43
44
  tools: {
44
45
  openshell: {
45
46
  ...input.tools.openshell,
46
- cli: await tool(resolve(directory, input.tools.openshell.cli), join(output, "tools/openshell"), "tools/openshell"),
47
- gateway: await tool(resolve(directory, input.tools.openshell.gateway), join(output, "tools/openshell-gateway"), "tools/openshell-gateway"),
47
+ cli: {
48
+ [input.platforms[0]]: await tool(resolve(directory, input.tools.openshell.cli), join(output, "tools/openshell"), "tools/openshell"),
49
+ },
50
+ gateway: {
51
+ [input.platforms[0]]: await tool(resolve(directory, input.tools.openshell.gateway), join(output, "tools/openshell-gateway"), "tools/openshell-gateway"),
52
+ },
48
53
  },
49
54
  },
50
55
  });
@@ -15,12 +15,18 @@ const file = z.strictObject({
15
15
  }, "Tool downloads require HTTPS without credentials.")
16
16
  .optional(),
17
17
  });
18
+ export const hostPlatformSchema = z.enum([
19
+ "darwin-arm64",
20
+ "linux-arm64",
21
+ "linux-x64",
22
+ ]);
23
+ const tool = z.partialRecord(hostPlatformSchema, file);
18
24
  /** Pinned runtime metadata, independent of recipe defaults and deployment state. */
19
25
  export const releaseSchema = z.strictObject({
20
26
  schemaVersion: z.literal(1),
21
27
  version: z.string().regex(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/),
22
28
  sourceRevision: z.string().regex(/^[a-f0-9]{40}$/),
23
- platforms: z.array(z.literal("darwin-arm64")).min(1).max(1),
29
+ platforms: z.array(hostPlatformSchema).min(1),
24
30
  images: z
25
31
  .strictObject({
26
32
  postgres: z.literal(postgresImage),
@@ -44,8 +50,33 @@ export const releaseSchema = z.strictObject({
44
50
  tools: z.strictObject({
45
51
  openshell: z.strictObject({
46
52
  version: z.string().min(1),
47
- cli: file,
48
- gateway: file,
53
+ cli: tool,
54
+ gateway: tool,
49
55
  }),
50
56
  }),
51
57
  });
58
+ /** Published manifests must work without checkout-local images or executables. */
59
+ export function assertPublishedRuntime(runtime) {
60
+ if (JSON.stringify(runtime.images).includes('"sha256:') ||
61
+ runtime.platforms.some((platform) => {
62
+ const tools = releaseTools(runtime, platform);
63
+ return !tools.cli.url || !tools.gateway.url;
64
+ }))
65
+ throw Error("Published runtimes require registry digests and downloadable tools.");
66
+ }
67
+ /** Single-platform development bundles and multi-platform published bundles share resolution. */
68
+ export function releaseTools(release, platform = `${process.platform}-${process.arch}`) {
69
+ const host = hostPlatformSchema.parse(platform);
70
+ if (!release.platforms.includes(host))
71
+ throw new Error(`Runtime ${release.version} does not include ${host}.`);
72
+ const select = (value) => {
73
+ const selected = value[host];
74
+ if (!selected)
75
+ throw new Error(`Missing runtime tool for ${host}.`);
76
+ return selected;
77
+ };
78
+ return {
79
+ cli: select(release.tools.openshell.cli),
80
+ gateway: select(release.tools.openshell.gateway),
81
+ };
82
+ }
@@ -0,0 +1,279 @@
1
+ import packageInfo from "../package.json" with { type: "json" };
2
+ import { randomUUID } from "node:crypto";
3
+ import { mkdir, open, readFile } from "node:fs/promises";
4
+ import { homedir } from "node:os";
5
+ import { join } from "node:path";
6
+ import { PostHog } from "posthog-node";
7
+ import { Agent, fetch } from "undici";
8
+ import { z } from "zod";
9
+ const destinationSchema = z.strictObject({
10
+ host: z.url().refine((value) => {
11
+ const url = new URL(value);
12
+ return (!url.username &&
13
+ !url.password &&
14
+ !url.search &&
15
+ !url.hash &&
16
+ url.pathname === "/" &&
17
+ (url.protocol === "https:" ||
18
+ (url.protocol === "http:" && url.hostname === "127.0.0.1")));
19
+ }),
20
+ projectToken: z.string().regex(/^phc_[A-Za-z0-9_-]+$/),
21
+ });
22
+ // Deliberately finite: remote errors and OperatorError.code can contain arbitrary text.
23
+ const errorCodeSchema = z.enum([
24
+ "invalid_configuration",
25
+ "unsupported_platform",
26
+ "release_mismatch",
27
+ "stale_plan",
28
+ "change_unsupported",
29
+ "unavailable",
30
+ "operation_failed",
31
+ "invalid_arguments",
32
+ "operation_busy",
33
+ "invalid_team_configuration",
34
+ "runtime_binding_changed",
35
+ "runtime_binding_unavailable",
36
+ "administrator_unverified",
37
+ "bootstrap_outcome_unknown",
38
+ "browser_unavailable",
39
+ "command_failed",
40
+ "configuration_changed",
41
+ "database_start_failed",
42
+ "executable_unavailable",
43
+ "incomplete_certificate",
44
+ "invalid_certificate",
45
+ "invalid_model_setup",
46
+ "model_credential_pending",
47
+ "invalid_connections_setup",
48
+ "connections_catalog_blocked",
49
+ "connections_configuration_pending",
50
+ "connections_configuration_refused",
51
+ "connections_configuration_unavailable",
52
+ "invalid_runtime_policy",
53
+ "native_unavailable",
54
+ "network_identity_changed",
55
+ "network_lookup_incomplete",
56
+ "network_outcome_unknown",
57
+ "network_unprepared",
58
+ "platform_unqualified",
59
+ "port_check_failed",
60
+ "port_in_use",
61
+ "private_directory_required",
62
+ "runtime_failed",
63
+ "runtime_identity_changed",
64
+ "runtime_lookup_incomplete",
65
+ "runtime_outcome_unknown",
66
+ "runtime_start_failed",
67
+ "runtime_stop_pending",
68
+ "startup_interrupted",
69
+ "startup_timed_out",
70
+ "unowned_directory",
71
+ "ownership_conflict",
72
+ "runtime_credentials_changed",
73
+ "database_setup_failed",
74
+ "outcome_unknown",
75
+ "unauthorized",
76
+ "forbidden",
77
+ "ENOENT",
78
+ "ENOTDIR",
79
+ "EACCES",
80
+ "EPERM",
81
+ "EEXIST",
82
+ "ELOOP",
83
+ "ENOSPC",
84
+ ]);
85
+ /** One CLI invocation. No runtime, server, browser, or automatic exception instrumentation. */
86
+ export class CliTelemetry {
87
+ environment;
88
+ destinationFile;
89
+ client;
90
+ dispatcher;
91
+ started;
92
+ startTime = 0;
93
+ distinctId = "";
94
+ command = "";
95
+ action;
96
+ interactive = false;
97
+ version = "";
98
+ invocationId = randomUUID();
99
+ outcome = "success";
100
+ mode;
101
+ configuration;
102
+ constructor(environment = process.env, destinationFile = new URL("../release/telemetry.json", import.meta.url)) {
103
+ this.environment = environment;
104
+ this.destinationFile = destinationFile;
105
+ }
106
+ async start(command) {
107
+ if (this.environment.CLAWSCARF_TELEMETRY_DISABLED === "1")
108
+ return;
109
+ try {
110
+ const destination = destinationSchema
111
+ .nullable()
112
+ .parse(JSON.parse(await readFile(this.destinationFile, "utf8")));
113
+ if (!destination)
114
+ return;
115
+ const manifest = z
116
+ .object({
117
+ version: z.string().regex(/^\d+\.\d+\.\d+(?:-[A-Za-z0-9.-]+)?$/),
118
+ })
119
+ .parse(packageInfo);
120
+ this.version = manifest.version;
121
+ const directory = join(this.environment.XDG_CONFIG_HOME || homedir(), this.environment.XDG_CONFIG_HOME ? "clawscarf" : ".config/clawscarf");
122
+ await mkdir(directory, { recursive: true, mode: 0o700 });
123
+ const file = join(directory, "telemetry-id");
124
+ try {
125
+ const handle = await open(file, "wx", 0o600);
126
+ try {
127
+ await handle.writeFile(randomUUID() + "\n");
128
+ }
129
+ finally {
130
+ await handle.close();
131
+ }
132
+ process.stderr.write("ClawScarf reports CLI usage and failure codes to PostHog. Disable with CLAWSCARF_TELEMETRY_DISABLED=1. Details: https://github.com/clawscarf/clawscarf/blob/main/deploy/deployment/installation.md#telemetry\n");
133
+ }
134
+ catch (error) {
135
+ if (!(error instanceof Error &&
136
+ "code" in error &&
137
+ error.code === "EEXIST"))
138
+ throw error;
139
+ }
140
+ // A concurrent creator may still be writing. Skip this invocation if incomplete.
141
+ this.distinctId = z.uuid().parse((await readFile(file, "utf8")).trim());
142
+ const names = [];
143
+ for (let current = command; current.parent; current = current.parent)
144
+ names.unshift(current.name());
145
+ this.command = names.join(" ");
146
+ const options = command.optsWithGlobals();
147
+ this.interactive =
148
+ [process.stdin.isTTY, process.stdout.isTTY].every((isTTY) => isTTY) &&
149
+ !options.nonInteractive;
150
+ if (this.command === "stop")
151
+ this.action = options.delete ? "delete" : "stop";
152
+ const dispatcher = new Agent({ connect: { timeout: 500 } });
153
+ this.dispatcher = dispatcher;
154
+ this.client = new PostHog(destination.projectToken, {
155
+ host: destination.host,
156
+ isServer: false,
157
+ disableGeoip: true,
158
+ enableExceptionAutocapture: false,
159
+ enableLocalEvaluation: false,
160
+ flushInterval: 0,
161
+ fetchRetryCount: 0,
162
+ requestTimeout: 500,
163
+ disableCompression: true,
164
+ fetch: async (url, options) => {
165
+ // Only uncompressed event JSON is supported; never follow ingestion redirects.
166
+ if (typeof options.body !== "string")
167
+ throw Error("Expected event JSON");
168
+ const response = await fetch(url, {
169
+ method: options.method,
170
+ headers: options.headers,
171
+ body: options.body,
172
+ dispatcher,
173
+ redirect: "error",
174
+ signal: AbortSignal.timeout(500),
175
+ });
176
+ const body = await response.text();
177
+ return {
178
+ status: response.status,
179
+ headers: response.headers,
180
+ text: () => Promise.resolve(body),
181
+ json: () => Promise.resolve().then(() => JSON.parse(body)),
182
+ };
183
+ },
184
+ });
185
+ this.startTime = performance.now();
186
+ this.started = this.capture("cli_command_started");
187
+ }
188
+ catch {
189
+ // Telemetry configuration/storage must never prevent a command from running.
190
+ await this.dispatcher?.destroy().catch(() => { });
191
+ this.client = undefined;
192
+ }
193
+ }
194
+ configurationMode(mode) {
195
+ this.mode = mode;
196
+ }
197
+ result(value) {
198
+ if (!value || typeof value !== "object")
199
+ return;
200
+ if ("state" in value && value.state === "cancelled")
201
+ this.outcome = "cancelled";
202
+ else if ("state" in value && value.state === "action_required")
203
+ this.outcome = "action_required";
204
+ else if (this.command === "configure" || this.command === "start") {
205
+ if ("ready" in value && value.ready === false)
206
+ this.outcome = "action_required";
207
+ }
208
+ if (this.command !== "configure")
209
+ return;
210
+ if ("ready" in value && value.ready === true)
211
+ this.configuration = "ready";
212
+ else if ("state" in value && value.state === "unchanged")
213
+ this.configuration = "unchanged";
214
+ else if ("state" in value && value.state === "prepared")
215
+ this.configuration = "saved";
216
+ }
217
+ async finish(exitCode, failureCode) {
218
+ if (!this.client)
219
+ return;
220
+ const outcome = exitCode === 130 || failureCode === "cancelled"
221
+ ? "cancelled"
222
+ : failureCode !== undefined ||
223
+ (exitCode !== undefined && Number(exitCode) !== 0)
224
+ ? "failure"
225
+ : this.outcome;
226
+ const code = errorCodeSchema.safeParse(failureCode);
227
+ try {
228
+ await Promise.all([
229
+ this.started,
230
+ this.capture("cli_command_finished", {
231
+ outcome,
232
+ duration_ms: Math.round(performance.now() - this.startTime),
233
+ ...(outcome === "failure"
234
+ ? {
235
+ error_code: code.success ? code.data : "operation_failed",
236
+ operation: this.command,
237
+ }
238
+ : {}),
239
+ ...(this.mode ? { configuration_mode: this.mode } : {}),
240
+ ...(this.configuration
241
+ ? { configuration_outcome: this.configuration }
242
+ : {}),
243
+ }),
244
+ ]);
245
+ await this.client.shutdown(1000);
246
+ }
247
+ catch {
248
+ // Best effort only: no retries, offline queue, output, or changed exit status.
249
+ }
250
+ finally {
251
+ await this.dispatcher?.destroy().catch(() => { });
252
+ this.client = undefined;
253
+ }
254
+ }
255
+ async capture(event, result = {}) {
256
+ try {
257
+ await this.client?.captureImmediate({
258
+ distinctId: this.distinctId,
259
+ event,
260
+ timestamp: new Date(),
261
+ properties: {
262
+ command: this.command,
263
+ invocation_id: this.invocationId,
264
+ cli_version: this.version,
265
+ os: process.platform,
266
+ architecture: process.arch,
267
+ interactive: this.interactive,
268
+ ...(this.action ? { action: this.action } : {}),
269
+ ...result,
270
+ $process_person_profile: false,
271
+ $ip: null,
272
+ },
273
+ });
274
+ }
275
+ catch {
276
+ // Never forward telemetry errors (including remote response bodies) to output.
277
+ }
278
+ }
279
+ }
@@ -1,37 +0,0 @@
1
- {
2
- "schemaVersion": 1,
3
- "version": "0.1.0-alpha.1",
4
- "sourceRevision": "a53e95251321e61d8ffd6d06a6984f651c0b0db4",
5
- "platforms": [
6
- "darwin-arm64"
7
- ],
8
- "images": {
9
- "postgres": "postgres@sha256:742f40ea20b9ff2ff31db5458d127452988a2164df9e17441e191f3b72252193",
10
- "gateway": "ghcr.io/clawscarf/clawscarf/runtime@sha256:eaa25c8e487970176b95de657df8bf4faff605f9f265ca760197800d9a1e5fcf",
11
- "companion": "ghcr.io/clawscarf/clawscarf/companion@sha256:97cfeb78ab56ba625b30aafe2249633d9b69f7f3c75de7116453385010e6281f",
12
- "openshellClient": "ghcr.io/clawscarf/clawscarf/openshell-client@sha256:669f37f654926fc7714783cc8b1b76bae9666895c2a4da959580037d281ebf56",
13
- "relay": "ghcr.io/clawscarf/clawscarf/browser-relay@sha256:5ee13e9496e3b76e076e91c59a861a10ad7a31a13b145384fba314d7f0b686b5",
14
- "models": "ghcr.io/berriai/litellm:v1.100.1@sha256:a3715fa7ad8387941ab697259bd2881d68931657247a41984f90fae6d11c62bf",
15
- "browser": {
16
- "chromium": "ghcr.io/clawscarf/clawscarf/browser@sha256:28ca9e4007d3b7c1bb9cb8f4bbaf275d1bc36ee2744079326b151b1f9eec78fd",
17
- "node": "ghcr.io/clawscarf/clawscarf/browser-node@sha256:2837d8b75df69fb39c7f7ca8fa68249d9de6e20d4e028213792de5f8f9ef1bd7",
18
- "dns": "ghcr.io/clawscarf/clawscarf/browser-dns@sha256:9f14cb85df522014c2bfc068e2b5788568f26b6e8d948cebe1250aff7630847b",
19
- "egress": "ghcr.io/clawscarf/clawscarf/browser-egress@sha256:bd819dffa4e1f60fbeed3b459a26b3b274300a4e5836bb90cbd080f6681d594c"
20
- }
21
- },
22
- "tools": {
23
- "openshell": {
24
- "version": "0.0.116",
25
- "cli": {
26
- "file": "tools/openshell",
27
- "sha256": "0baeeffe0e4c184b08fde910ac9e8815fb0b8cfb808e49c0e634d7db65fb03bb",
28
- "url": "https://github.com/clawscarf/clawscarf/releases/download/v0.1.0-alpha.1/openshell-darwin-arm64"
29
- },
30
- "gateway": {
31
- "file": "tools/openshell-gateway",
32
- "sha256": "298706259ef18350f334c222e981e91583558511d50bce22727c689d261203e6",
33
- "url": "https://github.com/clawscarf/clawscarf/releases/download/v0.1.0-alpha.1/openshell-gateway-darwin-arm64"
34
- }
35
- }
36
- }
37
- }
@@ -1,80 +0,0 @@
1
- import { isDeepStrictEqual } from "node:util";
2
- import { readFile, rm } from "node:fs/promises";
3
- import { join } from "node:path";
4
- import { z } from "zod";
5
- import { run } from "./process.js";
6
- import { resourceNames, writePrivate } from "./state.js";
7
- import { runtimeManager } from "./runtime.js";
8
- const ruleSchema = z.object({
9
- name: z.string(),
10
- endpoints: z.array(z.object({ host: z.string(), port: z.number(), tls: z.string() })),
11
- binaries: z.array(z.object({ path: z.string() })),
12
- });
13
- const policySchema = z.looseObject({
14
- network_policies: z.record(z.string(), z.unknown()),
15
- });
16
- /** Apply only the explicitly selected broker rule; never regenerate an operator's other policies. */
17
- export async function applyConnectionPolicy(directory, state, env, verify = false) {
18
- const pending = join(directory, "private/connection-policy-change.json");
19
- let text;
20
- try {
21
- text = await readFile(pending, "utf8");
22
- }
23
- catch (error) {
24
- if (error instanceof Error && "code" in error && error.code === "ENOENT")
25
- return;
26
- throw error;
27
- }
28
- const change = z
29
- .strictObject({
30
- ownerId: z.literal(state.ownerId),
31
- rule: ruleSchema.nullable(),
32
- })
33
- .parse(JSON.parse(text));
34
- const base = join(directory, "private/runtime-policy.json");
35
- const update = (policy) => {
36
- if (change.rule)
37
- policy.network_policies.connections_broker = change.rule;
38
- else
39
- delete policy.network_policies.connections_broker;
40
- return policy;
41
- };
42
- const matches = (rule) => {
43
- if (change.rule === null)
44
- return rule === undefined;
45
- const parsed = ruleSchema.safeParse(rule);
46
- return parsed.success && isDeepStrictEqual(parsed.data, change.rule);
47
- };
48
- const name = resourceNames(state).sandbox;
49
- if (!(await runtimeManager(directory, state, env, run).recorded()).receipt) {
50
- if (verify)
51
- throw Error("The configured runtime has not been recorded.");
52
- await writePrivate(base, JSON.stringify(update(policySchema.parse(JSON.parse(await readFile(base, "utf8"))))));
53
- return;
54
- }
55
- const observed = z
56
- .object({
57
- sandbox: z.literal(name),
58
- policy_source: z.literal("sandbox"),
59
- policy: policySchema,
60
- })
61
- .parse(JSON.parse(await run(state.input.openshellCli, [
62
- "policy",
63
- "get",
64
- name,
65
- "--gateway",
66
- name,
67
- "--base",
68
- "--output",
69
- "json",
70
- ], { env })));
71
- if (matches(observed.policy.network_policies.connections_broker)) {
72
- if (verify)
73
- await rm(pending);
74
- return;
75
- }
76
- if (verify)
77
- throw Error("The Connections network permission was not confirmed. The installation remains unavailable.");
78
- await writePrivate(base, JSON.stringify(update(observed.policy)));
79
- await run(state.input.openshellCli, ["policy", "set", name, "--gateway", name, "--policy", base], { env });
80
- }
@@ -1,148 +0,0 @@
1
- """Pinned public OpenShell protobuf RPCs absent from its high-level SandboxClient.
2
-
3
- Operator-only: private mTLS material and snapshots never enter the runtime image.
4
- The published SDK supplies generated messages; no private client attributes are used.
5
- """
6
- import base64
7
- import json
8
- import pathlib
9
- import sys
10
-
11
- import grpc
12
- from openshell._proto import openshell_pb2 as api, openshell_pb2_grpc, sandbox_pb2 as policy
13
-
14
-
15
- class Refused(Exception):
16
- pass
17
-
18
-
19
- def encode(message):
20
- return base64.b64encode(message.SerializeToString(deterministic=True)).decode("ascii")
21
-
22
-
23
- def decode(value, kind):
24
- result = kind()
25
- result.ParseFromString(base64.b64decode(value, validate=True))
26
- return result
27
-
28
-
29
- def current(stub, request):
30
- result = stub.GetSandbox(api.GetSandboxRequest(name=request["name"], workspace="default"), timeout=15).sandbox
31
- if result.metadata.id != request["id"] or result.metadata.labels.get("clawscarf.installation") != request["ownerId"]:
32
- raise Refused("identity_changed")
33
- return result
34
-
35
-
36
- def config(stub, sandbox):
37
- return stub.GetSandboxConfig(policy.GetSandboxConfigRequest(sandbox_id=sandbox.metadata.id), timeout=15)
38
-
39
-
40
- def no_globals(stub, effective):
41
- global_settings = stub.GetGatewayConfig(policy.GetGatewayConfigRequest(), timeout=15)
42
- if any(value.WhichOneof("value") is not None for value in global_settings.settings.values()) or effective.policy_source != policy.POLICY_SOURCE_SANDBOX:
43
- raise Refused("global_overrides_unsupported")
44
-
45
-
46
- def snapshot(stub, request):
47
- sandbox = current(stub, request)
48
- if sandbox.status.phase != api.SANDBOX_PHASE_STOPPED:
49
- raise Refused("runtime_not_stopped")
50
- effective = config(stub, sandbox)
51
- no_globals(stub, effective)
52
- revision = stub.GetSandboxPolicyStatus(api.GetSandboxPolicyStatusRequest(name=request["name"], workspace="default"), timeout=15)
53
- if revision.revision.status != api.POLICY_STATUS_LOADED or revision.active_version != revision.revision.version:
54
- raise Refused("policy_not_loaded")
55
- spec = api.SandboxSpec()
56
- spec.CopyFrom(sandbox.spec)
57
- spec.policy.CopyFrom(revision.revision.policy)
58
- if list(spec.command) != ["/app/clawscarf/bin/openclaw", "gateway"]:
59
- raise Refused("runtime_command_unsupported")
60
- create = api.CreateSandboxRequest(spec=spec, name=request["name"], workspace="default",
61
- labels=sandbox.metadata.labels, annotations=sandbox.metadata.annotations)
62
- result = {"create": encode(create), "effective": encode(effective),
63
- "resourceVersion": str(sandbox.metadata.resource_version)}
64
- # Reads are not a transaction: reject a revision changing during the snapshot.
65
- if current(stub, request).metadata.resource_version != sandbox.metadata.resource_version or encode(config(stub, sandbox)) != result["effective"]:
66
- raise Refused("configuration_changed")
67
- return result
68
-
69
-
70
- def restored(stub, request, expected):
71
- sandbox = current(stub, request)
72
- actual = config(stub, sandbox)
73
- no_globals(stub, actual)
74
- if actual.policy != expected.policy or actual.settings != expected.settings:
75
- raise Refused("restoration_unverified")
76
- original = decode(request["snapshot"]["create"], api.CreateSandboxRequest)
77
- if list(sandbox.spec.providers) != list(original.spec.providers):
78
- raise Refused("configuration_changed")
79
- return {"verified": True}
80
-
81
-
82
- def perform(stub, request):
83
- action = request["action"]
84
- if action == "snapshot":
85
- return snapshot(stub, request)
86
- original = decode(request["snapshot"]["create"], api.CreateSandboxRequest)
87
- expected = decode(request["snapshot"]["effective"], policy.GetSandboxConfigResponse)
88
- if original.name != request["name"] or original.labels.get("clawscarf.installation") != request["ownerId"]:
89
- raise Refused("identity_changed")
90
- if action == "create":
91
- no_globals(stub, expected)
92
- original.spec.template.image = request["image"]
93
- original.spec.environment["CLAWSCARF_START_GATE"] = request["generation"]
94
- original.labels["clawscarf.upgrade"] = request["generation"]
95
- result = stub.CreateSandbox(original, timeout=120).sandbox
96
- return {"id": result.metadata.id}
97
- if action == "verify":
98
- return restored(stub, request, expected)
99
- if action == "restore":
100
- sandbox = current(stub, request)
101
- no_globals(stub, config(stub, sandbox))
102
- for key, setting in expected.settings.items():
103
- if setting.scope != policy.SETTING_SCOPE_SANDBOX or setting.value.WhichOneof("value") is None:
104
- continue
105
- sandbox = current(stub, request)
106
- observed = config(stub, sandbox).settings.get(key)
107
- if observed is not None and observed == setting:
108
- continue
109
- if observed is not None and observed.value.WhichOneof("value") is not None:
110
- raise Refused("configuration_changed")
111
- # The pinned setting-update API has no atomic revision precondition.
112
- # Explicit resume skips observed matches and reapplies only unset values;
113
- # exclusive operator control prevents concurrent edits.
114
- stub.UpdateConfig(api.UpdateConfigRequest(name=request["name"], workspace="default", setting_key=key,
115
- setting_value=setting.value), timeout=15)
116
- return restored(stub, request, expected)
117
- raise Refused("invalid_request")
118
-
119
-
120
- def main():
121
- try:
122
- raw = sys.stdin.buffer.read(4 * 1024 * 1024 + 1)
123
- if len(raw) > 4 * 1024 * 1024:
124
- raise Refused("invalid_request")
125
- request = json.loads(raw)
126
- directory = pathlib.Path(request["controller"])
127
- settings = json.loads((directory / "controller.json").read_text())
128
- if settings["name"] != request["name"]:
129
- raise Refused("identity_changed")
130
- tls = directory / "tls"
131
- credentials = grpc.ssl_channel_credentials((tls / "ca.crt").read_bytes(),
132
- (tls / "client/tls.key").read_bytes(), (tls / "client/tls.crt").read_bytes())
133
- with grpc.secure_channel(f"127.0.0.1:{settings['port']}", credentials) as channel:
134
- result = perform(openshell_pb2_grpc.OpenShellStub(channel), request)
135
- output = json.dumps({"ok": True, "value": result})
136
- if len(output.encode()) > 4 * 1024 * 1024:
137
- raise Refused("response_too_large")
138
- print(output)
139
- except Refused as error:
140
- print(json.dumps({"ok": False, "code": str(error)}))
141
- except grpc.RpcError as error:
142
- print(json.dumps({"ok": False, "code": error.code().name}))
143
- except Exception:
144
- print(json.dumps({"ok": False, "code": "unavailable"}))
145
-
146
-
147
- if __name__ == "__main__":
148
- main()