openmausbot 0.1.76 → 0.1.78
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-BqDJqf2C.js} +1 -1
- package/dist/assets/index-DjwAroIZ.js +305 -0
- package/dist/assets/index-Pbb6Ao0s.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +5 -1
- package/dist-server/drivers/agents-proxy.js +838 -30
- package/dist-server/enterprise/server/index.js +387 -29
- package/dist-server/index.js +4097 -2063
- package/dist-server/openmausbot.js +730 -523
- package/dist-server/pair-cli.js +730 -523
- package/dist-server/server/agent-tool-policy.js +1 -0
- package/dist-server/server/auto-approve.js +16 -0
- package/dist-server/server/bot-overview.js +6 -2
- package/dist-server/server/bot-package.js +17 -3
- package/dist-server/server/browser-live.js +3 -0
- package/dist-server/server/chief-of-staff.js +2 -2
- 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 +132 -27
- 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 +877 -197
- package/dist-server/server/message-file.js +4 -0
- package/dist-server/server/package-export.js +16 -14
- package/dist-server/server/peer-approval.js +1 -1
- package/dist-server/server/profile-requests.js +26 -4
- package/dist-server/server/provider-key-check.js +18 -1
- package/dist-server/server/request-auth.js +14 -5
- package/dist-server/server/room-handoffs.js +38 -1
- package/dist-server/server/routine-requests.js +86 -3
- package/dist-server/server/routines.js +31 -13
- package/dist-server/server/sessions.js +115 -8
- package/dist-server/server/setup-mode.js +11 -13
- package/dist-server/server/shared-computer-control.js +24 -0
- package/dist-server/server/shared-computers.js +131 -0
- package/dist-server/server/skill-learn.js +4 -4
- package/dist-server/server/skills.js +21 -0
- package/dist-server/server/steer-queue.js +2 -0
- package/dist-server/server/store.js +110 -8
- package/dist-server/server/system-prompt.js +7 -5
- package/dist-server/server/team-backup.js +6 -2
- package/dist-server/server/team-computers.js +108 -0
- package/dist-server/server/team-setup-requests.js +83 -31
- 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/cron-label.js +39 -0
- package/dist-server/shared/routine-schedule.js +89 -0
- package/dist-server/shared/team-backup.js +1 -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
|
@@ -21,7 +21,7 @@ import { ClaudeLoginController } from "./claude-login-auth.js";
|
|
|
21
21
|
import { computerProxyEnv } from "../container-computer.js";
|
|
22
22
|
import { gateServer, resultBudget } from "../mcp-gate-config.js";
|
|
23
23
|
import { newEventId, newId } from "../contracts.js";
|
|
24
|
-
import { askInputSummary, commandSummary } from "../tool-summary.js";
|
|
24
|
+
import { askInputSummary, commandSummary, toolDetailPreview } from "../tool-summary.js";
|
|
25
25
|
import { classifyError, computeBackoff, interruptibleDelay, RETRY_MAX_ATTEMPTS } from "./retry.js";
|
|
26
26
|
import { applyClaudeInject, decodeInjectId, mergeLocalInject, probeLocalInjects, resolveInjectId, } from "./local-inject.js";
|
|
27
27
|
import { appendNative } from "./native.js";
|
|
@@ -1396,6 +1396,7 @@ export const ClaudeDriver = {
|
|
|
1396
1396
|
itemId: b.id,
|
|
1397
1397
|
title: b.name,
|
|
1398
1398
|
summary: commandSummary(b.input),
|
|
1399
|
+
input: toolDetailPreview(b.input),
|
|
1399
1400
|
});
|
|
1400
1401
|
}
|
|
1401
1402
|
}
|
|
@@ -1415,7 +1416,7 @@ export const ClaudeDriver = {
|
|
|
1415
1416
|
case "user":
|
|
1416
1417
|
for (const b of Array.isArray(o.message?.content) ? o.message.content : []) {
|
|
1417
1418
|
if (b.type === "tool_result") {
|
|
1418
|
-
emit({ ...base(threadId, currentTurnId()), type: "item.completed", itemType: "tool", itemId: b.tool_use_id, ok: !b.is_error });
|
|
1419
|
+
emit({ ...base(threadId, currentTurnId()), type: "item.completed", itemType: "tool", itemId: b.tool_use_id, ok: !b.is_error, output: toolDetailPreview(b.content) });
|
|
1419
1420
|
}
|
|
1420
1421
|
}
|
|
1421
1422
|
break;
|
|
@@ -22,7 +22,7 @@ import { codexLocalProviderArgs } from "./local-inject.js";
|
|
|
22
22
|
import { augmentedPath, splitCliString } from "../env-path.js";
|
|
23
23
|
import { classifyError, computeBackoff, RETRY_MAX_ATTEMPTS } from "./retry.js";
|
|
24
24
|
import { appendNative } from "./native.js";
|
|
25
|
-
import { commandSummary } from "../tool-summary.js";
|
|
25
|
+
import { commandSummary, toolDetailPreview } from "../tool-summary.js";
|
|
26
26
|
import { codexDeveloperInstructions, syncCodexInstructions } from "./codex-instructions.js";
|
|
27
27
|
import { CodexDeviceAuthController } from "./codex-device-auth.js";
|
|
28
28
|
import { codexAccountEmail } from "./codex-identity.js";
|
|
@@ -799,6 +799,7 @@ export const CodexDriver = {
|
|
|
799
799
|
itemId: item.id,
|
|
800
800
|
title,
|
|
801
801
|
summary: item.type === "commandExecution" ? commandSummary({ command: item.command }) : undefined,
|
|
802
|
+
input: toolDetailPreview(item.type === "commandExecution" ? { command: item.command, cwd: item.cwd } : item.type === "mcpToolCall" ? item.arguments : item.type === "fileChange" ? item.changes : item.query),
|
|
802
803
|
});
|
|
803
804
|
}
|
|
804
805
|
break;
|
|
@@ -839,6 +840,7 @@ export const CodexDriver = {
|
|
|
839
840
|
itemType: "tool",
|
|
840
841
|
itemId: item.id,
|
|
841
842
|
ok: item.status !== "failed" && item.status !== "declined",
|
|
843
|
+
output: toolDetailPreview(item.type === "commandExecution" ? { output: item.aggregatedOutput, exitCode: item.exitCode } : item.type === "mcpToolCall" ? item.error ?? item.result : item.type === "fileChange" ? item.changes : item.action),
|
|
842
844
|
});
|
|
843
845
|
}
|
|
844
846
|
else if (item.type === "reasoning") {
|
|
@@ -28,6 +28,7 @@ import { computerProxyEnv } from "../container-computer.js";
|
|
|
28
28
|
import { augmentedPath } from "../env-path.js";
|
|
29
29
|
import { describeSpawnFailure, execCli, killCliTree, spawnCli } from "../procs.js";
|
|
30
30
|
import { SPAWNED_PROXIES } from "../proxy-paths.js";
|
|
31
|
+
import { commandSummary, toolDetailPreview } from "../tool-summary.js";
|
|
31
32
|
import { EFFORT_LEVELS, newEventId, newId } from "../contracts.js";
|
|
32
33
|
import { decodeInjectId, encodeInjectId, hostApiKey, localHost, mergeLocalInject, } from "./local-inject.js";
|
|
33
34
|
import { appendNative } from "./native.js";
|
|
@@ -610,6 +611,8 @@ export const PiDriver = {
|
|
|
610
611
|
itemType: "tool",
|
|
611
612
|
itemId: evt.toolCallId,
|
|
612
613
|
title: String(evt.toolName ?? "tool").slice(0, 80),
|
|
614
|
+
summary: commandSummary(evt.args),
|
|
615
|
+
input: toolDetailPreview(evt.args),
|
|
613
616
|
});
|
|
614
617
|
return;
|
|
615
618
|
}
|
|
@@ -620,6 +623,7 @@ export const PiDriver = {
|
|
|
620
623
|
itemType: "tool",
|
|
621
624
|
itemId: evt.toolCallId,
|
|
622
625
|
ok: !evt.isError,
|
|
626
|
+
output: toolDetailPreview(evt.result),
|
|
623
627
|
});
|
|
624
628
|
return;
|
|
625
629
|
}
|
|
@@ -27,6 +27,40 @@ const layerSchema = z.object({
|
|
|
27
27
|
});
|
|
28
28
|
const DEFAULT_DIR = join(SERVER_ROOT, "..", "enterprise");
|
|
29
29
|
let current = { edition: "oss", features: [] };
|
|
30
|
+
let workspaceAccessFactory;
|
|
31
|
+
export function hostedWorkspaceConfigured(env = process.env) {
|
|
32
|
+
return env.OMB_ADMIN_URL !== undefined || env.OMB_ADMIN_WORKSPACE !== undefined || env.OMB_ADMIN_MEMBERSHIP !== undefined;
|
|
33
|
+
}
|
|
34
|
+
/** Validate the operator's complete hosted configuration before any session
|
|
35
|
+
* can delegate membership authority. A partial setting is never standalone. */
|
|
36
|
+
export function hostedWorkspaceConfiguration(env = process.env) {
|
|
37
|
+
try {
|
|
38
|
+
const origin = (raw) => {
|
|
39
|
+
if (!raw || raw !== raw.trim())
|
|
40
|
+
throw new Error("missing origin");
|
|
41
|
+
const url = new URL(raw);
|
|
42
|
+
if (url.protocol !== "https:" || (raw !== url.origin && raw !== `${url.origin}/`))
|
|
43
|
+
throw new Error("invalid origin");
|
|
44
|
+
return url;
|
|
45
|
+
};
|
|
46
|
+
if (!/^[a-z][a-z0-9-]{1,30}$/.test(env.OMB_ADMIN_WORKSPACE ?? ""))
|
|
47
|
+
return null;
|
|
48
|
+
if (env.OMB_ADMIN_MEMBERSHIP !== undefined && !["local", "portal"].includes(env.OMB_ADMIN_MEMBERSHIP))
|
|
49
|
+
return null;
|
|
50
|
+
return { admin: origin(env.OMB_ADMIN_URL), tenant: origin(env.OMB_PUBLIC_URL), workspace: env.OMB_ADMIN_WORKSPACE, portalMembership: env.OMB_ADMIN_MEMBERSHIP === "portal" };
|
|
51
|
+
}
|
|
52
|
+
catch {
|
|
53
|
+
return null;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
/** No enterprise import or portal dependency enters the core bundle. A
|
|
57
|
+
* configured server without this hook must refuse hosted access, not fall
|
|
58
|
+
* back to legacy email or QR credentials. */
|
|
59
|
+
export function createWorkspaceAccess(options) {
|
|
60
|
+
if (!hostedWorkspaceConfigured(options.env))
|
|
61
|
+
return null;
|
|
62
|
+
return workspaceAccessFactory?.({ ...options, entitled: () => entitled("admin") }) ?? null;
|
|
63
|
+
}
|
|
30
64
|
function oss(notice) {
|
|
31
65
|
current = notice ? { edition: "oss", features: [], notice } : { edition: "oss", features: [] };
|
|
32
66
|
return current;
|
|
@@ -34,6 +68,7 @@ function oss(notice) {
|
|
|
34
68
|
/** Resolve the edition once at startup. Never throws: a broken layer or key
|
|
35
69
|
* degrades to the open-source edition with a notice that says what to fix. */
|
|
36
70
|
export async function loadEnterpriseLayer(options = {}) {
|
|
71
|
+
workspaceAccessFactory = undefined;
|
|
37
72
|
const dir = options.dir ?? process.env.OMB_ENTERPRISE_DIR ?? DEFAULT_DIR;
|
|
38
73
|
const licenseKey = options.licenseKey ?? process.env.OMB_LICENSE_KEY;
|
|
39
74
|
// Source in a checkout, a compiled bundle in an image: same convention as proxy-paths.ts.
|
|
@@ -43,6 +78,9 @@ export async function loadEnterpriseLayer(options = {}) {
|
|
|
43
78
|
}
|
|
44
79
|
try {
|
|
45
80
|
const loaded = await import(__rewriteRelativeImportExtension(pathToFileURL(entry).href));
|
|
81
|
+
const access = Reflect.get(Object(loaded), "createWorkspaceAccess");
|
|
82
|
+
if (typeof access === "function")
|
|
83
|
+
workspaceAccessFactory = access;
|
|
46
84
|
const register = Reflect.get(Object(loaded), "register");
|
|
47
85
|
if (typeof register !== "function")
|
|
48
86
|
throw new Error(`${entry} does not export register()`);
|
|
@@ -99,6 +99,9 @@ export function environmentDescriptor(input) {
|
|
|
99
99
|
version: serverVersion(),
|
|
100
100
|
capabilities: {
|
|
101
101
|
remoteSessions: true,
|
|
102
|
+
// Never advertise a protocol this server would refuse: the routes are
|
|
103
|
+
// gone unless features.sharedComputers is on, so the capability is too.
|
|
104
|
+
...(input.sharedComputers === true ? { sharedComputers: true } : {}),
|
|
102
105
|
selfUpdate: input.desktopManaged ? "desktop-managed" : "operator",
|
|
103
106
|
emailSignIn: input.emailSignIn === true,
|
|
104
107
|
},
|
|
@@ -6,9 +6,8 @@
|
|
|
6
6
|
import { appendFileSync, chmodSync, existsSync, mkdirSync, rmSync } from "node:fs";
|
|
7
7
|
import { createServer } from "node:http";
|
|
8
8
|
import { dirname } from "node:path";
|
|
9
|
-
import { defaultFleetDeps, executePlan, loadRegistry, planFleetAction } from "./fleet-cli.js";
|
|
10
|
-
import { assertSlug, fleetLayout, workspaceDataDir } from "./fleet.js";
|
|
11
|
-
import { readUsage, summarizeUsage } from "./usage-ledger.js";
|
|
9
|
+
import { defaultFleetDeps, executePlan, loadRegistry, markProvisioningFailed, planFleetAction } from "./fleet-cli.js";
|
|
10
|
+
import { assertSlug, fleetLayout, fleetUser, openRouterModelIds, workspaceDataDir } from "./fleet.js";
|
|
12
11
|
const MAX_BODY = 256 * 1024;
|
|
13
12
|
function readBody(req) {
|
|
14
13
|
return new Promise((resolve, reject) => {
|
|
@@ -35,11 +34,23 @@ function readBody(req) {
|
|
|
35
34
|
const strings = (value) => (Array.isArray(value) ? value.filter((item) => typeof item === "string").map((item) => item.trim()).filter(Boolean) : []);
|
|
36
35
|
const text = (value) => (typeof value === "string" && value.trim() ? value.trim() : undefined);
|
|
37
36
|
const number = (value) => (typeof value === "number" && Number.isFinite(value) ? value : undefined);
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
37
|
+
const models = (value) => {
|
|
38
|
+
try {
|
|
39
|
+
return openRouterModelIds(value);
|
|
40
|
+
}
|
|
41
|
+
catch (error) {
|
|
42
|
+
throw Object.assign(error, { status: 400 });
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
/** Only bounded, unprivileged aggregates cross back into the root process. */
|
|
46
|
+
export function workspaceUsage(layout, slug, now, deps) {
|
|
47
|
+
const month = now.toISOString().slice(0, 7);
|
|
48
|
+
try {
|
|
49
|
+
return { month, ...deps.usage(workspaceDataDir(layout, slug), fleetUser(slug), now) };
|
|
50
|
+
}
|
|
51
|
+
catch {
|
|
52
|
+
return { month, turns: null, costUsd: null, billableUsd: null, unavailable: true };
|
|
53
|
+
}
|
|
43
54
|
}
|
|
44
55
|
export function createFleetAgent(options) {
|
|
45
56
|
const deps = options.deps ?? defaultFleetDeps();
|
|
@@ -59,7 +70,7 @@ export function createFleetAgent(options) {
|
|
|
59
70
|
/* the audit line must never fail the action it records */
|
|
60
71
|
}
|
|
61
72
|
};
|
|
62
|
-
const
|
|
73
|
+
const runAction = async (input) => {
|
|
63
74
|
const log = [];
|
|
64
75
|
const errors = [];
|
|
65
76
|
const io = { log: (line) => log.push(line), error: (line) => errors.push(line) };
|
|
@@ -71,10 +82,32 @@ export function createFleetAgent(options) {
|
|
|
71
82
|
audit({ action: input.action, slug: input.slug, ok: false, error: error instanceof Error ? error.message : String(error) });
|
|
72
83
|
return { status: 400, body: { error: error instanceof Error ? error.message : String(error) } };
|
|
73
84
|
}
|
|
74
|
-
|
|
85
|
+
let code = 1;
|
|
86
|
+
try {
|
|
87
|
+
code = await executePlan(steps, deps, io);
|
|
88
|
+
}
|
|
89
|
+
catch (error) {
|
|
90
|
+
errors.push(error instanceof Error ? error.message : String(error));
|
|
91
|
+
}
|
|
92
|
+
if (code !== 0) {
|
|
93
|
+
try {
|
|
94
|
+
markProvisioningFailed(input, deps);
|
|
95
|
+
}
|
|
96
|
+
catch {
|
|
97
|
+
errors.push("could not record the failed provisioning; inspect the reserved workspace before recovery");
|
|
98
|
+
}
|
|
99
|
+
}
|
|
75
100
|
audit({ action: input.action, slug: input.slug, ok: code === 0, ...(input.email ? { email: input.email } : {}) });
|
|
76
101
|
return code === 0 ? { status: 200, body: { ok: true, log } } : { status: 500, body: { error: errors[0] ?? "the step failed", log: [...log, ...errors] } };
|
|
77
102
|
};
|
|
103
|
+
// Planning reads the registry too: serialize the entire read/modify/execute
|
|
104
|
+
// sequence, not just the subprocesses, so ports and updates cannot collide.
|
|
105
|
+
let mutationTail = Promise.resolve();
|
|
106
|
+
const act = (input) => {
|
|
107
|
+
const result = mutationTail.then(() => runAction(input));
|
|
108
|
+
mutationTail = result.catch(() => { });
|
|
109
|
+
return result;
|
|
110
|
+
};
|
|
78
111
|
const handle = async (req, res) => {
|
|
79
112
|
const url = new URL(req.url ?? "/", "http://fleet");
|
|
80
113
|
const method = req.method ?? "GET";
|
|
@@ -87,10 +120,13 @@ export function createFleetAgent(options) {
|
|
|
87
120
|
return send(200, { ok: true });
|
|
88
121
|
if (method === "GET" && url.pathname === "/workspaces") {
|
|
89
122
|
const registry = loadRegistry(layout, deps);
|
|
123
|
+
const statusOnly = url.searchParams.get("statusOnly") === "true";
|
|
90
124
|
const workspaces = [];
|
|
91
125
|
for (const workspace of Object.values(registry.workspaces).sort((a, b) => a.slug.localeCompare(b.slug))) {
|
|
92
|
-
const live =
|
|
93
|
-
|
|
126
|
+
const live = workspace.status === "running" || workspace.status === "suspended"
|
|
127
|
+
? (await deps.run(["systemctl", "is-active", `openmausbot@${workspace.slug}.service`])).output.trim() || "unknown"
|
|
128
|
+
: workspace.status;
|
|
129
|
+
workspaces.push(statusOnly ? { slug: workspace.slug, live } : { ...workspace, live, usage: workspaceUsage(layout, workspace.slug, now(), deps) });
|
|
94
130
|
}
|
|
95
131
|
return send(200, { domain: registry.domain, operator: registry.operator ?? null, workspaces });
|
|
96
132
|
}
|
|
@@ -99,6 +135,9 @@ export function createFleetAgent(options) {
|
|
|
99
135
|
const result = await act({
|
|
100
136
|
...base(), action: "create", slug: text(body.slug), admins: strings(body.admins), members: strings(body.members),
|
|
101
137
|
cap: number(body.cap), memory: text(body.memory), brandJson: text(body.brandJson), anthropicKey: text(body.anthropicKey),
|
|
138
|
+
portalUrl: text(body.portalUrl), anthropicUrl: text(body.anthropicUrl),
|
|
139
|
+
openrouterKey: text(body.openrouterKey), openrouterUrl: text(body.openrouterUrl),
|
|
140
|
+
openrouterModels: body.openrouterModels === undefined ? undefined : models(body.openrouterModels), openrouterDefault: body.openrouterDefault === true,
|
|
102
141
|
});
|
|
103
142
|
return send(result.status, result.body);
|
|
104
143
|
}
|
|
@@ -106,11 +145,16 @@ export function createFleetAgent(options) {
|
|
|
106
145
|
const result = await act({ ...base(), action: "upgrade" });
|
|
107
146
|
return send(result.status, result.body);
|
|
108
147
|
}
|
|
109
|
-
const match = /^\/workspaces\/([a-z0-9-]+)(?:\/(users|suspend|resume))?$/.exec(url.pathname);
|
|
148
|
+
const match = /^\/workspaces\/([a-z0-9-]+)(?:\/(users|providers|suspend|resume))?$/.exec(url.pathname);
|
|
110
149
|
if (match) {
|
|
111
150
|
const slug = match[1];
|
|
112
151
|
assertSlug(slug);
|
|
113
152
|
const sub = match[2];
|
|
153
|
+
if (method === "POST" && sub === "providers") {
|
|
154
|
+
const body = await readBody(req);
|
|
155
|
+
const result = await act({ ...base(), action: "providers", slug, openrouterModels: models(body.models) });
|
|
156
|
+
return send(result.status, result.body);
|
|
157
|
+
}
|
|
114
158
|
if (method === "POST" && sub === "users") {
|
|
115
159
|
const body = await readBody(req);
|
|
116
160
|
const action = body.action === "remove" ? "remove" : body.action === "add" ? "add" : undefined;
|
|
@@ -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)}`);
|