openmausbot 0.1.71 → 0.1.73
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/dist/assets/index-BLJ7byG3.js +296 -0
- package/dist/assets/{index-CJtyBonr.js → index-BYbK7ber.js} +1 -1
- package/dist/assets/index-De2TRLxu.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/companion/src/listener.js +12 -2
- package/dist-server/container-mcp.js +26 -2
- package/dist-server/drivers/agents-proxy.js +2 -2
- package/dist-server/index.js +7603 -2509
- package/dist-server/local-computer.js +4 -0
- package/dist-server/openmausbot.js +1639 -581
- package/dist-server/pair-cli.js +1639 -581
- package/dist-server/server/bot-overview.js +28 -0
- package/dist-server/server/browser-engine.js +2 -1
- package/dist-server/server/calendar-calls.js +1 -0
- package/dist-server/server/cli.js +102 -1
- package/dist-server/server/config.js +57 -5
- package/dist-server/server/drivers/agents-proxy.js +2 -2
- package/dist-server/server/drivers/claude.js +14 -7
- package/dist-server/server/drivers/codex.js +64 -16
- package/dist-server/server/fleet-agent.js +161 -0
- package/dist-server/server/fleet-cli.js +225 -0
- package/dist-server/server/fleet-client.js +45 -0
- package/dist-server/server/fleet.js +410 -0
- package/dist-server/server/index.js +340 -15
- package/dist-server/server/local-computer.js +4 -0
- package/dist-server/server/mcp-registry.js +71 -3
- package/dist-server/server/prices.js +16 -0
- package/dist-server/server/provider-auth-sessions.js +3 -0
- package/dist-server/server/provider-key-check.js +66 -0
- package/dist-server/server/routines.js +1 -0
- package/dist-server/server/skill-learn.js +15 -1
- package/dist-server/server/spend.js +61 -0
- package/dist-server/server/system-prompt.js +15 -5
- package/dist-server/server/tailscale.js +2 -2
- package/dist-server/server/usage-ledger.js +239 -0
- package/dist-server/server/vps-computer.js +81 -12
- package/dist-server/server/webhook-ingress.js +7 -2
- package/dist-server/server/workspace-backup-http.js +264 -0
- package/dist-server/server/workspace-backup-maintenance.js +40 -0
- package/dist-server/server/workspace-backup-policy.js +28 -0
- package/dist-server/server/workspace-backup.js +1022 -0
- package/dist-server/shared/learn-request.js +6 -0
- package/dist-server/shared/workspace-backup-client.js +15 -0
- package/dist-server/shared/workspace-backup.js +1 -0
- package/dist-server/vps-container-mcp.js +26 -2
- package/package.json +1 -1
- package/dist/assets/index-CYLdYtYp.css +0 -1
- package/dist/assets/index-Di4ovwu5.js +0 -289
|
@@ -0,0 +1,410 @@
|
|
|
1
|
+
// Many client workspaces on one Linux server: each is its own OS user, its
|
|
2
|
+
// own `openmausbot@<slug>` service on its own loopback ports, its own data
|
|
3
|
+
// folder, brand, sign-in list and keys, reached at <slug>.<domain> through
|
|
4
|
+
// the system Caddy. This module only PLANS: it renders files and fixed
|
|
5
|
+
// argument lists as steps, so what the CLI does as root is inspectable and
|
|
6
|
+
// testable, and nothing here ever builds a shell command string.
|
|
7
|
+
import { posix } from "node:path";
|
|
8
|
+
const { join } = posix;
|
|
9
|
+
/** A workspace name: DNS label and Unix user suffix at once. */
|
|
10
|
+
export const FLEET_SLUG = /^[a-z][a-z0-9-]{1,30}$/;
|
|
11
|
+
export const FLEET_USER_PREFIX = "omb-";
|
|
12
|
+
export const FLEET_FIRST_PORT = 8810;
|
|
13
|
+
export const FLEET_PORT_STRIDE = 10;
|
|
14
|
+
export function fleetLayout(root = "/") {
|
|
15
|
+
const at = (...parts) => join(root, ...parts);
|
|
16
|
+
return {
|
|
17
|
+
etcDir: at("etc", "openmausbot"),
|
|
18
|
+
registryFile: at("etc", "openmausbot", "fleet.json"),
|
|
19
|
+
instancesDir: at("etc", "openmausbot", "instances"),
|
|
20
|
+
unitFile: at("etc", "systemd", "system", "openmausbot@.service"),
|
|
21
|
+
fenceFile: at("etc", "openmausbot", "fence.nft"),
|
|
22
|
+
fenceUnitFile: at("etc", "systemd", "system", "openmausbot-fence.service"),
|
|
23
|
+
caddyDir: at("etc", "caddy", "omb.d"),
|
|
24
|
+
caddyfile: at("etc", "caddy", "Caddyfile"),
|
|
25
|
+
homesDir: at("var", "lib", "openmausbot"),
|
|
26
|
+
agentUnitFile: at("etc", "systemd", "system", "openmausbot-fleet.service"),
|
|
27
|
+
socketPath: at("run", "openmausbot", "fleet.sock"),
|
|
28
|
+
auditFile: at("var", "log", "openmausbot", "fleet.jsonl"),
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
/** The agent: root, one socket, reachable by the operator's user only. */
|
|
32
|
+
export function agentUnit(spec) {
|
|
33
|
+
const layout = spec.layout ?? fleetLayout();
|
|
34
|
+
const strip = spec.script.endsWith(".ts") ? " --experimental-strip-types" : "";
|
|
35
|
+
return [
|
|
36
|
+
"# Written by `openmausbot fleet init`. The operator workspace creates and manages workspaces through this.",
|
|
37
|
+
"[Unit]",
|
|
38
|
+
"Description=OpenMausBot fleet agent",
|
|
39
|
+
"After=network-online.target",
|
|
40
|
+
"Wants=network-online.target",
|
|
41
|
+
"",
|
|
42
|
+
"[Service]",
|
|
43
|
+
"Type=simple",
|
|
44
|
+
`ExecStart=${spec.node}${strip} ${spec.script} fleet agent --socket ${layout.socketPath} --group ${spec.operator}`,
|
|
45
|
+
"RuntimeDirectory=openmausbot",
|
|
46
|
+
"RuntimeDirectoryMode=0755",
|
|
47
|
+
"Restart=always",
|
|
48
|
+
"RestartSec=3",
|
|
49
|
+
"",
|
|
50
|
+
"[Install]",
|
|
51
|
+
"WantedBy=multi-user.target",
|
|
52
|
+
"",
|
|
53
|
+
].join("\n");
|
|
54
|
+
}
|
|
55
|
+
export function emptyRegistry(domain) {
|
|
56
|
+
return { version: 1, domain, nextPort: FLEET_FIRST_PORT, workspaces: {} };
|
|
57
|
+
}
|
|
58
|
+
export function parseRegistry(text) {
|
|
59
|
+
const value = JSON.parse(text);
|
|
60
|
+
const record = value && typeof value === "object" ? value : null;
|
|
61
|
+
if (!record || record.version !== 1 || typeof record.domain !== "string" || typeof record.nextPort !== "number" || !record.workspaces || typeof record.workspaces !== "object") {
|
|
62
|
+
throw new Error("fleet.json is not a fleet registry this version understands");
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
version: 1,
|
|
66
|
+
domain: record.domain,
|
|
67
|
+
nextPort: record.nextPort,
|
|
68
|
+
...(typeof record.operator === "string" ? { operator: record.operator } : {}),
|
|
69
|
+
workspaces: { ...record.workspaces },
|
|
70
|
+
};
|
|
71
|
+
}
|
|
72
|
+
export function fleetUser(slug) {
|
|
73
|
+
return `${FLEET_USER_PREFIX}${slug}`;
|
|
74
|
+
}
|
|
75
|
+
export function workspaceHome(layout, slug) {
|
|
76
|
+
return join(layout.homesDir, slug);
|
|
77
|
+
}
|
|
78
|
+
export function workspaceDataDir(layout, slug) {
|
|
79
|
+
return join(workspaceHome(layout, slug), ".openmausbot");
|
|
80
|
+
}
|
|
81
|
+
export function assertSlug(slug) {
|
|
82
|
+
if (!FLEET_SLUG.test(slug))
|
|
83
|
+
throw new Error(`"${slug}" is not a workspace name: lowercase letters, digits and dashes, 2 to 31 characters, starting with a letter`);
|
|
84
|
+
}
|
|
85
|
+
export function assertDomain(domain) {
|
|
86
|
+
if (!/^(?=.{1,253}$)([a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,63}$/i.test(domain))
|
|
87
|
+
throw new Error(`"${domain}" is not a domain name`);
|
|
88
|
+
}
|
|
89
|
+
export function assertEmailOrDomain(entry) {
|
|
90
|
+
if (!entry || /\s/.test(entry) || (!entry.startsWith("@") && !entry.includes("@")))
|
|
91
|
+
throw new Error(`"${entry}" is not an email address or @domain`);
|
|
92
|
+
}
|
|
93
|
+
/** The one unit every workspace runs from; `%i` is the slug. */
|
|
94
|
+
export function templateUnit(spec) {
|
|
95
|
+
const layout = spec.layout ?? fleetLayout();
|
|
96
|
+
const strip = spec.script.endsWith(".ts") ? " --experimental-strip-types" : "";
|
|
97
|
+
return [
|
|
98
|
+
"# Written by `openmausbot fleet init`. One unit for every workspace: %i is the slug.",
|
|
99
|
+
"[Unit]",
|
|
100
|
+
"Description=OpenMausBot workspace %i",
|
|
101
|
+
"After=network-online.target",
|
|
102
|
+
"Wants=network-online.target",
|
|
103
|
+
"",
|
|
104
|
+
"[Service]",
|
|
105
|
+
"Type=simple",
|
|
106
|
+
`User=${FLEET_USER_PREFIX}%i`,
|
|
107
|
+
`Group=${FLEET_USER_PREFIX}%i`,
|
|
108
|
+
`WorkingDirectory=${layout.homesDir}/%i`,
|
|
109
|
+
`EnvironmentFile=${layout.instancesDir}/%i.env`,
|
|
110
|
+
`Environment=HOME=${layout.homesDir}/%i`,
|
|
111
|
+
"Environment=PATH=/usr/local/bin:/usr/bin:/bin",
|
|
112
|
+
`ExecStart=${spec.node}${strip} ${spec.script} serve --port \${OMB_PORT} --data-dir \${OMB_DATA_DIR} --public-url \${OMB_PUBLIC_URL} --label %i --no-pair`,
|
|
113
|
+
"Restart=always",
|
|
114
|
+
"RestartSec=3",
|
|
115
|
+
"KillMode=mixed",
|
|
116
|
+
"TimeoutStopSec=30",
|
|
117
|
+
"# Each workspace gets its own temp, cannot gain privileges, and writes only its own home.",
|
|
118
|
+
"PrivateTmp=yes",
|
|
119
|
+
"NoNewPrivileges=yes",
|
|
120
|
+
"ProtectSystem=strict",
|
|
121
|
+
"ProtectHome=yes",
|
|
122
|
+
`ReadWritePaths=${layout.homesDir}/%i`,
|
|
123
|
+
"",
|
|
124
|
+
"[Install]",
|
|
125
|
+
"WantedBy=multi-user.target",
|
|
126
|
+
"",
|
|
127
|
+
].join("\n");
|
|
128
|
+
}
|
|
129
|
+
export function fenceUnit(layout = fleetLayout()) {
|
|
130
|
+
return [
|
|
131
|
+
"# Written by `openmausbot fleet init`: keeps each workspace's loopback ports to its own user.",
|
|
132
|
+
"[Unit]",
|
|
133
|
+
"Description=OpenMausBot per-workspace loopback fence",
|
|
134
|
+
"Before=openmausbot@.service",
|
|
135
|
+
"",
|
|
136
|
+
"[Service]",
|
|
137
|
+
"Type=oneshot",
|
|
138
|
+
"RemainAfterExit=yes",
|
|
139
|
+
`ExecStart=/usr/sbin/nft -f ${layout.fenceFile}`,
|
|
140
|
+
"",
|
|
141
|
+
"[Install]",
|
|
142
|
+
"WantedBy=multi-user.target",
|
|
143
|
+
"",
|
|
144
|
+
].join("\n");
|
|
145
|
+
}
|
|
146
|
+
/** nftables rules: only a workspace's own user, Caddy and root may open its
|
|
147
|
+
* loopback ports, so a shell-capable bot cannot reach a sibling's API.
|
|
148
|
+
* Written whole each time, so `nft -f` is idempotent. */
|
|
149
|
+
export function fenceRules(workspaces) {
|
|
150
|
+
const lines = ["#!/usr/sbin/nft -f", "# Written by `openmausbot fleet`; regenerated on every create, suspend, resume and delete.", "add table inet openmausbot", "flush table inet openmausbot", "table inet openmausbot {", "\tchain output {", "\t\ttype filter hook output priority 0; policy accept;"];
|
|
151
|
+
for (const workspace of [...workspaces].sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
152
|
+
lines.push(`\t\toif lo tcp dport { ${workspace.port}, ${workspace.webhookPort} } meta skuid != { ${fleetUser(workspace.slug)}, caddy, root } reject`);
|
|
153
|
+
}
|
|
154
|
+
lines.push("\t}", "}", "");
|
|
155
|
+
return lines.join("\n");
|
|
156
|
+
}
|
|
157
|
+
export function caddySite(workspace) {
|
|
158
|
+
if (workspace.status === "suspended") {
|
|
159
|
+
return [`${workspace.host} {`, "\trespond \"This workspace is suspended.\" 503", "}", ""].join("\n");
|
|
160
|
+
}
|
|
161
|
+
return [
|
|
162
|
+
`${workspace.host} {`,
|
|
163
|
+
"\tencode zstd gzip",
|
|
164
|
+
"\thandle /hooks/* {",
|
|
165
|
+
`\t\treverse_proxy 127.0.0.1:${workspace.webhookPort}`,
|
|
166
|
+
"\t}",
|
|
167
|
+
"\thandle {",
|
|
168
|
+
`\t\treverse_proxy 127.0.0.1:${workspace.port} {`,
|
|
169
|
+
"\t\t\tflush_interval -1",
|
|
170
|
+
"\t\t}",
|
|
171
|
+
"\t}",
|
|
172
|
+
"}",
|
|
173
|
+
"",
|
|
174
|
+
].join("\n");
|
|
175
|
+
}
|
|
176
|
+
export function caddyImportLine(layout = fleetLayout()) {
|
|
177
|
+
return `import ${layout.caddyDir}/*.caddy`;
|
|
178
|
+
}
|
|
179
|
+
export function instanceEnv(input) {
|
|
180
|
+
const lines = [
|
|
181
|
+
`OMB_DATA_DIR=${input.dataDir}`,
|
|
182
|
+
`OMB_PORT=${input.workspace.port}`,
|
|
183
|
+
`OMB_WEBHOOK_PORT=${input.workspace.webhookPort}`,
|
|
184
|
+
`OMB_PUBLIC_URL=https://${input.workspace.host}`,
|
|
185
|
+
];
|
|
186
|
+
if (input.licenseKey)
|
|
187
|
+
lines.push(`OMB_LICENSE_KEY=${input.licenseKey}`);
|
|
188
|
+
return lines.join("\n") + "\n";
|
|
189
|
+
}
|
|
190
|
+
/** The workspace's first config.json: who may sign in, the key it runs on,
|
|
191
|
+
* and its cap. Everything else stays the server's defaults. */
|
|
192
|
+
export function initialConfig(seed) {
|
|
193
|
+
const config = { signIn: { admins: seed.admins, members: seed.members } };
|
|
194
|
+
if (seed.anthropicKey)
|
|
195
|
+
config.anthropic = { key: seed.anthropicKey };
|
|
196
|
+
if (seed.monthlyCapUsd !== undefined)
|
|
197
|
+
config.budgets = { monthlyUsd: seed.monthlyCapUsd };
|
|
198
|
+
return `${JSON.stringify(config, null, 2)}\n`;
|
|
199
|
+
}
|
|
200
|
+
export function allocatePort(registry) {
|
|
201
|
+
const taken = new Set(Object.values(registry.workspaces).map((workspace) => workspace.port));
|
|
202
|
+
let port = registry.nextPort;
|
|
203
|
+
while (taken.has(port))
|
|
204
|
+
port += FLEET_PORT_STRIDE;
|
|
205
|
+
return { port, next: port + FLEET_PORT_STRIDE };
|
|
206
|
+
}
|
|
207
|
+
const argvText = (argv) => argv.join(" ");
|
|
208
|
+
export function assertUnixUser(name) {
|
|
209
|
+
if (!/^[a-z_][a-z0-9_-]{0,31}$/.test(name))
|
|
210
|
+
throw new Error(`"${name}" is not a Unix user name`);
|
|
211
|
+
}
|
|
212
|
+
export function initPlan(input) {
|
|
213
|
+
assertDomain(input.domain);
|
|
214
|
+
if (input.operator)
|
|
215
|
+
assertUnixUser(input.operator);
|
|
216
|
+
const layout = input.layout ?? fleetLayout();
|
|
217
|
+
const registry = { ...emptyRegistry(input.domain.toLowerCase()), ...(input.operator ? { operator: input.operator } : {}) };
|
|
218
|
+
const steps = [
|
|
219
|
+
{ kind: "mkdir", path: layout.etcDir, mode: 0o755 },
|
|
220
|
+
{ kind: "mkdir", path: layout.instancesDir, mode: 0o700 },
|
|
221
|
+
{ kind: "mkdir", path: layout.homesDir, mode: 0o755 },
|
|
222
|
+
{ kind: "mkdir", path: layout.caddyDir, mode: 0o755 },
|
|
223
|
+
{ kind: "mkdir", path: layout.auditFile.replace(/\/[^/]+$/, ""), mode: 0o750 },
|
|
224
|
+
{ kind: "write", path: layout.registryFile, content: `${JSON.stringify(registry, null, 2)}\n`, mode: 0o600 },
|
|
225
|
+
{ kind: "write", path: layout.unitFile, content: templateUnit({ node: input.node, script: input.script, layout }), mode: 0o644 },
|
|
226
|
+
{ kind: "write", path: layout.fenceFile, content: fenceRules([]), mode: 0o600 },
|
|
227
|
+
{ kind: "write", path: layout.fenceUnitFile, content: fenceUnit(layout), mode: 0o644 },
|
|
228
|
+
...(input.operator ? [{ kind: "write", path: layout.agentUnitFile, content: agentUnit({ node: input.node, script: input.script, operator: input.operator, layout }), mode: 0o644 }] : []),
|
|
229
|
+
{ kind: "append-once", path: layout.caddyfile, line: caddyImportLine(layout) },
|
|
230
|
+
{ kind: "run", argv: ["systemctl", "daemon-reload"], why: "load the workspace template and the fence unit" },
|
|
231
|
+
{ kind: "run", argv: ["systemctl", "enable", "--now", "openmausbot-fence.service"], why: "apply the loopback fence now and at boot" },
|
|
232
|
+
...(input.operator ? [{ kind: "run", argv: ["systemctl", "enable", "--now", "openmausbot-fleet.service"], why: `start the fleet agent for ${input.operator}` }] : []),
|
|
233
|
+
{ kind: "run", argv: ["systemctl", "reload", "caddy"], why: "start serving the workspaces folder" },
|
|
234
|
+
{ kind: "note", text: `point *.${registry.domain} at this server (a wildcard A/AAAA record); each workspace gets its own certificate when created` },
|
|
235
|
+
...(input.operator ? [{ kind: "note", text: `the workspace running as ${input.operator} can now manage workspaces from Settings → Workspaces` }] : []),
|
|
236
|
+
];
|
|
237
|
+
return { steps, registry };
|
|
238
|
+
}
|
|
239
|
+
export function createPlan(input) {
|
|
240
|
+
assertSlug(input.slug);
|
|
241
|
+
if (input.registry.workspaces[input.slug])
|
|
242
|
+
throw new Error(`workspace "${input.slug}" already exists`);
|
|
243
|
+
if (!input.seed.admins.length)
|
|
244
|
+
throw new Error("a workspace needs at least one admin email (--admin)");
|
|
245
|
+
for (const entry of [...input.seed.admins, ...input.seed.members])
|
|
246
|
+
assertEmailOrDomain(entry);
|
|
247
|
+
if (input.memoryMax !== undefined && !/^\d+[MG]$/.test(input.memoryMax))
|
|
248
|
+
throw new Error("--memory takes a size like 1G or 512M");
|
|
249
|
+
const layout = input.layout ?? fleetLayout();
|
|
250
|
+
const { port, next } = allocatePort(input.registry);
|
|
251
|
+
const workspace = {
|
|
252
|
+
slug: input.slug,
|
|
253
|
+
host: `${input.slug}.${input.registry.domain}`,
|
|
254
|
+
port,
|
|
255
|
+
webhookPort: port + 1,
|
|
256
|
+
status: "running",
|
|
257
|
+
createdAt: (input.now ?? new Date()).toISOString(),
|
|
258
|
+
};
|
|
259
|
+
const registry = { ...input.registry, nextPort: next, workspaces: { ...input.registry.workspaces, [workspace.slug]: workspace } };
|
|
260
|
+
const user = fleetUser(workspace.slug);
|
|
261
|
+
const home = workspaceHome(layout, workspace.slug);
|
|
262
|
+
const dataDir = workspaceDataDir(layout, workspace.slug);
|
|
263
|
+
const steps = [
|
|
264
|
+
{ kind: "run", argv: ["useradd", "--system", "--create-home", "--home-dir", home, "--shell", "/usr/sbin/nologin", "--user-group", user], why: `the workspace's own account` },
|
|
265
|
+
{ kind: "mkdir", path: dataDir, mode: 0o700, owner: user },
|
|
266
|
+
{ kind: "write", path: join(dataDir, "config.json"), content: initialConfig(input.seed), mode: 0o600, owner: user },
|
|
267
|
+
...(input.seed.brandJson ? [{ kind: "write", path: join(dataDir, "brand.json"), content: input.seed.brandJson, mode: 0o600, owner: user }] : []),
|
|
268
|
+
{ kind: "write", path: join(layout.instancesDir, `${workspace.slug}.env`), content: instanceEnv({ workspace, dataDir, licenseKey: input.licenseKey }), mode: 0o600 },
|
|
269
|
+
...(input.memoryMax
|
|
270
|
+
? [
|
|
271
|
+
{ kind: "mkdir", path: join(layout.unitFile.replace(/openmausbot@\.service$/, ""), `openmausbot@${workspace.slug}.service.d`), mode: 0o755 },
|
|
272
|
+
{ kind: "write", path: join(layout.unitFile.replace(/openmausbot@\.service$/, ""), `openmausbot@${workspace.slug}.service.d`, "limits.conf"), content: `[Service]\nMemoryMax=${input.memoryMax}\n`, mode: 0o644 },
|
|
273
|
+
]
|
|
274
|
+
: []),
|
|
275
|
+
{ kind: "write", path: layout.fenceFile, content: fenceRules(Object.values(registry.workspaces)), mode: 0o600 },
|
|
276
|
+
{ kind: "run", argv: ["nft", "-f", layout.fenceFile], why: "fence the new ports to their user" },
|
|
277
|
+
{ kind: "run", argv: ["systemctl", "daemon-reload"], why: "pick up the workspace's environment and limits" },
|
|
278
|
+
{ kind: "run", argv: ["systemctl", "enable", "--now", `openmausbot@${workspace.slug}.service`], why: "start the workspace now and at boot" },
|
|
279
|
+
{ kind: "health", url: `http://127.0.0.1:${workspace.port}/api/health`, why: "wait for the workspace to answer" },
|
|
280
|
+
{ kind: "write", path: join(layout.caddyDir, `${workspace.slug}.caddy`), content: caddySite(workspace), mode: 0o644 },
|
|
281
|
+
{ kind: "run", argv: ["systemctl", "reload", "caddy"], why: `serve https://${workspace.host} and fetch its certificate` },
|
|
282
|
+
{ kind: "write", path: layout.registryFile, content: `${JSON.stringify(registry, null, 2)}\n`, mode: 0o600 },
|
|
283
|
+
{ kind: "note", text: `https://${workspace.host} is ready; ${input.seed.admins[0]} signs in with an emailed code` },
|
|
284
|
+
];
|
|
285
|
+
return { steps, registry, workspace };
|
|
286
|
+
}
|
|
287
|
+
function withStatus(registry, slug, status) {
|
|
288
|
+
assertSlug(slug);
|
|
289
|
+
const current = registry.workspaces[slug];
|
|
290
|
+
if (!current)
|
|
291
|
+
throw new Error(`no workspace "${slug}"`);
|
|
292
|
+
const workspace = { ...current, status };
|
|
293
|
+
return { registry: { ...registry, workspaces: { ...registry.workspaces, [slug]: workspace } }, workspace };
|
|
294
|
+
}
|
|
295
|
+
export function suspendPlan(input) {
|
|
296
|
+
const layout = input.layout ?? fleetLayout();
|
|
297
|
+
const { registry, workspace } = withStatus(input.registry, input.slug, "suspended");
|
|
298
|
+
return {
|
|
299
|
+
registry,
|
|
300
|
+
steps: [
|
|
301
|
+
{ kind: "run", argv: ["systemctl", "disable", "--now", `openmausbot@${workspace.slug}.service`], why: "stop the workspace and keep it stopped at boot" },
|
|
302
|
+
{ kind: "write", path: join(layout.caddyDir, `${workspace.slug}.caddy`), content: caddySite(workspace), mode: 0o644 },
|
|
303
|
+
{ kind: "run", argv: ["systemctl", "reload", "caddy"], why: "show the suspended page instead" },
|
|
304
|
+
{ kind: "write", path: layout.registryFile, content: `${JSON.stringify(registry, null, 2)}\n`, mode: 0o600 },
|
|
305
|
+
],
|
|
306
|
+
};
|
|
307
|
+
}
|
|
308
|
+
export function resumePlan(input) {
|
|
309
|
+
const layout = input.layout ?? fleetLayout();
|
|
310
|
+
const { registry, workspace } = withStatus(input.registry, input.slug, "running");
|
|
311
|
+
return {
|
|
312
|
+
registry,
|
|
313
|
+
steps: [
|
|
314
|
+
{ kind: "run", argv: ["systemctl", "enable", "--now", `openmausbot@${workspace.slug}.service`], why: "start the workspace again" },
|
|
315
|
+
{ kind: "health", url: `http://127.0.0.1:${workspace.port}/api/health`, why: "wait for the workspace to answer" },
|
|
316
|
+
{ kind: "write", path: join(layout.caddyDir, `${workspace.slug}.caddy`), content: caddySite(workspace), mode: 0o644 },
|
|
317
|
+
{ kind: "run", argv: ["systemctl", "reload", "caddy"], why: "serve it again" },
|
|
318
|
+
{ kind: "write", path: layout.registryFile, content: `${JSON.stringify(registry, null, 2)}\n`, mode: 0o600 },
|
|
319
|
+
],
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
export function deletePlan(input) {
|
|
323
|
+
assertSlug(input.slug);
|
|
324
|
+
const layout = input.layout ?? fleetLayout();
|
|
325
|
+
const workspace = input.registry.workspaces[input.slug];
|
|
326
|
+
if (!workspace)
|
|
327
|
+
throw new Error(`no workspace "${input.slug}"`);
|
|
328
|
+
const { [input.slug]: _gone, ...rest } = input.registry.workspaces;
|
|
329
|
+
const registry = { ...input.registry, workspaces: rest };
|
|
330
|
+
const unitsDir = layout.unitFile.replace(/openmausbot@\.service$/, "");
|
|
331
|
+
return {
|
|
332
|
+
registry,
|
|
333
|
+
steps: [
|
|
334
|
+
{ kind: "run", argv: ["systemctl", "disable", "--now", `openmausbot@${workspace.slug}.service`], why: "stop the workspace" },
|
|
335
|
+
{ kind: "remove", path: join(layout.caddyDir, `${workspace.slug}.caddy`) },
|
|
336
|
+
{ kind: "run", argv: ["systemctl", "reload", "caddy"], why: "stop serving its address" },
|
|
337
|
+
{ kind: "remove", path: join(layout.instancesDir, `${workspace.slug}.env`) },
|
|
338
|
+
{ kind: "remove", path: join(unitsDir, `openmausbot@${workspace.slug}.service.d`, "limits.conf") },
|
|
339
|
+
{ kind: "write", path: layout.fenceFile, content: fenceRules(Object.values(registry.workspaces)), mode: 0o600 },
|
|
340
|
+
{ kind: "run", argv: ["nft", "-f", layout.fenceFile], why: "drop its fence rule" },
|
|
341
|
+
{ kind: "run", argv: input.keepData ? ["userdel", fleetUser(workspace.slug)] : ["userdel", "--remove", fleetUser(workspace.slug)], why: input.keepData ? "remove the account, keep its home and data" : "remove the account and everything it owned" },
|
|
342
|
+
{ kind: "write", path: layout.registryFile, content: `${JSON.stringify(registry, null, 2)}\n`, mode: 0o600 },
|
|
343
|
+
],
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
export function upgradePlan(input) {
|
|
347
|
+
const running = Object.values(input.registry.workspaces).filter((workspace) => workspace.status === "running").sort((a, b) => a.slug.localeCompare(b.slug));
|
|
348
|
+
return [
|
|
349
|
+
{ kind: "run", argv: ["npm", "install", "-g", "openmausbot@latest"], why: "install the release every workspace runs from" },
|
|
350
|
+
...running.map((workspace) => ({ kind: "run", argv: ["systemctl", "restart", `openmausbot@${workspace.slug}.service`], why: `restart ${workspace.slug} on the new release` })),
|
|
351
|
+
...running.map((workspace) => ({ kind: "health", url: `http://127.0.0.1:${workspace.port}/api/health`, why: `wait for ${workspace.slug}` })),
|
|
352
|
+
];
|
|
353
|
+
}
|
|
354
|
+
/** A workspace's sign-in list edited from outside, the same shape
|
|
355
|
+
* `openmausbot access` writes; the server reads it live. */
|
|
356
|
+
export function applySignIn(configText, action, email, chatOnly) {
|
|
357
|
+
const entry = email.trim().toLowerCase();
|
|
358
|
+
assertEmailOrDomain(entry);
|
|
359
|
+
let raw = {};
|
|
360
|
+
const parsed = JSON.parse(configText || "{}");
|
|
361
|
+
if (parsed && typeof parsed === "object" && !Array.isArray(parsed))
|
|
362
|
+
raw = { ...parsed };
|
|
363
|
+
const current = raw.signIn && typeof raw.signIn === "object" ? raw.signIn : {};
|
|
364
|
+
const list = (value) => (Array.isArray(value) ? value.map((item) => String(item).trim().toLowerCase()).filter(Boolean) : []);
|
|
365
|
+
const without = (items) => items.filter((item) => item !== entry);
|
|
366
|
+
const admins = without(list(current.admins));
|
|
367
|
+
const members = without(list(current.members));
|
|
368
|
+
if (action === "remove") {
|
|
369
|
+
if (admins.length + members.length === list(current.admins).length + list(current.members).length)
|
|
370
|
+
throw new Error(`${entry} is not on the list`);
|
|
371
|
+
return { config: `${JSON.stringify({ ...raw, signIn: { admins, members } }, null, 2)}\n`, summary: `${entry} can no longer sign in; existing sessions stay until they expire or are revoked` };
|
|
372
|
+
}
|
|
373
|
+
const next = chatOnly ? { admins, members: [...members, entry] } : { admins: [...admins, entry], members };
|
|
374
|
+
return { config: `${JSON.stringify({ ...raw, signIn: next }, null, 2)}\n`, summary: `${entry} can sign in with an emailed code (${chatOnly ? "chat and approvals" : "full access"})` };
|
|
375
|
+
}
|
|
376
|
+
function shellQuote(value) {
|
|
377
|
+
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
378
|
+
}
|
|
379
|
+
/** The plan as lines an operator can read or paste into a root shell. */
|
|
380
|
+
export function describeSteps(steps) {
|
|
381
|
+
const lines = [];
|
|
382
|
+
for (const step of steps) {
|
|
383
|
+
switch (step.kind) {
|
|
384
|
+
case "mkdir":
|
|
385
|
+
lines.push(`install -d -m ${step.mode.toString(8)}${step.owner ? ` -o ${step.owner} -g ${step.owner}` : ""} ${shellQuote(step.path)}`);
|
|
386
|
+
break;
|
|
387
|
+
case "write":
|
|
388
|
+
lines.push(`cat > ${shellQuote(step.path)} <<'OMB_EOF'`, step.content.replace(/\n$/, ""), "OMB_EOF", `chmod ${step.mode.toString(8)} ${shellQuote(step.path)}`);
|
|
389
|
+
if (step.owner)
|
|
390
|
+
lines.push(`chown ${step.owner}:${step.owner} ${shellQuote(step.path)}`);
|
|
391
|
+
break;
|
|
392
|
+
case "append-once":
|
|
393
|
+
lines.push(`grep -qxF ${shellQuote(step.line)} ${shellQuote(step.path)} || printf '\\n%s\\n' ${shellQuote(step.line)} >> ${shellQuote(step.path)}`);
|
|
394
|
+
break;
|
|
395
|
+
case "remove":
|
|
396
|
+
lines.push(`rm -f ${shellQuote(step.path)}`);
|
|
397
|
+
break;
|
|
398
|
+
case "run":
|
|
399
|
+
lines.push(`${argvText(step.argv.map(shellQuote))} # ${step.why}`);
|
|
400
|
+
break;
|
|
401
|
+
case "health":
|
|
402
|
+
lines.push(`until curl -sf ${shellQuote(step.url)} >/dev/null; do sleep 1; done # ${step.why}`);
|
|
403
|
+
break;
|
|
404
|
+
case "note":
|
|
405
|
+
lines.push(`# ${step.text}`);
|
|
406
|
+
break;
|
|
407
|
+
}
|
|
408
|
+
}
|
|
409
|
+
return lines;
|
|
410
|
+
}
|