openmausbot 0.1.76 → 0.1.77
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-C6blGSq5.js → index-DeK-z9ld.js} +1 -1
- package/dist/assets/index-XdHJ-9CF.css +1 -0
- package/dist/assets/index-lhqE1umn.js +305 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +5 -1
- package/dist-server/drivers/agents-proxy.js +52 -1
- package/dist-server/enterprise/server/index.js +387 -29
- package/dist-server/index.js +1377 -459
- package/dist-server/openmausbot.js +725 -522
- package/dist-server/pair-cli.js +725 -522
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/browser-live.js +3 -0
- package/dist-server/server/config.js +16 -0
- package/dist-server/server/drivers/acp/core.js +3 -1
- package/dist-server/server/drivers/agents-proxy.js +58 -2
- package/dist-server/server/drivers/boxagent.js +1 -1
- package/dist-server/server/drivers/claude.js +3 -2
- package/dist-server/server/drivers/codex.js +3 -1
- package/dist-server/server/drivers/pi.js +4 -0
- package/dist-server/server/enterprise.js +38 -0
- package/dist-server/server/environment.js +3 -0
- package/dist-server/server/fleet-agent.js +57 -13
- package/dist-server/server/fleet-cli.js +248 -31
- package/dist-server/server/fleet.js +88 -14
- package/dist-server/server/hosted-contract.js +16 -0
- package/dist-server/server/index.js +671 -100
- package/dist-server/server/message-file.js +4 -0
- package/dist-server/server/provider-key-check.js +18 -1
- package/dist-server/server/request-auth.js +14 -5
- package/dist-server/server/sessions.js +115 -8
- package/dist-server/server/shared-computer-control.js +24 -0
- package/dist-server/server/shared-computers.js +131 -0
- package/dist-server/server/steer-queue.js +2 -0
- package/dist-server/server/store.js +20 -7
- package/dist-server/server/team-computers.js +108 -0
- package/dist-server/server/tool-summary.js +53 -1
- package/dist-server/server/workspace-backup.js +1 -0
- package/dist-server/shared/approval-mode.js +4 -1
- package/dist-server/shared/team-computer.js +1 -0
- package/dist-server/vps-container-mcp.js +5 -1
- package/enterprise/README.md +21 -3
- package/enterprise/server/index.js +387 -29
- package/package.json +1 -1
- package/dist/assets/index-BtT047dk.css +0 -1
- package/dist/assets/index-fKXFpB_J.js +0 -305
|
@@ -3,11 +3,124 @@
|
|
|
3
3
|
// prints them for an operator to paste into a root shell, and never builds
|
|
4
4
|
// a shell command from user input. The fleet agent (fleet-agent.ts) reuses
|
|
5
5
|
// the same planner and executor over a Unix socket.
|
|
6
|
-
import { spawn } from "node:child_process";
|
|
7
|
-
import { appendFileSync, existsSync, mkdirSync, readFileSync, rmSync
|
|
6
|
+
import { execFileSync, spawn } from "node:child_process";
|
|
7
|
+
import { appendFileSync, closeSync, existsSync, fsyncSync, lstatSync, mkdirSync, openSync, readFileSync, rmSync } from "node:fs";
|
|
8
8
|
import { posix } from "node:path";
|
|
9
|
+
import { writeFileAtomic } from "./atomic.js";
|
|
9
10
|
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
|
+
import { applySignIn, assertManagedWorkspace, assertSlug, assertUnixUser, createPlan, deletePlan, describeSteps, fleetLayout, fleetUser, fenceRules, initPlan, MANAGED_OPENROUTER, managedOpenRouterModels, parseRegistry, resumePlan, suspendPlan, upgradePlan, workspaceDataDir, workspaceHome, } from "./fleet.js";
|
|
12
|
+
// Tenant-controlled ancestors can be renamed between any two filesystem calls.
|
|
13
|
+
// The boundary is dropping ALL root authority before I/O, not a preceding lstat.
|
|
14
|
+
// The checks additionally reject visible symlinks, shared inodes and special files.
|
|
15
|
+
const TENANT_IO = String.raw `
|
|
16
|
+
const fs = require("node:fs");
|
|
17
|
+
const path = require("node:path");
|
|
18
|
+
const { randomUUID } = require("node:crypto");
|
|
19
|
+
const request = JSON.parse(fs.readFileSync(0, "utf8"));
|
|
20
|
+
const { uid, gid, home, file, action, content, mode } = request;
|
|
21
|
+
if (process.getuid() === 0) {
|
|
22
|
+
process.setgroups([]);
|
|
23
|
+
process.setgid(gid);
|
|
24
|
+
process.setuid(uid);
|
|
25
|
+
}
|
|
26
|
+
if (process.getuid() !== uid || process.getgid() !== gid || uid === 0 || gid === 0) throw Error("could not drop fleet file privileges");
|
|
27
|
+
const limit = 4 * 1024 * 1024;
|
|
28
|
+
const check = (stat, directory) => {
|
|
29
|
+
if (stat.uid !== uid || (directory ? !stat.isDirectory() : !stat.isFile() || stat.nlink !== 1)) throw Error("unsafe workspace file or directory");
|
|
30
|
+
};
|
|
31
|
+
const totals = { turns: 0, costUsd: null, billableUsd: null };
|
|
32
|
+
let cursor = home;
|
|
33
|
+
try {
|
|
34
|
+
check(fs.lstatSync(cursor), true);
|
|
35
|
+
for (const part of path.relative(home, path.dirname(file)).split(path.sep).filter(Boolean)) {
|
|
36
|
+
cursor = path.join(cursor, part);
|
|
37
|
+
check(fs.lstatSync(cursor), true);
|
|
38
|
+
}
|
|
39
|
+
} catch (error) {
|
|
40
|
+
if (action !== "usage" || error.code !== "ENOENT") throw error;
|
|
41
|
+
process.stdout.write(JSON.stringify(totals));
|
|
42
|
+
process.exit(0);
|
|
43
|
+
}
|
|
44
|
+
let result = null;
|
|
45
|
+
if (action === "mkdir") {
|
|
46
|
+
try { fs.mkdirSync(file, { mode }); } catch (error) { if (error.code !== "EEXIST") throw error; }
|
|
47
|
+
check(fs.lstatSync(file), true);
|
|
48
|
+
} else if (action === "read" || action === "usage") {
|
|
49
|
+
let fd;
|
|
50
|
+
try { fd = fs.openSync(file, fs.constants.O_RDONLY | fs.constants.O_NOFOLLOW | fs.constants.O_NONBLOCK); }
|
|
51
|
+
catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
52
|
+
if (fd !== undefined) {
|
|
53
|
+
try {
|
|
54
|
+
const stat = fs.fstatSync(fd);
|
|
55
|
+
check(stat, false);
|
|
56
|
+
if (stat.size > limit) throw Error("workspace file exceeds 4 MiB");
|
|
57
|
+
const data = Buffer.alloc(limit + 1);
|
|
58
|
+
let size = 0;
|
|
59
|
+
while (size < data.length) { const read = fs.readSync(fd, data, size, data.length - size, null); if (!read) break; size += read; }
|
|
60
|
+
if (size > limit) throw Error("workspace file exceeds 4 MiB");
|
|
61
|
+
result = data.subarray(0, size).toString("utf8");
|
|
62
|
+
} finally { fs.closeSync(fd); }
|
|
63
|
+
}
|
|
64
|
+
if (action === "usage") {
|
|
65
|
+
const to = Date.parse(content);
|
|
66
|
+
const date = new Date(to);
|
|
67
|
+
const from = Date.UTC(date.getUTCFullYear(), date.getUTCMonth(), 1);
|
|
68
|
+
// Scan one bounded month's text without retaining rows or allocating groups.
|
|
69
|
+
const text = result || "";
|
|
70
|
+
for (let offset = 0; offset < text.length;) {
|
|
71
|
+
const end = text.indexOf("\n", offset);
|
|
72
|
+
const line = text.slice(offset, end < 0 ? text.length : end);
|
|
73
|
+
offset = end < 0 ? text.length : end + 1;
|
|
74
|
+
let row;
|
|
75
|
+
try { row = JSON.parse(line); } catch { continue; }
|
|
76
|
+
if (!row || typeof row !== "object" || typeof row.at !== "string" || typeof row.botId !== "string" || typeof row.model !== "string" || typeof row.input !== "number" || typeof row.output !== "number" || !row.trigger || typeof row.trigger !== "object" || typeof row.trigger.kind !== "string") continue;
|
|
77
|
+
const at = Date.parse(row.at);
|
|
78
|
+
if (!(at >= from && at <= to)) continue;
|
|
79
|
+
totals.turns++;
|
|
80
|
+
if (typeof row.costUsd === "number" && Number.isFinite(row.costUsd) && row.costUsd >= 0) {
|
|
81
|
+
const sum = (totals.costUsd || 0) + row.costUsd;
|
|
82
|
+
if (!Number.isFinite(sum)) throw Error("workspace usage total exceeds numeric range");
|
|
83
|
+
totals.costUsd = sum;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
result = totals;
|
|
87
|
+
}
|
|
88
|
+
} else if (action === "write") {
|
|
89
|
+
if (Buffer.byteLength(content) > limit) throw Error("workspace file exceeds 4 MiB");
|
|
90
|
+
try { check(fs.lstatSync(file), false); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
91
|
+
const temp = file + ".fleet-" + randomUUID();
|
|
92
|
+
let fd;
|
|
93
|
+
try {
|
|
94
|
+
fd = fs.openSync(temp, fs.constants.O_WRONLY | fs.constants.O_CREAT | fs.constants.O_EXCL | fs.constants.O_NOFOLLOW, mode);
|
|
95
|
+
fs.writeFileSync(fd, content);
|
|
96
|
+
fs.fsyncSync(fd);
|
|
97
|
+
fs.closeSync(fd); fd = undefined;
|
|
98
|
+
fs.renameSync(temp, file);
|
|
99
|
+
} finally {
|
|
100
|
+
if (fd !== undefined) fs.closeSync(fd);
|
|
101
|
+
try { fs.unlinkSync(temp); } catch (error) { if (error.code !== "ENOENT") throw error; }
|
|
102
|
+
}
|
|
103
|
+
} else throw Error("unknown fleet file operation");
|
|
104
|
+
process.stdout.write(JSON.stringify(result));
|
|
105
|
+
`;
|
|
106
|
+
function tenantIo(owner, file, action, content, mode) {
|
|
107
|
+
assertUnixUser(owner);
|
|
108
|
+
const options = { encoding: "utf8", timeout: 5_000, killSignal: "SIGKILL", cwd: "/", env: { PATH: "/usr/bin:/bin" }, stdio: ["pipe", "pipe", "pipe"] };
|
|
109
|
+
const account = execFileSync("/usr/bin/getent", ["passwd", owner], { ...options, maxBuffer: 8192 }).trim().split(":");
|
|
110
|
+
const uid = Number(account[2]);
|
|
111
|
+
const gid = Number(account[3]);
|
|
112
|
+
const home = account[5];
|
|
113
|
+
if (account.length !== 7 || account[0] !== owner || !Number.isSafeInteger(uid) || uid <= 0 || !Number.isSafeInteger(gid) || gid <= 0 || !home?.startsWith("/") || posix.normalize(home) !== home || !file.startsWith(`${home}/`) || posix.normalize(file) !== file) {
|
|
114
|
+
throw new Error(`unsafe filesystem identity or path for ${owner}`);
|
|
115
|
+
}
|
|
116
|
+
try {
|
|
117
|
+
return JSON.parse(execFileSync(process.execPath, ["--eval", TENANT_IO], { ...options, maxBuffer: action === "usage" ? 8192 : 32 * 1024 * 1024, input: JSON.stringify({ uid, gid, home, file, action, content, mode }) }));
|
|
118
|
+
}
|
|
119
|
+
catch {
|
|
120
|
+
// Do not repeat helper stderr: tenant-controlled file contents may be secret.
|
|
121
|
+
throw new Error(`could not ${action} workspace file as ${owner}: check ownership, links, file size and permissions`);
|
|
122
|
+
}
|
|
123
|
+
}
|
|
11
124
|
export function defaultFleetDeps() {
|
|
12
125
|
return {
|
|
13
126
|
isRoot: () => typeof process.getuid === "function" && process.getuid() === 0,
|
|
@@ -27,9 +140,39 @@ export function defaultFleetDeps() {
|
|
|
27
140
|
resolve({ code: null, output: error instanceof Error ? error.message : String(error) });
|
|
28
141
|
}
|
|
29
142
|
}),
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
143
|
+
pathExists: (path) => { try {
|
|
144
|
+
lstatSync(path);
|
|
145
|
+
return true;
|
|
146
|
+
}
|
|
147
|
+
catch (error) {
|
|
148
|
+
if (error.code === "ENOENT")
|
|
149
|
+
return false;
|
|
150
|
+
throw error;
|
|
151
|
+
} },
|
|
152
|
+
readText: (path, owner) => owner ? tenantIo(owner, path, "read") : (existsSync(path) ? readFileSync(path, "utf8") : null),
|
|
153
|
+
usage: (dataDir, owner, now) => {
|
|
154
|
+
const at = now.toISOString();
|
|
155
|
+
return tenantIo(owner, posix.join(dataDir, "usage", `${at.slice(0, 7)}.jsonl`), "usage", at);
|
|
156
|
+
},
|
|
157
|
+
writeText: (path, content, mode, owner) => {
|
|
158
|
+
if (owner) {
|
|
159
|
+
tenantIo(owner, path, "write", content, mode);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
writeFileAtomic(path, content, { mode });
|
|
163
|
+
// Persist the rename before the next external step (notably useradd).
|
|
164
|
+
const directory = openSync(posix.dirname(path), "r");
|
|
165
|
+
try {
|
|
166
|
+
fsyncSync(directory);
|
|
167
|
+
}
|
|
168
|
+
finally {
|
|
169
|
+
closeSync(directory);
|
|
170
|
+
}
|
|
171
|
+
},
|
|
172
|
+
mkdir: (path, mode, owner) => { if (owner)
|
|
173
|
+
tenantIo(owner, path, "mkdir", undefined, mode);
|
|
174
|
+
else
|
|
175
|
+
mkdirSync(path, { recursive: true, mode }); },
|
|
33
176
|
appendOnce: (path, line) => {
|
|
34
177
|
const current = existsSync(path) ? readFileSync(path, "utf8") : "";
|
|
35
178
|
if (!current.split("\n").some((existing) => existing.trim() === line))
|
|
@@ -60,20 +203,10 @@ export async function executePlan(steps, deps, io) {
|
|
|
60
203
|
for (const step of steps) {
|
|
61
204
|
switch (step.kind) {
|
|
62
205
|
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
|
-
}
|
|
206
|
+
deps.mkdir(step.path, step.mode, step.owner);
|
|
69
207
|
break;
|
|
70
208
|
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
|
-
}
|
|
209
|
+
deps.writeText(step.path, step.content, step.mode, step.owner);
|
|
77
210
|
break;
|
|
78
211
|
case "append-once":
|
|
79
212
|
deps.appendOnce(step.path, step.line);
|
|
@@ -125,20 +258,34 @@ export function planFleetAction(input, deps) {
|
|
|
125
258
|
const existing = deps.readText(layout.registryFile);
|
|
126
259
|
if (existing !== null && !input.yes)
|
|
127
260
|
throw new Error(`${layout.registryFile} exists; pass --yes to rewrite the template unit and fence (workspaces are kept)`);
|
|
128
|
-
const
|
|
129
|
-
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
plan.registry.workspaces
|
|
133
|
-
plan.
|
|
134
|
-
|
|
261
|
+
const kept = existing !== null ? parseRegistry(existing) : undefined;
|
|
262
|
+
const plan = initPlan({ domain: input.domain, node: input.node, script: input.script, operator: input.operator ?? kept?.operator, layout });
|
|
263
|
+
if (kept) {
|
|
264
|
+
// Reinitializing templates must not erase reservations or open sibling ports.
|
|
265
|
+
const registry = { ...kept, ...plan.registry, workspaces: kept.workspaces, nextPort: kept.nextPort };
|
|
266
|
+
return plan.steps.flatMap((step) => {
|
|
267
|
+
if (step.kind === "write" && step.path === layout.registryFile)
|
|
268
|
+
return [{ ...step, content: `${JSON.stringify(registry, null, 2)}\n` }];
|
|
269
|
+
if (step.kind === "write" && step.path === layout.fenceFile)
|
|
270
|
+
return [{ ...step, content: fenceRules(Object.values(registry.workspaces)) }];
|
|
271
|
+
if (step.kind === "run" && step.argv.join(" ") === "systemctl enable --now openmausbot-fence.service")
|
|
272
|
+
return [step, { kind: "run", argv: ["nft", "-f", layout.fenceFile], why: "reapply the preserved workspace fence" }];
|
|
273
|
+
return [step];
|
|
274
|
+
});
|
|
135
275
|
}
|
|
136
276
|
return plan.steps;
|
|
137
277
|
}
|
|
138
278
|
case "create": {
|
|
139
279
|
if (!input.slug)
|
|
140
280
|
throw new Error("fleet create needs a workspace name");
|
|
281
|
+
assertSlug(input.slug);
|
|
141
282
|
const registry = loadRegistry(layout, deps);
|
|
283
|
+
if (registry.workspaces[input.slug])
|
|
284
|
+
throw new Error(`workspace "${input.slug}" already exists`);
|
|
285
|
+
for (const path of [workspaceHome(layout, input.slug), posix.join(layout.instancesDir, `${input.slug}.env`), posix.join(layout.caddyDir, `${input.slug}.caddy`), posix.join(posix.dirname(layout.unitFile), `openmausbot@${input.slug}.service.d`)]) {
|
|
286
|
+
if (deps.pathExists(path))
|
|
287
|
+
throw new Error(`workspace path already exists: ${path}; recover or remove it explicitly before provisioning a fresh workspace`);
|
|
288
|
+
}
|
|
142
289
|
const brandJson = input.brandJson ?? (input.brandFile ? deps.readText(input.brandFile) : undefined);
|
|
143
290
|
if (input.brandFile && brandJson === null)
|
|
144
291
|
throw new Error(`${input.brandFile} not found`);
|
|
@@ -150,12 +297,58 @@ export function planFleetAction(input, deps) {
|
|
|
150
297
|
return createPlan({
|
|
151
298
|
registry,
|
|
152
299
|
slug: input.slug,
|
|
153
|
-
seed: { admins: input.admins, members: input.members, ...(anthropicKey ? { anthropicKey } : {}), ...(input.cap !== undefined ? { monthlyCapUsd: input.cap } : {}), ...(brandJson ? { brandJson } : {}) },
|
|
300
|
+
seed: { admins: input.admins, members: input.members, ...(anthropicKey ? { anthropicKey } : {}), ...(input.anthropicUrl ? { anthropicUrl: input.anthropicUrl } : {}), ...(input.portalUrl ? { portalUrl: input.portalUrl } : {}), ...(input.cap !== undefined ? { monthlyCapUsd: input.cap } : {}), ...(brandJson ? { brandJson } : {}), openrouterKey: input.openrouterKey, openrouterUrl: input.openrouterUrl, openrouterModels: input.openrouterModels, openrouterDefault: input.openrouterDefault },
|
|
154
301
|
...(input.licenseKey ? { licenseKey: input.licenseKey } : {}),
|
|
155
302
|
...(input.memory ? { memoryMax: input.memory } : {}),
|
|
156
303
|
layout,
|
|
157
304
|
}).steps;
|
|
158
305
|
}
|
|
306
|
+
case "providers": {
|
|
307
|
+
if (!input.slug)
|
|
308
|
+
throw new Error("fleet providers needs a workspace name");
|
|
309
|
+
assertSlug(input.slug);
|
|
310
|
+
const workspace = loadRegistry(layout, deps).workspaces[input.slug];
|
|
311
|
+
if (!workspace)
|
|
312
|
+
throw new Error(`no workspace "${input.slug}"`);
|
|
313
|
+
assertManagedWorkspace(workspace);
|
|
314
|
+
const models = managedOpenRouterModels(input.openrouterModels);
|
|
315
|
+
const env = deps.readText(posix.join(layout.instancesDir, `${input.slug}.env`)) ?? "";
|
|
316
|
+
const portal = /^OMB_ADMIN_URL=(.+)$/m.exec(env)?.[1];
|
|
317
|
+
if (!portal || !env.split("\n").includes(`OMB_ADMIN_WORKSPACE=${input.slug}`))
|
|
318
|
+
throw new Error("workspace has no trusted portal configuration; operator recovery is required");
|
|
319
|
+
try {
|
|
320
|
+
const url = new URL(portal);
|
|
321
|
+
if (url.protocol !== "https:" || url.origin !== portal)
|
|
322
|
+
throw new Error();
|
|
323
|
+
}
|
|
324
|
+
catch {
|
|
325
|
+
throw new Error("workspace portal configuration is invalid; operator recovery is required");
|
|
326
|
+
}
|
|
327
|
+
const file = posix.join(workspaceHome(layout, input.slug), ".config", "opencode", "opencode.json");
|
|
328
|
+
const raw = deps.readText(file, fleetUser(input.slug));
|
|
329
|
+
const object = (value) => Boolean(value) && typeof value === "object" && !Array.isArray(value);
|
|
330
|
+
let config;
|
|
331
|
+
try {
|
|
332
|
+
const parsed = JSON.parse(raw ?? "null");
|
|
333
|
+
if (!object(parsed))
|
|
334
|
+
throw new Error();
|
|
335
|
+
config = parsed;
|
|
336
|
+
}
|
|
337
|
+
catch {
|
|
338
|
+
throw new Error("managed OpenRouter configuration is missing or invalid; operator recovery is required");
|
|
339
|
+
}
|
|
340
|
+
const providers = config.provider;
|
|
341
|
+
const managed = object(providers) ? providers[MANAGED_OPENROUTER] : undefined;
|
|
342
|
+
const options = object(managed) ? managed.options : undefined;
|
|
343
|
+
if (!object(managed) || managed.npm !== "@ai-sdk/openai-compatible" || !object(options) || options.baseURL !== `${portal}/api/gateway/${input.slug}/openrouter/v1` || typeof options.apiKey !== "string" || !/^[!-~]+$/.test(options.apiKey)) {
|
|
344
|
+
throw new Error("managed OpenRouter configuration does not match the workspace portal; operator recovery is required");
|
|
345
|
+
}
|
|
346
|
+
managed.models = models;
|
|
347
|
+
return [
|
|
348
|
+
{ kind: "write", path: file, content: `${JSON.stringify(config, null, 2)}\n`, mode: 0o600, owner: fleetUser(input.slug) },
|
|
349
|
+
{ kind: "note", text: "managed OpenRouter models updated; refresh the workspace model picker to load its catalog" },
|
|
350
|
+
];
|
|
351
|
+
}
|
|
159
352
|
case "users": {
|
|
160
353
|
if (!input.slug || !input.userAction || !input.email)
|
|
161
354
|
throw new Error("fleet users needs: NAME add|remove EMAIL [--chat-only]");
|
|
@@ -163,8 +356,9 @@ export function planFleetAction(input, deps) {
|
|
|
163
356
|
const registry = loadRegistry(layout, deps);
|
|
164
357
|
if (!registry.workspaces[input.slug])
|
|
165
358
|
throw new Error(`no workspace "${input.slug}"`);
|
|
359
|
+
assertManagedWorkspace(registry.workspaces[input.slug]);
|
|
166
360
|
const file = posix.join(workspaceDataDir(layout, input.slug), "config.json");
|
|
167
|
-
const next = applySignIn(deps.readText(file) ?? "{}", input.userAction, input.email, Boolean(input.chatOnly));
|
|
361
|
+
const next = applySignIn(deps.readText(file, fleetUser(input.slug)) ?? "{}", input.userAction, input.email, Boolean(input.chatOnly));
|
|
168
362
|
return [
|
|
169
363
|
{ kind: "write", path: file, content: next.config, mode: 0o600, owner: fleetUser(input.slug) },
|
|
170
364
|
{ kind: "note", text: next.summary },
|
|
@@ -181,7 +375,7 @@ export function planFleetAction(input, deps) {
|
|
|
181
375
|
if (!input.slug)
|
|
182
376
|
throw new Error("fleet delete needs a workspace name");
|
|
183
377
|
if (!input.yes)
|
|
184
|
-
throw new Error(`fleet delete
|
|
378
|
+
throw new Error(`fleet delete ${input.keepData ? "retires the workspace, keeping its data and reserved Unix account" : "removes the workspace's account and all of its data"}; pass --yes to confirm${input.keepData ? "" : ", or --keep-data to keep the home folder and account reserved"}`);
|
|
185
379
|
const registry = loadRegistry(layout, deps);
|
|
186
380
|
return deletePlan({ registry, slug: input.slug, keepData: input.keepData, layout }).steps;
|
|
187
381
|
}
|
|
@@ -191,8 +385,20 @@ export function planFleetAction(input, deps) {
|
|
|
191
385
|
return [];
|
|
192
386
|
}
|
|
193
387
|
}
|
|
388
|
+
/** Preserve the reservation and any account checkpoint after a failed create. */
|
|
389
|
+
export function markProvisioningFailed(input, deps) {
|
|
390
|
+
if (input.action !== "create" || !input.slug)
|
|
391
|
+
return;
|
|
392
|
+
const layout = fleetLayout(input.root ?? "/");
|
|
393
|
+
const registry = loadRegistry(layout, deps);
|
|
394
|
+
const workspace = registry.workspaces[input.slug];
|
|
395
|
+
if (workspace?.status !== "provisioning")
|
|
396
|
+
return;
|
|
397
|
+
registry.workspaces[input.slug] = { ...workspace, status: "error" };
|
|
398
|
+
deps.writeText(layout.registryFile, `${JSON.stringify(registry, null, 2)}\n`, 0o600);
|
|
399
|
+
}
|
|
194
400
|
function printPlan(steps, io, reason) {
|
|
195
|
-
io.log(`${reason};
|
|
401
|
+
io.log(`${reason}; inspect this plan, then rerun the fleet command as root without --dry-run (with --yes where required):`);
|
|
196
402
|
io.log("");
|
|
197
403
|
for (const line of describeSteps(steps))
|
|
198
404
|
io.log(line);
|
|
@@ -210,13 +416,24 @@ export async function runFleetCommand(input, io, deps = defaultFleetDeps()) {
|
|
|
210
416
|
return 0;
|
|
211
417
|
}
|
|
212
418
|
for (const workspace of rows) {
|
|
213
|
-
const live = asRoot ? (await deps.run(["systemctl", "is-active", `openmausbot@${workspace.slug}.service`])).output.trim() : workspace.status;
|
|
419
|
+
const live = asRoot && (workspace.status === "running" || workspace.status === "suspended") ? (await deps.run(["systemctl", "is-active", `openmausbot@${workspace.slug}.service`])).output.trim() : workspace.status;
|
|
214
420
|
io.log(`${workspace.slug.padEnd(24)} https://${workspace.host.padEnd(40)} :${String(workspace.port).padEnd(6)} ${live}`);
|
|
215
421
|
}
|
|
216
422
|
return 0;
|
|
217
423
|
}
|
|
218
424
|
const steps = planFleetAction(input, deps);
|
|
219
|
-
|
|
425
|
+
if (!asRoot)
|
|
426
|
+
return printPlan(steps, io, input.dryRun ? "dry run" : "not running as root");
|
|
427
|
+
try {
|
|
428
|
+
const code = await executePlan(steps, deps, io);
|
|
429
|
+
if (code !== 0)
|
|
430
|
+
markProvisioningFailed(input, deps);
|
|
431
|
+
return code;
|
|
432
|
+
}
|
|
433
|
+
catch (error) {
|
|
434
|
+
markProvisioningFailed(input, deps);
|
|
435
|
+
throw error;
|
|
436
|
+
}
|
|
220
437
|
}
|
|
221
438
|
catch (error) {
|
|
222
439
|
io.error(`fleet: ${error instanceof Error ? error.message : String(error)}`);
|
|
@@ -98,8 +98,9 @@ export function templateUnit(spec) {
|
|
|
98
98
|
"# Written by `openmausbot fleet init`. One unit for every workspace: %i is the slug.",
|
|
99
99
|
"[Unit]",
|
|
100
100
|
"Description=OpenMausBot workspace %i",
|
|
101
|
-
"After=network-online.target",
|
|
101
|
+
"After=network-online.target openmausbot-fence.service",
|
|
102
102
|
"Wants=network-online.target",
|
|
103
|
+
"Requires=openmausbot-fence.service",
|
|
103
104
|
"",
|
|
104
105
|
"[Service]",
|
|
105
106
|
"Type=simple",
|
|
@@ -131,7 +132,6 @@ export function fenceUnit(layout = fleetLayout()) {
|
|
|
131
132
|
"# Written by `openmausbot fleet init`: keeps each workspace's loopback ports to its own user.",
|
|
132
133
|
"[Unit]",
|
|
133
134
|
"Description=OpenMausBot per-workspace loopback fence",
|
|
134
|
-
"Before=openmausbot@.service",
|
|
135
135
|
"",
|
|
136
136
|
"[Service]",
|
|
137
137
|
"Type=oneshot",
|
|
@@ -149,14 +149,17 @@ export function fenceUnit(layout = fleetLayout()) {
|
|
|
149
149
|
export function fenceRules(workspaces) {
|
|
150
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
151
|
for (const workspace of [...workspaces].sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
152
|
+
if (workspace.status !== "running" && workspace.status !== "suspended" && workspace.status !== "retained" && !workspace.accountCreated)
|
|
153
|
+
continue;
|
|
152
154
|
lines.push(`\t\toif lo tcp dport { ${workspace.port}, ${workspace.webhookPort} } meta skuid != { ${fleetUser(workspace.slug)}, caddy, root } reject`);
|
|
153
155
|
}
|
|
154
156
|
lines.push("\t}", "}", "");
|
|
155
157
|
return lines.join("\n");
|
|
156
158
|
}
|
|
157
159
|
export function caddySite(workspace) {
|
|
158
|
-
if (workspace.status
|
|
159
|
-
|
|
160
|
+
if (workspace.status !== "running") {
|
|
161
|
+
const message = workspace.status === "suspended" ? "This workspace is suspended." : "This workspace is not available.";
|
|
162
|
+
return [`${workspace.host} {`, `\trespond "${message}" 503`, "}", ""].join("\n");
|
|
160
163
|
}
|
|
161
164
|
return [
|
|
162
165
|
`${workspace.host} {`,
|
|
@@ -185,14 +188,29 @@ export function instanceEnv(input) {
|
|
|
185
188
|
];
|
|
186
189
|
if (input.licenseKey)
|
|
187
190
|
lines.push(`OMB_LICENSE_KEY=${input.licenseKey}`);
|
|
191
|
+
if (input.portalUrl)
|
|
192
|
+
lines.push(`OMB_ADMIN_URL=${input.portalUrl}`, `OMB_ADMIN_WORKSPACE=${input.workspace.slug}`);
|
|
188
193
|
return lines.join("\n") + "\n";
|
|
189
194
|
}
|
|
195
|
+
export const MANAGED_OPENROUTER = "omb-managed-openrouter";
|
|
196
|
+
/** The gateway validates assignments too; keep filesystem seeds bounded and literal. */
|
|
197
|
+
export function openRouterModelIds(value) {
|
|
198
|
+
if (!Array.isArray(value) || value.length > 100 || value.some((id) => typeof id !== "string" || !/^[a-zA-Z0-9][a-zA-Z0-9._:/+-]{0,159}$/.test(id))) {
|
|
199
|
+
throw new Error("OpenRouter models must be an array of at most 100 valid model IDs");
|
|
200
|
+
}
|
|
201
|
+
return [...new Set(value)];
|
|
202
|
+
}
|
|
203
|
+
export function managedOpenRouterModels(models) {
|
|
204
|
+
return Object.fromEntries(openRouterModelIds(models).map((id) => [id, { name: id }]));
|
|
205
|
+
}
|
|
190
206
|
/** The workspace's first config.json: who may sign in, the key it runs on,
|
|
191
207
|
* and its cap. Everything else stays the server's defaults. */
|
|
192
208
|
export function initialConfig(seed) {
|
|
193
209
|
const config = { signIn: { admins: seed.admins, members: seed.members } };
|
|
194
210
|
if (seed.anthropicKey)
|
|
195
|
-
config.anthropic = { key: seed.anthropicKey };
|
|
211
|
+
config.anthropic = { key: seed.anthropicKey, ...(seed.anthropicUrl ? { url: seed.anthropicUrl } : {}) };
|
|
212
|
+
if (seed.openrouterDefault && seed.openrouterModels?.length)
|
|
213
|
+
config.defaultModelSelection = { instanceId: "opencodeGo", model: `${MANAGED_OPENROUTER}/${seed.openrouterModels[0]}` };
|
|
196
214
|
if (seed.monthlyCapUsd !== undefined)
|
|
197
215
|
config.budgets = { monthlyUsd: seed.monthlyCapUsd };
|
|
198
216
|
return `${JSON.stringify(config, null, 2)}\n`;
|
|
@@ -238,6 +256,26 @@ export function initPlan(input) {
|
|
|
238
256
|
}
|
|
239
257
|
export function createPlan(input) {
|
|
240
258
|
assertSlug(input.slug);
|
|
259
|
+
if (input.seed.portalUrl) {
|
|
260
|
+
const portal = new URL(input.seed.portalUrl);
|
|
261
|
+
if (portal.protocol !== "https:" || portal.origin !== input.seed.portalUrl)
|
|
262
|
+
throw new Error("portalUrl must be an HTTPS origin without a path or credentials");
|
|
263
|
+
if (portal.hostname === `${input.slug}.${input.registry.domain}`)
|
|
264
|
+
throw new Error("this workspace name is reserved for the admin portal");
|
|
265
|
+
if (input.seed.anthropicUrl && input.seed.anthropicUrl !== `${portal.origin}/api/gateway/${input.slug}/anthropic`)
|
|
266
|
+
throw new Error("managed provider URL must belong to this workspace's portal gateway");
|
|
267
|
+
}
|
|
268
|
+
else if (input.seed.anthropicUrl)
|
|
269
|
+
throw new Error("a managed provider URL requires portalUrl");
|
|
270
|
+
if (input.seed.openrouterKey !== undefined || input.seed.openrouterUrl !== undefined || input.seed.openrouterModels !== undefined || input.seed.openrouterDefault) {
|
|
271
|
+
if (!input.seed.portalUrl || input.seed.openrouterUrl !== `${input.seed.portalUrl}/api/gateway/${input.slug}/openrouter/v1`)
|
|
272
|
+
throw new Error("managed OpenRouter URL must belong to this workspace's portal gateway");
|
|
273
|
+
if (!input.seed.openrouterKey || !/^[!-~]+$/.test(input.seed.openrouterKey))
|
|
274
|
+
throw new Error("managed OpenRouter requires a non-empty workspace credential without whitespace");
|
|
275
|
+
const models = openRouterModelIds(input.seed.openrouterModels ?? []);
|
|
276
|
+
if (input.seed.openrouterDefault && !models.length)
|
|
277
|
+
throw new Error("a default OpenRouter model requires a model assignment");
|
|
278
|
+
}
|
|
241
279
|
if (input.registry.workspaces[input.slug])
|
|
242
280
|
throw new Error(`workspace "${input.slug}" already exists`);
|
|
243
281
|
if (!input.seed.admins.length)
|
|
@@ -257,15 +295,32 @@ export function createPlan(input) {
|
|
|
257
295
|
createdAt: (input.now ?? new Date()).toISOString(),
|
|
258
296
|
};
|
|
259
297
|
const registry = { ...input.registry, nextPort: next, workspaces: { ...input.registry.workspaces, [workspace.slug]: workspace } };
|
|
298
|
+
const reserved = (accountCreated) => ({
|
|
299
|
+
...registry,
|
|
300
|
+
workspaces: { ...registry.workspaces, [workspace.slug]: { ...workspace, status: "provisioning", accountCreated } },
|
|
301
|
+
});
|
|
260
302
|
const user = fleetUser(workspace.slug);
|
|
261
303
|
const home = workspaceHome(layout, workspace.slug);
|
|
262
304
|
const dataDir = workspaceDataDir(layout, workspace.slug);
|
|
263
305
|
const steps = [
|
|
306
|
+
// Reserve the identity and ports before any external resource is created.
|
|
307
|
+
// A crash leaves an inspectable reservation, never a silently reusable home.
|
|
308
|
+
{ kind: "write", path: layout.registryFile, content: `${JSON.stringify(reserved(false), null, 2)}\n`, mode: 0o600 },
|
|
264
309
|
{ kind: "run", argv: ["useradd", "--system", "--create-home", "--home-dir", home, "--shell", "/usr/sbin/nologin", "--user-group", user], why: `the workspace's own account` },
|
|
310
|
+
{ kind: "write", path: layout.registryFile, content: `${JSON.stringify(reserved(true), null, 2)}\n`, mode: 0o600 },
|
|
265
311
|
{ kind: "mkdir", path: dataDir, mode: 0o700, owner: user },
|
|
266
312
|
{ kind: "write", path: join(dataDir, "config.json"), content: initialConfig(input.seed), mode: 0o600, owner: user },
|
|
313
|
+
...(input.seed.openrouterKey ? [
|
|
314
|
+
{ kind: "mkdir", path: join(home, ".config"), mode: 0o700, owner: user },
|
|
315
|
+
{ kind: "mkdir", path: join(home, ".config", "opencode"), mode: 0o700, owner: user },
|
|
316
|
+
{ kind: "write", path: join(home, ".config", "opencode", "opencode.json"), mode: 0o600, owner: user, content: `${JSON.stringify({ provider: { [MANAGED_OPENROUTER]: {
|
|
317
|
+
npm: "@ai-sdk/openai-compatible", name: "Managed OpenRouter",
|
|
318
|
+
options: { baseURL: input.seed.openrouterUrl, apiKey: input.seed.openrouterKey },
|
|
319
|
+
models: managedOpenRouterModels(input.seed.openrouterModels ?? []),
|
|
320
|
+
} } }, null, 2)}\n` },
|
|
321
|
+
] : []),
|
|
267
322
|
...(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 },
|
|
323
|
+
{ kind: "write", path: join(layout.instancesDir, `${workspace.slug}.env`), content: instanceEnv({ workspace, dataDir, licenseKey: input.licenseKey, portalUrl: input.seed.portalUrl }), mode: 0o600 },
|
|
269
324
|
...(input.memoryMax
|
|
270
325
|
? [
|
|
271
326
|
{ kind: "mkdir", path: join(layout.unitFile.replace(/openmausbot@\.service$/, ""), `openmausbot@${workspace.slug}.service.d`), mode: 0o755 },
|
|
@@ -284,11 +339,17 @@ export function createPlan(input) {
|
|
|
284
339
|
];
|
|
285
340
|
return { steps, registry, workspace };
|
|
286
341
|
}
|
|
342
|
+
/** Incomplete provisioning and retained homes need explicit operator recovery. */
|
|
343
|
+
export function assertManagedWorkspace(workspace) {
|
|
344
|
+
if (workspace.status !== "running" && workspace.status !== "suspended")
|
|
345
|
+
throw new Error(`workspace "${workspace.slug}" is ${workspace.status} and requires operator recovery`);
|
|
346
|
+
}
|
|
287
347
|
function withStatus(registry, slug, status) {
|
|
288
348
|
assertSlug(slug);
|
|
289
349
|
const current = registry.workspaces[slug];
|
|
290
350
|
if (!current)
|
|
291
351
|
throw new Error(`no workspace "${slug}"`);
|
|
352
|
+
assertManagedWorkspace(current);
|
|
292
353
|
const workspace = { ...current, status };
|
|
293
354
|
return { registry: { ...registry, workspaces: { ...registry.workspaces, [slug]: workspace } }, workspace };
|
|
294
355
|
}
|
|
@@ -325,8 +386,9 @@ export function deletePlan(input) {
|
|
|
325
386
|
const workspace = input.registry.workspaces[input.slug];
|
|
326
387
|
if (!workspace)
|
|
327
388
|
throw new Error(`no workspace "${input.slug}"`);
|
|
389
|
+
assertManagedWorkspace(workspace);
|
|
328
390
|
const { [input.slug]: _gone, ...rest } = input.registry.workspaces;
|
|
329
|
-
const registry = { ...input.registry, workspaces: rest };
|
|
391
|
+
const registry = { ...input.registry, workspaces: input.keepData ? { ...rest, [input.slug]: { ...workspace, status: "retained" } } : rest };
|
|
330
392
|
const unitsDir = layout.unitFile.replace(/openmausbot@\.service$/, "");
|
|
331
393
|
return {
|
|
332
394
|
registry,
|
|
@@ -337,8 +399,12 @@ export function deletePlan(input) {
|
|
|
337
399
|
{ kind: "remove", path: join(layout.instancesDir, `${workspace.slug}.env`) },
|
|
338
400
|
{ kind: "remove", path: join(unitsDir, `openmausbot@${workspace.slug}.service.d`, "limits.conf") },
|
|
339
401
|
{ 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
|
-
|
|
402
|
+
{ kind: "run", argv: ["nft", "-f", layout.fenceFile], why: input.keepData ? "keep the retained account's fence" : "drop its fence rule" },
|
|
403
|
+
// Retain the nologin account too: recycling its UID would give a new
|
|
404
|
+
// workspace ownership of the preserved home, even under a different slug.
|
|
405
|
+
...(input.keepData
|
|
406
|
+
? [{ kind: "note", text: "keeping the disabled workspace's account, home and name reserved; operator recovery is required before reuse" }]
|
|
407
|
+
: [{ kind: "run", argv: ["userdel", "--remove", fleetUser(workspace.slug)], why: "remove the account and everything it owned" }]),
|
|
342
408
|
{ kind: "write", path: layout.registryFile, content: `${JSON.stringify(registry, null, 2)}\n`, mode: 0o600 },
|
|
343
409
|
],
|
|
344
410
|
};
|
|
@@ -368,26 +434,34 @@ export function applySignIn(configText, action, email, chatOnly) {
|
|
|
368
434
|
if (action === "remove") {
|
|
369
435
|
if (admins.length + members.length === list(current.admins).length + list(current.members).length)
|
|
370
436
|
throw new Error(`${entry} is not on the list`);
|
|
371
|
-
|
|
437
|
+
if (!admins.length)
|
|
438
|
+
throw new Error("a workspace needs at least one admin; add another admin first");
|
|
439
|
+
return { config: `${JSON.stringify({ ...raw, signIn: { admins, members } }, null, 2)}\n`, summary: `${entry} was removed from the allow-list; existing email sessions are rechecked against the remaining entries` };
|
|
372
440
|
}
|
|
373
441
|
const next = chatOnly ? { admins, members: [...members, entry] } : { admins: [...admins, entry], members };
|
|
442
|
+
if (!next.admins.length)
|
|
443
|
+
throw new Error("a workspace needs at least one admin; add another admin first");
|
|
374
444
|
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
445
|
}
|
|
376
446
|
function shellQuote(value) {
|
|
377
447
|
return /^[A-Za-z0-9_@%+=:,./-]+$/.test(value) ? value : `'${value.replaceAll("'", "'\\''")}'`;
|
|
378
448
|
}
|
|
379
|
-
/**
|
|
449
|
+
/** Inspectable plan. Tenant I/O must use the executor's privilege drop. */
|
|
380
450
|
export function describeSteps(steps) {
|
|
381
451
|
const lines = [];
|
|
382
452
|
for (const step of steps) {
|
|
383
453
|
switch (step.kind) {
|
|
384
454
|
case "mkdir":
|
|
385
|
-
lines.push(
|
|
455
|
+
lines.push(step.owner
|
|
456
|
+
? `# Create ${shellQuote(step.path)} mode ${step.mode.toString(8)} as ${step.owner} (use fleet --yes; never root chown on tenant paths)`
|
|
457
|
+
: `install -d -m ${step.mode.toString(8)} ${shellQuote(step.path)}`);
|
|
386
458
|
break;
|
|
387
459
|
case "write":
|
|
460
|
+
if (step.owner) {
|
|
461
|
+
lines.push(`# Write ${shellQuote(step.path)} mode ${step.mode.toString(8)} as ${step.owner} (use fleet --yes; tenant content omitted)`);
|
|
462
|
+
break;
|
|
463
|
+
}
|
|
388
464
|
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
465
|
break;
|
|
392
466
|
case "append-once":
|
|
393
467
|
lines.push(`grep -qxF ${shellQuote(step.line)} ${shellQuote(step.path)} || printf '\\n%s\\n' ${shellQuote(step.line)} >> ${shellQuote(step.path)}`);
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
// This versions only the hosted consume/check wire contract, not the app release.
|
|
2
|
+
export const HOSTED_CONTRACT_VERSION = 1;
|
|
3
|
+
export const HOSTED_CONTRACT_HEADER = "x-omb-hosted-contract-version";
|
|
4
|
+
export const HOSTED_CONTRACT_METADATA = Object.freeze({
|
|
5
|
+
contractVersion: HOSTED_CONTRACT_VERSION,
|
|
6
|
+
supportedContractVersions: Object.freeze([HOSTED_CONTRACT_VERSION]),
|
|
7
|
+
legacyPolicy: "legacy-v1",
|
|
8
|
+
});
|
|
9
|
+
export const HOSTED_CONTRACT_MISMATCH = "HOSTED_CONTRACT_MISMATCH";
|
|
10
|
+
export const HOSTED_CONTRACT_ERROR = "Admin and workspace runtime versions are incompatible. Ask your administrator to update the deployment.";
|
|
11
|
+
/** legacy-v1 accepts an omitted version only for the existing v1 payload.
|
|
12
|
+
* Remove this transition at protocol v2; never retry an unsupported explicit
|
|
13
|
+
* version without its version field or infer compatibility from app versions. */
|
|
14
|
+
export function hostedContractCompatible(version) {
|
|
15
|
+
return version === undefined ? HOSTED_CONTRACT_VERSION === 1 : version === HOSTED_CONTRACT_VERSION;
|
|
16
|
+
}
|