openmausbot 0.1.71 → 0.1.72

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 (44) hide show
  1. package/dist/assets/index-BFPYw6P-.js +296 -0
  2. package/dist/assets/{index-CJtyBonr.js → index-Bpai86ua.js} +1 -1
  3. package/dist/assets/index-D1odUdTi.css +1 -0
  4. package/dist/index.html +2 -2
  5. package/dist-server/companion/src/listener.js +12 -2
  6. package/dist-server/container-mcp.js +26 -2
  7. package/dist-server/index.js +7477 -2481
  8. package/dist-server/local-computer.js +4 -0
  9. package/dist-server/openmausbot.js +1589 -565
  10. package/dist-server/pair-cli.js +1589 -565
  11. package/dist-server/server/bot-overview.js +28 -0
  12. package/dist-server/server/browser-engine.js +2 -1
  13. package/dist-server/server/calendar-calls.js +1 -0
  14. package/dist-server/server/cli.js +102 -1
  15. package/dist-server/server/config.js +57 -5
  16. package/dist-server/server/drivers/claude.js +14 -7
  17. package/dist-server/server/fleet-agent.js +161 -0
  18. package/dist-server/server/fleet-cli.js +225 -0
  19. package/dist-server/server/fleet-client.js +45 -0
  20. package/dist-server/server/fleet.js +410 -0
  21. package/dist-server/server/index.js +316 -8
  22. package/dist-server/server/local-computer.js +4 -0
  23. package/dist-server/server/mcp-registry.js +71 -3
  24. package/dist-server/server/prices.js +16 -0
  25. package/dist-server/server/provider-auth-sessions.js +3 -0
  26. package/dist-server/server/provider-key-check.js +66 -0
  27. package/dist-server/server/routines.js +1 -0
  28. package/dist-server/server/skill-learn.js +15 -1
  29. package/dist-server/server/spend.js +61 -0
  30. package/dist-server/server/system-prompt.js +15 -5
  31. package/dist-server/server/tailscale.js +2 -2
  32. package/dist-server/server/usage-ledger.js +239 -0
  33. package/dist-server/server/webhook-ingress.js +7 -2
  34. package/dist-server/server/workspace-backup-http.js +264 -0
  35. package/dist-server/server/workspace-backup-maintenance.js +40 -0
  36. package/dist-server/server/workspace-backup-policy.js +28 -0
  37. package/dist-server/server/workspace-backup.js +1022 -0
  38. package/dist-server/shared/learn-request.js +6 -0
  39. package/dist-server/shared/workspace-backup-client.js +15 -0
  40. package/dist-server/shared/workspace-backup.js +1 -0
  41. package/dist-server/vps-container-mcp.js +26 -2
  42. package/package.json +1 -1
  43. package/dist/assets/index-CYLdYtYp.css +0 -1
  44. package/dist/assets/index-Di4ovwu5.js +0 -289
@@ -0,0 +1,225 @@
1
+ // `openmausbot fleet`: many client workspaces on one Linux server. The plans
2
+ // come from fleet.ts; this runs them as root with fixed argument lists, or
3
+ // prints them for an operator to paste into a root shell, and never builds
4
+ // a shell command from user input. The fleet agent (fleet-agent.ts) reuses
5
+ // the same planner and executor over a Unix socket.
6
+ import { spawn } from "node:child_process";
7
+ import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync, writeFileSync } from "node:fs";
8
+ import { posix } from "node:path";
9
+ import { unstableInstallWarning } from "./service-unit.js";
10
+ import { applySignIn, assertSlug, createPlan, deletePlan, describeSteps, fleetLayout, fleetUser, initPlan, parseRegistry, resumePlan, suspendPlan, upgradePlan, workspaceDataDir, } from "./fleet.js";
11
+ export function defaultFleetDeps() {
12
+ return {
13
+ isRoot: () => typeof process.getuid === "function" && process.getuid() === 0,
14
+ run: (argv) => new Promise((resolve) => {
15
+ const [command, ...args] = argv;
16
+ let output = "";
17
+ try {
18
+ const child = spawn(command, args, { stdio: ["ignore", "pipe", "pipe"] });
19
+ const receive = (chunk) => { if (output.length < 16_384)
20
+ output += chunk.toString("utf8"); };
21
+ child.stdout.on("data", receive);
22
+ child.stderr.on("data", receive);
23
+ child.once("error", (error) => resolve({ code: null, output: `${output}${error.message}` }));
24
+ child.once("close", (code) => resolve({ code, output }));
25
+ }
26
+ catch (error) {
27
+ resolve({ code: null, output: error instanceof Error ? error.message : String(error) });
28
+ }
29
+ }),
30
+ readText: (path) => (existsSync(path) ? readFileSync(path, "utf8") : null),
31
+ writeText: (path, content, mode) => writeFileSync(path, content, { mode }),
32
+ mkdir: (path, mode) => mkdirSync(path, { recursive: true, mode }),
33
+ appendOnce: (path, line) => {
34
+ const current = existsSync(path) ? readFileSync(path, "utf8") : "";
35
+ if (!current.split("\n").some((existing) => existing.trim() === line))
36
+ appendFileSync(path, `${current.endsWith("\n") || !current ? "" : "\n"}\n${line}\n`);
37
+ },
38
+ remove: (path) => rmSync(path, { force: true }),
39
+ health: async (url, timeoutMs) => {
40
+ const deadline = Date.now() + timeoutMs;
41
+ while (Date.now() < deadline) {
42
+ try {
43
+ const response = await fetch(url, { signal: AbortSignal.timeout(2000) });
44
+ if (response.ok)
45
+ return true;
46
+ }
47
+ catch {
48
+ /* not up yet */
49
+ }
50
+ await new Promise((resolve) => setTimeout(resolve, 1000));
51
+ }
52
+ return false;
53
+ },
54
+ };
55
+ }
56
+ const HEALTH_TIMEOUT_MS = 60_000;
57
+ /** Run a plan as root. Stops at the first failed step, naming it and showing
58
+ * only the tool's last lines, never a secret from the plan. */
59
+ export async function executePlan(steps, deps, io) {
60
+ for (const step of steps) {
61
+ switch (step.kind) {
62
+ case "mkdir":
63
+ deps.mkdir(step.path, step.mode);
64
+ if (step.owner) {
65
+ const chown = await deps.run(["chown", `${step.owner}:${step.owner}`, step.path]);
66
+ if (chown.code !== 0)
67
+ return failed(io, `chown ${step.path}`, chown.output);
68
+ }
69
+ break;
70
+ case "write":
71
+ deps.writeText(step.path, step.content, step.mode);
72
+ if (step.owner) {
73
+ const chown = await deps.run(["chown", `${step.owner}:${step.owner}`, step.path]);
74
+ if (chown.code !== 0)
75
+ return failed(io, `chown ${step.path}`, chown.output);
76
+ }
77
+ break;
78
+ case "append-once":
79
+ deps.appendOnce(step.path, step.line);
80
+ break;
81
+ case "remove":
82
+ deps.remove(step.path);
83
+ break;
84
+ case "run": {
85
+ const result = await deps.run(step.argv);
86
+ if (result.code !== 0)
87
+ return failed(io, `${step.why} (${step.argv[0]} exited ${result.code ?? "without a code"})`, result.output);
88
+ break;
89
+ }
90
+ case "health":
91
+ if (!(await deps.health(step.url, HEALTH_TIMEOUT_MS)))
92
+ return failed(io, step.why, `${step.url} did not answer within ${HEALTH_TIMEOUT_MS / 1000}s; check journalctl -u openmausbot@<slug>`);
93
+ break;
94
+ case "note":
95
+ io.log(step.text);
96
+ break;
97
+ }
98
+ }
99
+ return 0;
100
+ }
101
+ function failed(io, what, output) {
102
+ io.error(`fleet: could not ${what}`);
103
+ const tail = output.trim().split("\n").slice(-6).join("\n");
104
+ if (tail)
105
+ io.error(tail);
106
+ return 1;
107
+ }
108
+ export function loadRegistry(layout, deps) {
109
+ const text = deps.readText(layout.registryFile);
110
+ if (text === null)
111
+ throw new Error(`no fleet on this server yet: run \`openmausbot fleet init --domain your.domain\` first`);
112
+ return parseRegistry(text);
113
+ }
114
+ /** The steps an action means on this machine. Throws on anything invalid
115
+ * before a single step runs; `list` has no steps and is answered directly. */
116
+ export function planFleetAction(input, deps) {
117
+ const layout = fleetLayout(input.root ?? "/");
118
+ switch (input.action) {
119
+ case "init": {
120
+ if (!input.domain)
121
+ throw new Error("fleet init needs --domain, the domain whose subdomains the workspaces live at");
122
+ const warning = unstableInstallWarning(input.script);
123
+ if (warning)
124
+ throw new Error(warning);
125
+ const existing = deps.readText(layout.registryFile);
126
+ if (existing !== null && !input.yes)
127
+ throw new Error(`${layout.registryFile} exists; pass --yes to rewrite the template unit and fence (workspaces are kept)`);
128
+ const plan = initPlan({ domain: input.domain, node: input.node, script: input.script, operator: input.operator, layout });
129
+ if (existing) {
130
+ // Keep the workspaces already registered; only the domain and templates are rewritten.
131
+ const kept = parseRegistry(existing);
132
+ plan.registry.workspaces = kept.workspaces;
133
+ plan.registry.nextPort = kept.nextPort;
134
+ return plan.steps.map((step) => (step.kind === "write" && step.path === layout.registryFile ? { ...step, content: `${JSON.stringify(plan.registry, null, 2)}\n` } : step));
135
+ }
136
+ return plan.steps;
137
+ }
138
+ case "create": {
139
+ if (!input.slug)
140
+ throw new Error("fleet create needs a workspace name");
141
+ const registry = loadRegistry(layout, deps);
142
+ const brandJson = input.brandJson ?? (input.brandFile ? deps.readText(input.brandFile) : undefined);
143
+ if (input.brandFile && brandJson === null)
144
+ throw new Error(`${input.brandFile} not found`);
145
+ if (brandJson)
146
+ JSON.parse(brandJson);
147
+ const anthropicKey = input.anthropicKey?.trim() || (input.anthropicKeyFile ? deps.readText(input.anthropicKeyFile)?.trim() : undefined);
148
+ if (input.anthropicKeyFile && !anthropicKey)
149
+ throw new Error(`${input.anthropicKeyFile} is missing or empty`);
150
+ return createPlan({
151
+ registry,
152
+ slug: input.slug,
153
+ seed: { admins: input.admins, members: input.members, ...(anthropicKey ? { anthropicKey } : {}), ...(input.cap !== undefined ? { monthlyCapUsd: input.cap } : {}), ...(brandJson ? { brandJson } : {}) },
154
+ ...(input.licenseKey ? { licenseKey: input.licenseKey } : {}),
155
+ ...(input.memory ? { memoryMax: input.memory } : {}),
156
+ layout,
157
+ }).steps;
158
+ }
159
+ case "users": {
160
+ if (!input.slug || !input.userAction || !input.email)
161
+ throw new Error("fleet users needs: NAME add|remove EMAIL [--chat-only]");
162
+ assertSlug(input.slug);
163
+ const registry = loadRegistry(layout, deps);
164
+ if (!registry.workspaces[input.slug])
165
+ throw new Error(`no workspace "${input.slug}"`);
166
+ const file = posix.join(workspaceDataDir(layout, input.slug), "config.json");
167
+ const next = applySignIn(deps.readText(file) ?? "{}", input.userAction, input.email, Boolean(input.chatOnly));
168
+ return [
169
+ { kind: "write", path: file, content: next.config, mode: 0o600, owner: fleetUser(input.slug) },
170
+ { kind: "note", text: next.summary },
171
+ ];
172
+ }
173
+ case "suspend":
174
+ case "resume": {
175
+ if (!input.slug)
176
+ throw new Error(`fleet ${input.action} needs a workspace name`);
177
+ const registry = loadRegistry(layout, deps);
178
+ return (input.action === "suspend" ? suspendPlan({ registry, slug: input.slug, layout }) : resumePlan({ registry, slug: input.slug, layout })).steps;
179
+ }
180
+ case "delete": {
181
+ if (!input.slug)
182
+ throw new Error("fleet delete needs a workspace name");
183
+ if (!input.yes)
184
+ throw new Error(`fleet delete removes the workspace's account${input.keepData ? "" : " and all of its data"}; pass --yes to confirm${input.keepData ? "" : ", or --keep-data to keep the home folder"}`);
185
+ const registry = loadRegistry(layout, deps);
186
+ return deletePlan({ registry, slug: input.slug, keepData: input.keepData, layout }).steps;
187
+ }
188
+ case "upgrade":
189
+ return upgradePlan({ registry: loadRegistry(layout, deps) });
190
+ case "list":
191
+ return [];
192
+ }
193
+ }
194
+ function printPlan(steps, io, reason) {
195
+ io.log(`${reason}; run these as root:`);
196
+ io.log("");
197
+ for (const line of describeSteps(steps))
198
+ io.log(line);
199
+ return 0;
200
+ }
201
+ export async function runFleetCommand(input, io, deps = defaultFleetDeps()) {
202
+ const layout = fleetLayout(input.root ?? "/");
203
+ const asRoot = deps.isRoot() && !input.dryRun;
204
+ try {
205
+ if (input.action === "list") {
206
+ const registry = loadRegistry(layout, deps);
207
+ const rows = Object.values(registry.workspaces).sort((a, b) => a.slug.localeCompare(b.slug));
208
+ if (!rows.length) {
209
+ io.log(`no workspaces yet on ${registry.domain}; create one with: openmausbot fleet create NAME --admin you@example.com`);
210
+ return 0;
211
+ }
212
+ for (const workspace of rows) {
213
+ const live = asRoot ? (await deps.run(["systemctl", "is-active", `openmausbot@${workspace.slug}.service`])).output.trim() : workspace.status;
214
+ io.log(`${workspace.slug.padEnd(24)} https://${workspace.host.padEnd(40)} :${String(workspace.port).padEnd(6)} ${live}`);
215
+ }
216
+ return 0;
217
+ }
218
+ const steps = planFleetAction(input, deps);
219
+ return asRoot ? await executePlan(steps, deps, io) : printPlan(steps, io, input.dryRun ? "dry run" : "not running as root");
220
+ }
221
+ catch (error) {
222
+ io.error(`fleet: ${error instanceof Error ? error.message : String(error)}`);
223
+ return 2;
224
+ }
225
+ }
@@ -0,0 +1,45 @@
1
+ // The operator workspace's side of the fleet agent: one request over the
2
+ // agent's Unix socket, JSON in and out. The socket's permissions are the
3
+ // whole authorisation; this process can open it only because the operator
4
+ // user was named at `fleet init`.
5
+ import { existsSync } from "node:fs";
6
+ import { request } from "node:http";
7
+ export function fleetSocketPath(env = process.env) {
8
+ return env.OMB_FLEET_SOCKET?.trim() || "/run/openmausbot/fleet.sock";
9
+ }
10
+ /** Whether this server can reach a fleet agent at all. */
11
+ export function fleetAvailable(socketPath = fleetSocketPath()) {
12
+ return existsSync(socketPath);
13
+ }
14
+ export function fleetRequest(socketPath, method, path, body, timeoutMs = 15 * 60_000) {
15
+ return new Promise((resolve, reject) => {
16
+ const payload = body === undefined ? undefined : JSON.stringify(body);
17
+ const req = request({ socketPath, method, path, headers: { "content-type": "application/json", ...(payload ? { "content-length": Buffer.byteLength(payload) } : {}) }, timeout: timeoutMs }, (res) => {
18
+ let text = "";
19
+ res.setEncoding("utf8");
20
+ res.on("data", (chunk) => { if (text.length < 1_000_000)
21
+ text += chunk; });
22
+ res.on("end", () => {
23
+ let parsed = null;
24
+ try {
25
+ parsed = text ? JSON.parse(text) : null;
26
+ }
27
+ catch {
28
+ parsed = { error: "the fleet agent answered something that was not JSON" };
29
+ }
30
+ resolve({ status: res.statusCode ?? 502, body: parsed });
31
+ });
32
+ });
33
+ req.on("timeout", () => { req.destroy(new Error("the fleet agent did not answer in time")); });
34
+ req.on("error", (error) => {
35
+ reject(new Error(error.code === "ENOENT" || error.code === "ECONNREFUSED"
36
+ ? "no fleet agent on this server: run `openmausbot fleet init --domain … --operator <this user>` as root"
37
+ : error.code === "EACCES"
38
+ ? "this workspace's user may not open the fleet socket: re-run `openmausbot fleet init` with --operator set to it"
39
+ : `fleet agent: ${error.message}`));
40
+ });
41
+ if (payload)
42
+ req.write(payload);
43
+ req.end();
44
+ });
45
+ }