openmausbot 0.1.67 → 0.1.69
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-BwpNqgCm.js → index-BmHupgVJ.js} +1 -1
- package/dist/assets/index-COp5lMnv.js +279 -0
- package/dist/assets/index-Jk7TIeyW.css +1 -0
- package/dist/index.html +2 -2
- package/dist-server/container-mcp.js +40 -2
- package/dist-server/drivers/agents-proxy.js +112 -7
- package/dist-server/enterprise/server/index.js +14587 -0
- package/dist-server/index.js +1732 -681
- package/dist-server/openmausbot.js +939 -381
- package/dist-server/pair-cli.js +939 -381
- package/dist-server/server/avatar-image.js +86 -34
- package/dist-server/server/bot-package.js +60 -14
- package/dist-server/server/brand.js +7 -0
- package/dist-server/server/cli.js +23 -1
- package/dist-server/server/config.js +30 -3
- package/dist-server/server/delegations.js +56 -11
- package/dist-server/server/drivers/acp/qwen.js +142 -5
- package/dist-server/server/drivers/agents-proxy.js +135 -8
- package/dist-server/server/drivers/codex-device-auth.js +102 -3
- package/dist-server/server/drivers/codex-identity.js +115 -0
- package/dist-server/server/drivers/codex.js +6 -0
- package/dist-server/server/harness/registry.js +2 -0
- package/dist-server/server/index.js +220 -65
- package/dist-server/server/package-export.js +7 -0
- package/dist-server/server/provider-auth-sessions.js +24 -0
- package/dist-server/server/routine-requests.js +270 -25
- package/dist-server/server/routines.js +215 -21
- package/dist-server/server/service-cli.js +63 -0
- package/dist-server/server/service-unit.js +129 -0
- package/dist-server/server/steer-queue.js +30 -1
- package/dist-server/server/store.js +30 -4
- package/dist-server/server/system-prompt.js +1 -0
- package/dist-server/server/turn-dispatch-guard.js +6 -0
- package/dist-server/shared/image-generation.js +28 -0
- package/dist-server/vps-container-mcp.js +40 -2
- package/enterprise/LICENSE +32 -0
- package/enterprise/README.md +89 -0
- package/enterprise/server/index.js +14587 -0
- package/package.json +2 -1
- package/dist/assets/index-A3CBTxwl.css +0 -1
- package/dist/assets/index-CDuyoVPJ.js +0 -278
|
@@ -1,4 +1,6 @@
|
|
|
1
1
|
import { z } from "zod";
|
|
2
|
+
import { normalizeImageGenerationUrl } from "../shared/image-generation.js";
|
|
3
|
+
import { decodeGeneratedImage } from "./generated-image.js";
|
|
2
4
|
export const AVATAR_DIRECTION_MAX_CHARS = 400;
|
|
3
5
|
export const AVATAR_IMAGE_TIMEOUT_MS = 120_000;
|
|
4
6
|
const MAX_UPSTREAM_RESPONSE_BYTES = 15 * 1024 * 1024;
|
|
@@ -6,8 +8,34 @@ export const avatarGenerationRequestSchema = z.object({
|
|
|
6
8
|
prompt: z.string().trim().max(AVATAR_DIRECTION_MAX_CHARS).default(""),
|
|
7
9
|
});
|
|
8
10
|
const generatedImageResponseSchema = z.object({
|
|
9
|
-
data: z.array(z.object({ b64_json: z.string().min(1) })).min(1),
|
|
11
|
+
data: z.array(z.object({ b64_json: z.string().min(1).optional(), url: z.string().optional() })).min(1),
|
|
10
12
|
});
|
|
13
|
+
/** Public settings describe availability without returning provider secrets. */
|
|
14
|
+
export function avatarImageStatus(cfg) {
|
|
15
|
+
const provider = cfg.imageGen?.provider ?? "openai";
|
|
16
|
+
const openaiConfigured = Boolean(cfg.imageGen?.key?.trim());
|
|
17
|
+
const xaiConfigured = Boolean(cfg.xai?.key?.trim());
|
|
18
|
+
const customKeyConfigured = Boolean(cfg.imageGen?.customApiKey?.trim());
|
|
19
|
+
const customModel = cfg.imageGen?.customModel?.trim() ?? "";
|
|
20
|
+
let customUrl = "";
|
|
21
|
+
try {
|
|
22
|
+
if (cfg.imageGen?.customUrl?.trim())
|
|
23
|
+
customUrl = normalizeImageGenerationUrl(cfg.imageGen.customUrl);
|
|
24
|
+
}
|
|
25
|
+
catch {
|
|
26
|
+
// A manually edited invalid URL must not become an outbound request.
|
|
27
|
+
}
|
|
28
|
+
return {
|
|
29
|
+
provider,
|
|
30
|
+
configured: provider === "openai" ? openaiConfigured : provider === "xai" ? xaiConfigured : Boolean(customUrl && customModel),
|
|
31
|
+
model: provider === "openai" ? "gpt-image-2" : provider === "xai" ? "grok-imagine-image-2.0" : customModel,
|
|
32
|
+
customUrl,
|
|
33
|
+
customModel,
|
|
34
|
+
openaiConfigured,
|
|
35
|
+
xaiConfigured,
|
|
36
|
+
customKeyConfigured,
|
|
37
|
+
};
|
|
38
|
+
}
|
|
11
39
|
/** Copy the mutable avatar fields before an asynchronous generation starts. */
|
|
12
40
|
export function snapshotAvatarGenerationState(bot) {
|
|
13
41
|
return { avatarUrl: bot.avatarUrl, avatarCrop: bot.avatarCrop };
|
|
@@ -53,7 +81,14 @@ async function boundedResponseText(response) {
|
|
|
53
81
|
let received = 0;
|
|
54
82
|
try {
|
|
55
83
|
for (;;) {
|
|
56
|
-
|
|
84
|
+
let chunk;
|
|
85
|
+
try {
|
|
86
|
+
chunk = await reader.read();
|
|
87
|
+
}
|
|
88
|
+
catch {
|
|
89
|
+
throw Object.assign(new Error("Could not read the generated avatar response"), { status: 502 });
|
|
90
|
+
}
|
|
91
|
+
const { done, value } = chunk;
|
|
57
92
|
if (done)
|
|
58
93
|
break;
|
|
59
94
|
received += value.byteLength;
|
|
@@ -70,31 +105,45 @@ async function boundedResponseText(response) {
|
|
|
70
105
|
reader.releaseLock();
|
|
71
106
|
}
|
|
72
107
|
}
|
|
73
|
-
export async function generateAvatarImage(
|
|
74
|
-
|
|
75
|
-
|
|
108
|
+
export async function generateAvatarImage(cfg, bot, direction, fetchImpl = fetch, timeoutMs = AVATAR_IMAGE_TIMEOUT_MS) {
|
|
109
|
+
const settings = avatarImageStatus(cfg);
|
|
110
|
+
const { provider, model } = settings;
|
|
111
|
+
if (!settings.configured) {
|
|
112
|
+
const message = provider === "openai" ? "Add an OpenAI image API key first"
|
|
113
|
+
: provider === "xai" ? "Add an xAI API key first"
|
|
114
|
+
: "Set a valid custom image API URL and model first";
|
|
115
|
+
throw Object.assign(new Error(message), { status: 409 });
|
|
116
|
+
}
|
|
117
|
+
const apiKey = (provider === "openai" ? cfg.imageGen?.key
|
|
118
|
+
: provider === "xai" ? cfg.xai?.key : cfg.imageGen?.customApiKey)?.trim();
|
|
119
|
+
const providerName = provider === "openai" ? "OpenAI" : provider === "xai" ? "xAI" : "Custom";
|
|
120
|
+
const url = provider === "openai" ? "https://api.openai.com/v1/images/generations"
|
|
121
|
+
: provider === "xai" ? "https://api.x.ai/v1/images/generations"
|
|
122
|
+
: `${settings.customUrl}/images/generations`;
|
|
123
|
+
const headers = { "content-type": "application/json" };
|
|
124
|
+
if (apiKey)
|
|
125
|
+
headers.authorization = `Bearer ${apiKey}`;
|
|
126
|
+
const body = {
|
|
127
|
+
model,
|
|
128
|
+
prompt: avatarGenerationPrompt(bot, direction),
|
|
129
|
+
...(provider === "openai" ? { size: "1024x1024", quality: "low", output_format: "webp" }
|
|
130
|
+
: provider === "xai" ? { response_format: "b64_json", aspect_ratio: "1:1" }
|
|
131
|
+
: { response_format: "b64_json", size: "1024x1024", n: 1 }),
|
|
132
|
+
};
|
|
76
133
|
const timeoutSignal = AbortSignal.timeout(timeoutMs);
|
|
77
134
|
let response;
|
|
78
135
|
try {
|
|
79
|
-
response = await fetchImpl(
|
|
136
|
+
response = await fetchImpl(url, {
|
|
80
137
|
method: "POST",
|
|
81
|
-
headers
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
},
|
|
85
|
-
body: JSON.stringify({
|
|
86
|
-
model: "gpt-image-2",
|
|
87
|
-
prompt: avatarGenerationPrompt(bot, direction),
|
|
88
|
-
size: "1024x1024",
|
|
89
|
-
quality: "low",
|
|
90
|
-
output_format: "webp",
|
|
91
|
-
}),
|
|
138
|
+
headers,
|
|
139
|
+
body: JSON.stringify(body),
|
|
140
|
+
redirect: "error",
|
|
92
141
|
signal: timeoutSignal,
|
|
93
142
|
});
|
|
94
143
|
}
|
|
95
144
|
catch (error) {
|
|
96
145
|
const timedOut = timeoutSignal.aborted || (error instanceof Error && error.name === "TimeoutError");
|
|
97
|
-
throw Object.assign(new Error(timedOut ? "Avatar generation timed out" :
|
|
146
|
+
throw Object.assign(new Error(timedOut ? "Avatar generation timed out" : `Could not reach ${providerName} image generation`), { status: 502 });
|
|
98
147
|
}
|
|
99
148
|
let text;
|
|
100
149
|
try {
|
|
@@ -110,15 +159,9 @@ export async function generateAvatarImage(apiKey, bot, direction, fetchImpl = fe
|
|
|
110
159
|
throw error;
|
|
111
160
|
}
|
|
112
161
|
if (!response.ok) {
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
if (parsed.success)
|
|
117
|
-
message = parsed.data.error.message.slice(0, 500);
|
|
118
|
-
}
|
|
119
|
-
catch {
|
|
120
|
-
// Keep the bounded status-only message for malformed upstream errors.
|
|
121
|
-
}
|
|
162
|
+
// Upstream errors can repeat authorization headers, signed URLs, or other
|
|
163
|
+
// secrets. Return only the provider and HTTP status, never its raw body.
|
|
164
|
+
const message = `${providerName} image generation failed (HTTP ${response.status})`;
|
|
122
165
|
throw Object.assign(new Error(message), { status: response.status === 401 ? 401 : 502 });
|
|
123
166
|
}
|
|
124
167
|
let parsedJson;
|
|
@@ -126,19 +169,28 @@ export async function generateAvatarImage(apiKey, bot, direction, fetchImpl = fe
|
|
|
126
169
|
parsedJson = JSON.parse(text);
|
|
127
170
|
}
|
|
128
171
|
catch {
|
|
129
|
-
throw Object.assign(new Error(
|
|
172
|
+
throw Object.assign(new Error(`${providerName} returned an invalid image response`), { status: 502 });
|
|
130
173
|
}
|
|
131
174
|
const parsed = generatedImageResponseSchema.safeParse(parsedJson);
|
|
132
175
|
if (!parsed.success) {
|
|
133
|
-
throw Object.assign(new Error(
|
|
176
|
+
throw Object.assign(new Error(`${providerName} returned no generated image`), { status: 502 });
|
|
134
177
|
}
|
|
135
178
|
const encoded = parsed.data.data[0].b64_json;
|
|
179
|
+
if (!encoded) {
|
|
180
|
+
// Do not fetch arbitrary URLs returned by a provider. Compatible gateways
|
|
181
|
+
// must support the requested inline base64 response format.
|
|
182
|
+
throw Object.assign(new Error(`${providerName} image generation must return base64 image data (b64_json)`), { status: 502 });
|
|
183
|
+
}
|
|
136
184
|
if (!/^[A-Za-z0-9+/]+={0,2}$/.test(encoded) || encoded.length % 4 !== 0) {
|
|
137
|
-
throw Object.assign(new Error(
|
|
185
|
+
throw Object.assign(new Error(`${providerName} returned invalid image data`), { status: 502 });
|
|
138
186
|
}
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
187
|
+
try {
|
|
188
|
+
const image = decodeGeneratedImage(encoded);
|
|
189
|
+
if (image.mime === "image/gif")
|
|
190
|
+
throw new Error("unsupported avatar format");
|
|
191
|
+
return { bytes: image.bytes, mime: image.mime };
|
|
192
|
+
}
|
|
193
|
+
catch {
|
|
194
|
+
throw Object.assign(new Error(`${providerName} returned invalid or oversized image data; expected PNG, JPEG, or WebP`), { status: 502 });
|
|
142
195
|
}
|
|
143
|
-
return { bytes, mime: "image/webp" };
|
|
144
196
|
}
|
|
@@ -27,6 +27,52 @@ const key = requiredText(64).regex(/^[a-z0-9][a-z0-9_-]*$/, {
|
|
|
27
27
|
message: "may only contain lowercase letters, numbers, - and _",
|
|
28
28
|
});
|
|
29
29
|
const MAX_DATE_MS = 8_640_000_000_000_000;
|
|
30
|
+
const CLOCK_TIME = /^([01]\d|2[0-3]):[0-5]\d$/;
|
|
31
|
+
const intervalWeekdays = z.array(z.number().int().min(0).max(6)).min(1).max(7).refine((weekdays) => new Set(weekdays).size === weekdays.length, "must contain unique weekdays");
|
|
32
|
+
const intervalWindow = z.object({
|
|
33
|
+
start: z.string().regex(CLOCK_TIME, { message: "must use HH:MM" }),
|
|
34
|
+
end: z.string().regex(CLOCK_TIME, { message: "must use HH:MM" }),
|
|
35
|
+
}).strict().refine(({ start, end }) => start < end, {
|
|
36
|
+
message: "must end later on the same day",
|
|
37
|
+
});
|
|
38
|
+
const packageRoutineScheduleSchema = z.discriminatedUnion("type", [
|
|
39
|
+
z.object({ type: z.literal("once"), at: z.number().int() }),
|
|
40
|
+
z.object({
|
|
41
|
+
type: z.literal("daily"),
|
|
42
|
+
time: requiredText(5).regex(CLOCK_TIME, { message: "must use HH:MM" }),
|
|
43
|
+
weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
|
|
44
|
+
}),
|
|
45
|
+
z.object({
|
|
46
|
+
type: z.literal("interval"),
|
|
47
|
+
everyMinutes: z.number().int().min(5).max(1_440),
|
|
48
|
+
anchorAt: z.number().int().nonnegative().max(MAX_DATE_MS),
|
|
49
|
+
weekdays: intervalWeekdays.optional(),
|
|
50
|
+
window: intervalWindow.optional(),
|
|
51
|
+
endsAt: z.number().int().nonnegative().max(MAX_DATE_MS).optional(),
|
|
52
|
+
}),
|
|
53
|
+
]).superRefine((schedule, context) => {
|
|
54
|
+
if (schedule.type !== "interval")
|
|
55
|
+
return;
|
|
56
|
+
if (schedule.window) {
|
|
57
|
+
const [startHour, startMinute] = schedule.window.start.split(":").map(Number);
|
|
58
|
+
const [endHour, endMinute] = schedule.window.end.split(":").map(Number);
|
|
59
|
+
const windowMinutes = endHour * 60 + endMinute - (startHour * 60 + startMinute);
|
|
60
|
+
if (windowMinutes < schedule.everyMinutes) {
|
|
61
|
+
context.addIssue({
|
|
62
|
+
code: "custom",
|
|
63
|
+
message: "must be at least as long as the interval cadence",
|
|
64
|
+
path: ["window"],
|
|
65
|
+
});
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
if (schedule.endsAt !== undefined && schedule.endsAt < schedule.anchorAt) {
|
|
69
|
+
context.addIssue({
|
|
70
|
+
code: "custom",
|
|
71
|
+
message: "must not be before the interval anchor",
|
|
72
|
+
path: ["endsAt"],
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
});
|
|
30
76
|
const packageSchema = z.object({
|
|
31
77
|
format: z.literal(BOT_PACKAGE_FORMAT, { error: "This is not an OpenMaus package" }),
|
|
32
78
|
version: z.literal(BOT_PACKAGE_VERSION, { error: "Package version is not supported" }),
|
|
@@ -86,19 +132,7 @@ const packageSchema = z.object({
|
|
|
86
132
|
agent: key,
|
|
87
133
|
prompt: requiredText(20_000),
|
|
88
134
|
runOn: z.enum(["maus", "cloud"]),
|
|
89
|
-
schedule:
|
|
90
|
-
z.object({ type: z.literal("once"), at: z.number().int() }),
|
|
91
|
-
z.object({
|
|
92
|
-
type: z.literal("daily"),
|
|
93
|
-
time: requiredText(5).regex(/^([01]\d|2[0-3]):[0-5]\d$/, { message: "must use HH:MM" }),
|
|
94
|
-
weekdays: z.array(z.number().int().min(0).max(6)).min(1).max(7),
|
|
95
|
-
}),
|
|
96
|
-
z.object({
|
|
97
|
-
type: z.literal("interval"),
|
|
98
|
-
everyMinutes: z.number().int().min(5).max(1_440),
|
|
99
|
-
anchorAt: z.number().int().nonnegative().max(MAX_DATE_MS),
|
|
100
|
-
}),
|
|
101
|
-
]),
|
|
135
|
+
schedule: packageRoutineScheduleSchema,
|
|
102
136
|
durationMinutes: z.number().int().min(5).max(240),
|
|
103
137
|
timeoutMinutes: z.number().int().min(5).max(240).optional(),
|
|
104
138
|
enabledAfterInstall: z.literal(false),
|
|
@@ -200,6 +234,18 @@ export function parseBotPackage(value) {
|
|
|
200
234
|
return parsed.data;
|
|
201
235
|
}
|
|
202
236
|
const list = (values) => values.map((value) => `- ${value}`).join("\n");
|
|
237
|
+
const WEEKDAY_NAMES = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"];
|
|
238
|
+
function intervalScheduleText(schedule) {
|
|
239
|
+
const restrictions = [
|
|
240
|
+
schedule.weekdays?.length
|
|
241
|
+
? `on ${schedule.weekdays.map((day) => WEEKDAY_NAMES[day]).join(", ")}`
|
|
242
|
+
: null,
|
|
243
|
+
schedule.window ? `during ${schedule.window.start}–${schedule.window.end}` : null,
|
|
244
|
+
schedule.endsAt !== undefined ? `until ${new Date(schedule.endsAt).toISOString()}` : null,
|
|
245
|
+
].filter((part) => part !== null);
|
|
246
|
+
const cadence = `every ${schedule.everyMinutes} minutes from ${new Date(schedule.anchorAt).toISOString()}`;
|
|
247
|
+
return restrictions.length ? `${cadence}; ${restrictions.join("; ")}` : cadence;
|
|
248
|
+
}
|
|
203
249
|
/** Render the public artifact. The frontmatter enables deterministic imports;
|
|
204
250
|
* the body is deliberately complete enough for any Chief-of-Staff agent to
|
|
205
251
|
* run without OpenMausBot or another proprietary parser. */
|
|
@@ -226,7 +272,7 @@ export function renderBotPackageMarkdown(document) {
|
|
|
226
272
|
`**Schedule:** ${routine.schedule.type === "daily"
|
|
227
273
|
? `${routine.schedule.time} on weekdays ${routine.schedule.weekdays.join(", ")}`
|
|
228
274
|
: routine.schedule.type === "interval"
|
|
229
|
-
?
|
|
275
|
+
? intervalScheduleText(routine.schedule)
|
|
230
276
|
: `once at ${routine.schedule.at}`} `,
|
|
231
277
|
`**Run limit:** ${routine.timeoutMinutes === undefined ? "none" : `${routine.timeoutMinutes} minutes`} `,
|
|
232
278
|
"**Initial state:** paused — the user must enable it",
|
|
@@ -10,6 +10,7 @@ import { DATA_DIR } from "./config.js";
|
|
|
10
10
|
import { entitled } from "./enterprise.js";
|
|
11
11
|
const HEX_COLOUR = /^#[0-9a-fA-F]{6}$/;
|
|
12
12
|
const MAX_LOGO_CHARS = 300_000;
|
|
13
|
+
const MAX_FAVICON_CHARS = 120_000;
|
|
13
14
|
export const brandSchema = z
|
|
14
15
|
.object({
|
|
15
16
|
/** Product name shown in the window title, onboarding and settings. */
|
|
@@ -26,6 +27,12 @@ export const brandSchema = z
|
|
|
26
27
|
.optional(),
|
|
27
28
|
/** Where "get help" points for this deployment. */
|
|
28
29
|
supportUrl: z.string().url().startsWith("https://", "supportUrl must be an https:// URL").optional(),
|
|
30
|
+
/** Tab and home-screen icon: an inline data:image/... URI (PNG or SVG, small) or an https:// URL. */
|
|
31
|
+
favicon: z
|
|
32
|
+
.string()
|
|
33
|
+
.max(MAX_FAVICON_CHARS, `favicon must be under ${MAX_FAVICON_CHARS} characters (a 64px PNG or a small SVG)`)
|
|
34
|
+
.refine((v) => v.startsWith("data:image/") || v.startsWith("https://"), "favicon must be a data:image/... URI or an https:// URL")
|
|
35
|
+
.optional(),
|
|
29
36
|
})
|
|
30
37
|
.strict();
|
|
31
38
|
export const DEFAULT_BRAND = { name: "OpenMausBot" };
|
|
@@ -32,12 +32,13 @@ import qrcode from "qrcode-terminal";
|
|
|
32
32
|
import { parseAllowList } from "./account-signin.js";
|
|
33
33
|
import { writeFileAtomic } from "./atomic.js";
|
|
34
34
|
import { ensureCaddy, normalizeDomainOption, startCaddy } from "./caddy.js";
|
|
35
|
+
import { runServiceCommand } from "./service-cli.js";
|
|
35
36
|
import { explainTailscaleFailure, tailscaleServe, tailscaleServeOff, tailscaleStatus } from "./tailscale.js";
|
|
36
37
|
import { defaultSetupIo, SetupCancelled } from "./cli-prompts.js";
|
|
37
38
|
import { normalizePhoneOrigin, phonePairingInstructions, runPhoneSetup } from "./cli-phone-setup.js";
|
|
38
39
|
import { cleanupTunnelOrigin, createTunnelAccount, createTunnelOrigin, describeTunnelAccount, describeTunnelState, ensureCloudflared, FLEET_CREDENTIAL_ENV, fleetAccess, fleetCredential, guardianEntry, startTunnel, tunnelAccess, } from "./tunnel.js";
|
|
39
40
|
const HERE = dirname(fileURLToPath(import.meta.url));
|
|
40
|
-
const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "access", "browser", "help", "--help", "-h"];
|
|
41
|
+
const COMMANDS = ["setup", "start", "serve", "pair", "sessions", "status", "login", "logout", "access", "service", "browser", "help", "--help", "-h"];
|
|
41
42
|
export function parseArgs(argv, env = process.env) {
|
|
42
43
|
const implicitStart = !argv.length || (argv[0].startsWith("--") && argv[0] !== "--help");
|
|
43
44
|
const [command = "start", ...rest] = implicitStart ? ["start", ...argv] : argv;
|
|
@@ -105,6 +106,8 @@ export function parseArgs(argv, env = process.env) {
|
|
|
105
106
|
}
|
|
106
107
|
else if (options.command === "access" && arg === "--chat-only")
|
|
107
108
|
options.chatOnly = true;
|
|
109
|
+
else if (options.command === "service" && !options.serviceAction && (arg === "install" || arg === "uninstall"))
|
|
110
|
+
options.serviceAction = arg;
|
|
108
111
|
else if (options.command === "browser" && (arg === "install" || arg === "status"))
|
|
109
112
|
options.browserAction = arg;
|
|
110
113
|
else if (options.command === "browser" && arg === "--with-deps")
|
|
@@ -124,6 +127,8 @@ export function parseArgs(argv, env = process.env) {
|
|
|
124
127
|
return { error: "choose one of --tailscale (your tailnet) and --tunnel (a public address)" };
|
|
125
128
|
if (options.command === "access" && !options.accessAction)
|
|
126
129
|
return { error: "access needs one of: list, add EMAIL [--chat-only], remove EMAIL" };
|
|
130
|
+
if (options.command === "service" && !options.serviceAction)
|
|
131
|
+
return { error: "service needs one of: install [the same options as serve], uninstall" };
|
|
127
132
|
if (options.domain && (options.tailscale || options.tunnel || options.publicUrl))
|
|
128
133
|
return { error: "--domain already gives the server its address; drop --tailscale, --tunnel and --public-url" };
|
|
129
134
|
if (options.local && (options.tailscale || options.tunnel || options.publicUrl))
|
|
@@ -145,6 +150,7 @@ export const USAGE = `openmausbot — your team of AI bots, ready in a few steps
|
|
|
145
150
|
openmausbot login [--email you@example.com]
|
|
146
151
|
openmausbot logout
|
|
147
152
|
openmausbot access list | add EMAIL [--chat-only] | remove EMAIL
|
|
153
|
+
openmausbot service install [--domain HOST | --tunnel | --tailscale] [--port N] [--data-dir DIR] | uninstall
|
|
148
154
|
openmausbot browser install [--with-deps] | status
|
|
149
155
|
|
|
150
156
|
setup choose AI access and optional phone access; keep existing bots and chats
|
|
@@ -159,6 +165,10 @@ logout releases that address and signs out
|
|
|
159
165
|
access who may sign in with an emailed code at /pair: an address or
|
|
160
166
|
@domain; --chat-only gives chat and approvals without settings.
|
|
161
167
|
Takes effect at once, no restart.
|
|
168
|
+
service keep the server running across reboots: writes a systemd unit
|
|
169
|
+
(Linux) or a launchd agent (macOS) for the same serve options and
|
|
170
|
+
prints the commands that install it. Install the package
|
|
171
|
+
permanently first (npm install -g openmausbot).
|
|
162
172
|
browser install: the bots' browser engine (agent-browser, pinned) into the
|
|
163
173
|
data dir, and Chrome for Testing into the user's browser cache.
|
|
164
174
|
--with-deps also installs
|
|
@@ -1013,6 +1023,18 @@ export async function main(argv = process.argv.slice(2)) {
|
|
|
1013
1023
|
return runLogin(options);
|
|
1014
1024
|
case "access":
|
|
1015
1025
|
return runAccess(options);
|
|
1026
|
+
case "service":
|
|
1027
|
+
return runServiceCommand({
|
|
1028
|
+
action: options.serviceAction ?? "install",
|
|
1029
|
+
dataDir: options.dataDir,
|
|
1030
|
+
port: options.port,
|
|
1031
|
+
domain: options.domain,
|
|
1032
|
+
tunnel: options.tunnel,
|
|
1033
|
+
tailscale: options.tailscale,
|
|
1034
|
+
label: options.label,
|
|
1035
|
+
script: process.argv[1] ?? "",
|
|
1036
|
+
node: process.execPath,
|
|
1037
|
+
}, { log: (line) => console.log(line), error: (line) => console.error(line) });
|
|
1016
1038
|
case "logout":
|
|
1017
1039
|
return runLogout(options);
|
|
1018
1040
|
case "browser":
|
|
@@ -5,6 +5,7 @@ import { readFileSync, mkdirSync, existsSync, renameSync } from "node:fs";
|
|
|
5
5
|
import { homedir } from "node:os";
|
|
6
6
|
import { join } from "node:path";
|
|
7
7
|
import { z } from "zod";
|
|
8
|
+
import { normalizeImageGenerationUrl } from "../shared/image-generation.js";
|
|
8
9
|
import { writeFileAtomic } from "./atomic.js";
|
|
9
10
|
import { EFFORT_LEVELS } from "./contracts.js";
|
|
10
11
|
import { parseStoredMcpServer } from "./mcp-registry.js";
|
|
@@ -16,6 +17,8 @@ const BROWSER_PROFILE_ID = /^[a-z0-9_-]{1,40}$/;
|
|
|
16
17
|
export const DEFAULT_ROOM_TURN_TIMEOUT_MINUTES = 5;
|
|
17
18
|
export const MIN_ROOM_TURN_TIMEOUT_MINUTES = 1;
|
|
18
19
|
export const MAX_ROOM_TURN_TIMEOUT_MINUTES = 1_440;
|
|
20
|
+
export const DEFAULT_MAX_CONCURRENT_BOT_THREADS = 3;
|
|
21
|
+
export const MAX_CONCURRENT_BOT_THREADS = 10;
|
|
19
22
|
export const DEFAULT_LOCAL_VM_MODE = "shared";
|
|
20
23
|
export const DEFAULT_LOCAL_VM_MAX_INSTANCES = 2;
|
|
21
24
|
export const MIN_LOCAL_VM_MAX_INSTANCES = 1;
|
|
@@ -245,14 +248,31 @@ const appConfigSchema = z.object({
|
|
|
245
248
|
* engine: "elevenlabs" (default; needs a key) or "system" (the Mac's
|
|
246
249
|
* built-in voices, no key). */
|
|
247
250
|
tts: z.object({ key: optionalText, voice: optionalText, provider: z.enum(["elevenlabs", "system"]).optional() }).optional(),
|
|
248
|
-
/**
|
|
249
|
-
imageGen: z.object({
|
|
251
|
+
/** Avatar provider credentials stay separate; choosing a router never reuses a cloud key. */
|
|
252
|
+
imageGen: z.object({
|
|
253
|
+
provider: z.enum(["openai", "xai", "custom"]).optional(),
|
|
254
|
+
key: optionalText,
|
|
255
|
+
customApiKey: optionalText,
|
|
256
|
+
customUrl: z.string().trim().max(2048).transform((value, ctx) => {
|
|
257
|
+
if (!value)
|
|
258
|
+
return "";
|
|
259
|
+
try {
|
|
260
|
+
return normalizeImageGenerationUrl(value);
|
|
261
|
+
}
|
|
262
|
+
catch (error) {
|
|
263
|
+
ctx.addIssue({ code: "custom", message: error.message });
|
|
264
|
+
return z.NEVER;
|
|
265
|
+
}
|
|
266
|
+
}).optional(),
|
|
267
|
+
customModel: z.string().trim().max(200).refine((value) => !["\r", "\n", "\0"].some((character) => value.includes(character)), "Use a model ID without control characters").optional(),
|
|
268
|
+
}).optional(),
|
|
250
269
|
/** Non-secret profile details shown in the sidebar. */
|
|
251
270
|
profile: z.object({ name: optionalText, email: optionalText }).optional(),
|
|
252
271
|
/** UI language override (BCP-47, lowercase). Empty/absent = follow the
|
|
253
272
|
* system language. Unknown tags degrade to English in the renderer. */
|
|
254
273
|
language: optionalText,
|
|
255
274
|
rooms: roomConfigSchema.optional(),
|
|
275
|
+
threads: z.object({ maxConcurrentPerBot: z.number().int().min(1).max(MAX_CONCURRENT_BOT_THREADS) }).strict().optional(),
|
|
256
276
|
localVm: localVmConfigSchema.optional(),
|
|
257
277
|
features: featureConfigSchema.optional(),
|
|
258
278
|
browserProfiles: browserProfilesSchema.optional(),
|
|
@@ -349,6 +369,9 @@ export function vpsSshAlias(cfg) {
|
|
|
349
369
|
export function roomTurnTimeoutMinutes(cfg) {
|
|
350
370
|
return cfg.rooms?.turnTimeoutMinutes ?? DEFAULT_ROOM_TURN_TIMEOUT_MINUTES;
|
|
351
371
|
}
|
|
372
|
+
export function maxConcurrentBotThreads(cfg) {
|
|
373
|
+
return cfg.threads?.maxConcurrentPerBot ?? DEFAULT_MAX_CONCURRENT_BOT_THREADS;
|
|
374
|
+
}
|
|
352
375
|
export function localVmMode(cfg) {
|
|
353
376
|
return cfg.localVm?.mode ?? DEFAULT_LOCAL_VM_MODE;
|
|
354
377
|
}
|
|
@@ -427,6 +450,8 @@ export function loadConfig() {
|
|
|
427
450
|
cfg.imageGen = { ...cfg.imageGen };
|
|
428
451
|
if (process.env.OMB_OPENAI_IMAGE_KEY !== undefined)
|
|
429
452
|
cfg.imageGen.key = process.env.OMB_OPENAI_IMAGE_KEY;
|
|
453
|
+
if (process.env.OMB_CUSTOM_IMAGE_KEY !== undefined)
|
|
454
|
+
cfg.imageGen.customApiKey = process.env.OMB_CUSTOM_IMAGE_KEY;
|
|
430
455
|
// The sign-in allow-list: env is how a headless box or a container is
|
|
431
456
|
// bootstrapped before anyone can reach Settings.
|
|
432
457
|
const splitEmails = (value) => value.split(/[,\s]+/).map((entry) => entry.trim().toLowerCase()).filter(Boolean);
|
|
@@ -455,6 +480,7 @@ export function syncCredentialEnv(patch) {
|
|
|
455
480
|
[patch.opencodeGo?.apiKey, "OPENCODE_API_KEY"],
|
|
456
481
|
[patch.tts?.key, "OMB_TTS_KEY"],
|
|
457
482
|
[patch.imageGen?.key, "OMB_OPENAI_IMAGE_KEY"],
|
|
483
|
+
[patch.imageGen?.customApiKey, "OMB_CUSTOM_IMAGE_KEY"],
|
|
458
484
|
];
|
|
459
485
|
for (const [value, name] of secrets) {
|
|
460
486
|
if (value === undefined)
|
|
@@ -493,6 +519,7 @@ export const WORKSPACE_CREDENTIAL_ENV = [
|
|
|
493
519
|
"OPENCODE_API_KEY",
|
|
494
520
|
"OMB_TTS_KEY",
|
|
495
521
|
"OMB_OPENAI_IMAGE_KEY",
|
|
522
|
+
"OMB_CUSTOM_IMAGE_KEY",
|
|
496
523
|
"COMPOSIO_API_KEY",
|
|
497
524
|
"OMB_COMPOSIO_BROKER_TOKEN",
|
|
498
525
|
// Harness-private filesystem hints are not credentials themselves, but
|
|
@@ -544,7 +571,7 @@ export function saveConfig(patch, options = {}) {
|
|
|
544
571
|
const storedProfiles = storedBrowserProfilesSchema.safeParse(disk.browserProfiles);
|
|
545
572
|
if (storedProfiles.success)
|
|
546
573
|
disk.browserProfiles = storedProfiles.data;
|
|
547
|
-
for (const key of ["xai", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "localVm", "features"]) {
|
|
574
|
+
for (const key of ["xai", "openaiCompat", "composio", "box", "opencodeGo", "tts", "imageGen", "profile", "rooms", "threads", "localVm", "features"]) {
|
|
548
575
|
const section = checkedPatch[key];
|
|
549
576
|
if (!section)
|
|
550
577
|
continue;
|
|
@@ -251,7 +251,10 @@ export function queueDelegation(bus, from, item, maxDepth, sourceThreadId = from
|
|
|
251
251
|
* of the target turn is delegated to `runTarget` so delegations.ts
|
|
252
252
|
* stays free of harness-level concerns (commsDepth is the only thing
|
|
253
253
|
* the caller needs). */
|
|
254
|
-
export function drainDelegations(bus, approvalBus, threadId, runTarget
|
|
254
|
+
export function drainDelegations(bus, approvalBus, threadId, runTarget,
|
|
255
|
+
/** Terminal failures before dispatch also need to wake the source. A
|
|
256
|
+
* launched peer reports through its provider-turn finalizer instead. */
|
|
257
|
+
onSettled) {
|
|
255
258
|
if (drainingThreads.has(threadId)) {
|
|
256
259
|
queuedRedrains.add(threadId);
|
|
257
260
|
return;
|
|
@@ -263,6 +266,10 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
|
|
|
263
266
|
drainingThreads.add(threadId);
|
|
264
267
|
void (async () => {
|
|
265
268
|
for (const item of snapshot) {
|
|
269
|
+
// Stop/deletion may remove queued work while another item awaits a
|
|
270
|
+
// person's approval. A stale snapshot is never authority to launch it.
|
|
271
|
+
if (!pendingDelegations.get(threadId)?.some((candidate) => candidate.id === item.id))
|
|
272
|
+
continue;
|
|
266
273
|
// A shared channel's thread is not owned by any single bot, so each
|
|
267
274
|
// queued item carries its own source bot identity.
|
|
268
275
|
const from = bus.store.botByThread(threadId) ??
|
|
@@ -307,8 +314,19 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
|
|
|
307
314
|
finally {
|
|
308
315
|
// A requeued item (busy target, retries left) stays for the drain
|
|
309
316
|
// that the target's own settling turn will trigger.
|
|
317
|
+
const stillQueued = pendingDelegations.get(threadId)?.some((candidate) => candidate.id === item.id);
|
|
310
318
|
if (outcome !== "requeued")
|
|
311
319
|
acknowledgeDelegation(threadId, item.id);
|
|
320
|
+
if (outcome === "settled" && stillQueued) {
|
|
321
|
+
const receipt = findDelegationReceipt(item.id);
|
|
322
|
+
try {
|
|
323
|
+
if (receipt)
|
|
324
|
+
onSettled?.(receipt);
|
|
325
|
+
}
|
|
326
|
+
catch (error) {
|
|
327
|
+
console.error("delegation settled but its source could not be resumed", error);
|
|
328
|
+
}
|
|
329
|
+
}
|
|
312
330
|
}
|
|
313
331
|
}
|
|
314
332
|
})().finally(() => {
|
|
@@ -321,7 +339,7 @@ export function drainDelegations(bus, approvalBus, threadId, runTarget) {
|
|
|
321
339
|
const snapshotIds = new Set(snapshot.map((item) => item.id));
|
|
322
340
|
const hasNewItems = pendingDelegations.get(threadId)?.some((item) => !snapshotIds.has(item.id)) ?? false;
|
|
323
341
|
if (redrainRequested || hasNewItems) {
|
|
324
|
-
drainDelegations(bus, approvalBus, threadId, runTarget);
|
|
342
|
+
drainDelegations(bus, approvalBus, threadId, runTarget, onSettled);
|
|
325
343
|
}
|
|
326
344
|
});
|
|
327
345
|
}
|
|
@@ -368,6 +386,19 @@ export function discardDelegations(bus, threadId) {
|
|
|
368
386
|
async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarget) {
|
|
369
387
|
let sender = from;
|
|
370
388
|
let target = bus.store.bot(item.toBotId);
|
|
389
|
+
// Retained approval authorizes the message, not a deleted conversation or
|
|
390
|
+
// revoked room membership. Check every retry before writing to its source.
|
|
391
|
+
if (!sourceThreadBelongsToBot(bus.store, sender.id, sourceThreadId)) {
|
|
392
|
+
recordDelegationReceipt({
|
|
393
|
+
id: item.id,
|
|
394
|
+
sourceThreadId,
|
|
395
|
+
toBotId: item.toBotId,
|
|
396
|
+
toBotName: target?.name ?? item.toBotId,
|
|
397
|
+
status: "dropped",
|
|
398
|
+
result: "the source conversation no longer belongs to the delegating bot",
|
|
399
|
+
});
|
|
400
|
+
return "settled";
|
|
401
|
+
}
|
|
371
402
|
if (!target) {
|
|
372
403
|
recordDelegationReceipt({
|
|
373
404
|
id: item.id,
|
|
@@ -422,6 +453,23 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
|
|
|
422
453
|
}
|
|
423
454
|
if (sender.approvePeerComms && !item.approvalAlreadyGranted) {
|
|
424
455
|
const verdict = await requestPeerApproval(approvalBus, sender, target, item.message, "delegate_bot", sourceThreadId);
|
|
456
|
+
if (!pendingDelegations.get(sourceThreadId)?.some((candidate) => candidate.id === item.id))
|
|
457
|
+
return "settled";
|
|
458
|
+
// Approval may have waited for minutes. Revalidate before even reporting
|
|
459
|
+
// a denial, which otherwise recreates a deleted source transcript.
|
|
460
|
+
const current = bus.store.bot(item.toBotId);
|
|
461
|
+
const currentSender = bus.store.bot(from.id);
|
|
462
|
+
if (!current || !currentSender || !sourceThreadBelongsToBot(bus.store, currentSender.id, sourceThreadId)) {
|
|
463
|
+
recordDelegationReceipt({
|
|
464
|
+
id: item.id,
|
|
465
|
+
sourceThreadId,
|
|
466
|
+
toBotId: item.toBotId,
|
|
467
|
+
toBotName: target.name,
|
|
468
|
+
status: "dropped",
|
|
469
|
+
result: "the peer or source conversation no longer exists",
|
|
470
|
+
});
|
|
471
|
+
return "settled";
|
|
472
|
+
}
|
|
425
473
|
if (verdict !== "allow") {
|
|
426
474
|
recordDelegationReceipt({
|
|
427
475
|
id: item.id,
|
|
@@ -438,14 +486,11 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
|
|
|
438
486
|
});
|
|
439
487
|
return "settled";
|
|
440
488
|
}
|
|
441
|
-
//
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
//
|
|
445
|
-
|
|
446
|
-
const currentSender = bus.store.bot(from.id);
|
|
447
|
-
if (!current || !currentSender || !sourceThreadBelongsToBot(bus.store, currentSender.id, sourceThreadId))
|
|
448
|
-
return "settled";
|
|
489
|
+
// A busy-target retry must not ask for the very same approval again.
|
|
490
|
+
item.approvalAlreadyGranted = true;
|
|
491
|
+
savePending();
|
|
492
|
+
// Recheck peer access and busy state too: an approval must not start a
|
|
493
|
+
// second turn or mirror an exchange that cannot actually happen.
|
|
449
494
|
if (dropIfUnreachable(bus, currentSender, current, sourceThreadId, item)) {
|
|
450
495
|
return "settled";
|
|
451
496
|
}
|
|
@@ -490,7 +535,7 @@ async function processOne(bus, approvalBus, from, sourceThreadId, item, runTarge
|
|
|
490
535
|
const reasonLine = item.reason ? `\n\n[Reason: ${item.reason}]` : "";
|
|
491
536
|
const prefixed = `[Delegated by @${sender.name}, another bot in this OpenMausBot workspace. Do the work and reply directly.]\n\n${item.message}${reasonLine}`;
|
|
492
537
|
await runTarget(item.toBotId, prefixed, item.depth + 1, sourceThreadId, channel, item.id, sender.id);
|
|
493
|
-
return "
|
|
538
|
+
return "dispatched";
|
|
494
539
|
}
|
|
495
540
|
/** The source thread may be a bot's own task or a shared group where the
|
|
496
541
|
* bot is a member. This is the same check the API gate uses for group turns. */
|