openmausbot 0.1.63 → 0.1.65

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.
@@ -117,9 +117,12 @@ import { createGracefulShutdown } from "./graceful-shutdown.js";
117
117
  import { acquireDataDirLeaseForProcess } from "./data-dir-lease.js";
118
118
  import { describeEdition, editionStatus, loadEnterpriseLayer } from "./enterprise.js";
119
119
  import { environmentDescriptor, loadEnvironmentId } from "./environment.js";
120
+ import { createCustomDomainVerifier, normalizeCustomDomain } from "./custom-domain.js";
121
+ import { ProviderAuthSessions } from "./provider-auth-sessions.js";
120
122
  import { clearSessionCookie, clientBotPatchViolation, clientGroupPatchViolation, labelFromUserAgent, requestOrigin, requestSource, resolveRequestAuth, parseCookies, serializeSessionCookie, sessionCookieName, } from "./request-auth.js";
121
123
  import { cookieMaxAgeSeconds, formatPairingCode, SessionRegistry } from "./sessions.js";
122
124
  import { describeBrand, loadBrand } from "./brand.js";
125
+ import { deliverSseFrame } from "./sse-fanout.js";
123
126
  import { PHONE_SECRET_PROTOCOL_VERSION, PhoneSecretBridge, PhoneSecretError, PhoneSecretSubmissionRegistry, assertPhoneSecretRequestMatches, phoneSecretOperationId, } from "./phone-secret.js";
124
127
  const PORT = Number(process.env.OMB_PORT || process.env.OGB_PORT || 8799);
125
128
  const WEBHOOK_PORT = Number(process.env.OMB_WEBHOOK_PORT || PORT + 1);
@@ -169,9 +172,29 @@ const DESKTOP_MANAGED = process.env.OMB_DESKTOP_PARENT === "1";
169
172
  let desktopMutationToken = DESKTOP_MANAGED ? "" : undefined;
170
173
  let companionMutationToken = DESKTOP_MANAGED ? "" : undefined;
171
174
  // Where remote clients reach this server (a proxy's public address); pairing URLs use it.
172
- const PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
175
+ const FALLBACK_PUBLIC_URL = process.env.OMB_PUBLIC_URL?.trim().replace(/\/+$/, "") || null;
173
176
  const cfg = loadConfig();
177
+ const customDomainVerifier = createCustomDomainVerifier({ environmentId: ENVIRONMENT_ID });
178
+ let customDomainRevision = 0;
179
+ function savedCustomDomain() {
180
+ if (DESKTOP_MANAGED || !cfg.customDomain)
181
+ return null;
182
+ try {
183
+ return normalizeCustomDomain(cfg.customDomain);
184
+ }
185
+ catch {
186
+ return null;
187
+ }
188
+ }
189
+ function publicUrl() { return savedCustomDomain() ?? FALLBACK_PUBLIC_URL; }
190
+ function customDomainStatus() {
191
+ return {
192
+ customDomain: savedCustomDomain(), publicUrl: publicUrl(), fallbackUrl: FALLBACK_PUBLIC_URL,
193
+ supported: !DESKTOP_MANAGED, appPort: PORT, webhookPort: WEBHOOK_PORT,
194
+ };
195
+ }
174
196
  const registry = new ProviderRegistry(BUILT_IN_DRIVERS);
197
+ const providerAuthSessions = new ProviderAuthSessions();
175
198
  await registry.load(instanceConfigs(cfg));
176
199
  const bundledSkills = loadBundledSkills();
177
200
  const availableSkills = () => mergeSkills(bundledSkills, loadUserSkills(join(DATA_DIR, "skills")));
@@ -521,6 +544,7 @@ function browserIntegration(botId, profile) {
521
544
  session,
522
545
  encryptionKey: browserEngineEncryptionKey(),
523
546
  persistent: profile !== "guest",
547
+ env: { ...process.env, PATH: augmentedPath() },
524
548
  }),
525
549
  };
526
550
  }
@@ -1658,6 +1682,7 @@ function messageWindow(threadId, messageId, limit) {
1658
1682
  }
1659
1683
  const sseClients = new Set();
1660
1684
  sessions.onSessionRevoked((sessionId) => {
1685
+ providerAuthSessions.revokeOwner(sessionId);
1661
1686
  for (const client of sseClients) {
1662
1687
  if (client.sessionId !== sessionId)
1663
1688
  continue;
@@ -1720,10 +1745,9 @@ function broadcast(payload) {
1720
1745
  for (const client of [...sseClients]) {
1721
1746
  if (!wants(client, kind))
1722
1747
  continue;
1723
- try {
1724
- client.res.write(client.admin ? frame : clientFrame);
1725
- }
1726
- catch {
1748
+ // Screen frames are replaceable and durable events are not: see
1749
+ // ./sse-fanout.ts for the backpressure/bound decision this makes.
1750
+ if (deliverSseFrame(client, kind, client.admin ? frame : clientFrame) === "disconnected") {
1727
1751
  sseClients.delete(client);
1728
1752
  }
1729
1753
  }
@@ -6573,6 +6597,7 @@ function persistMcpServers(next) {
6573
6597
  /** Rebuild the provider fleet after a config change so new keys take
6574
6598
  * effect without a server restart (kills any in-flight turns). */
6575
6599
  async function reloadProviders() {
6600
+ providerAuthSessions.clear();
6576
6601
  // Every provider process is about to die. Revoke all turn capabilities in
6577
6602
  // one synchronous step before the first teardown await, including room/task
6578
6603
  // threads that are not a bot's default DM.
@@ -6716,6 +6741,12 @@ const handleRequest = async (req, res) => {
6716
6741
  if (method === "GET" && path === "/.well-known/openmausbot/environment") {
6717
6742
  return json(res, 200, environmentDescriptor({ environmentId: ENVIRONMENT_ID, desktopManaged: DESKTOP_MANAGED }));
6718
6743
  }
6744
+ const domainCheck = /^\/\.well-known\/openmausbot\/domain-check\/([a-f0-9]{64})$/.exec(path);
6745
+ if (method === "GET" && domainCheck) {
6746
+ res.setHeader("cache-control", "no-store");
6747
+ const challenge = customDomainVerifier.challenge(domainCheck[1]);
6748
+ return json(res, challenge ? 200 : 404, challenge ?? { error: "No active domain check." });
6749
+ }
6719
6750
  if (method === "POST" && path === "/api/auth/pair") {
6720
6751
  // JSON only: a cross-site HTML form cannot send this content type
6721
6752
  // without a preflight, so a stray unused code cannot be planted as a
@@ -6803,7 +6834,7 @@ const handleRequest = async (req, res) => {
6803
6834
  const scopes = Array.isArray(requested) ? requested.filter((v) => v === "admin" || v === "client") : undefined;
6804
6835
  const opened = sessions.openPairing({ label: typeof body?.label === "string" ? body.label : undefined, scopes });
6805
6836
  const origin = requestOrigin(req);
6806
- const base = PUBLIC_URL ?? (auth.kind === "session" && origin ? origin : null);
6837
+ const base = publicUrl() ?? (auth.kind === "session" && origin ? origin : null);
6807
6838
  const code = formatPairingCode(opened.code);
6808
6839
  return json(res, 200, {
6809
6840
  id: opened.id,
@@ -6816,7 +6847,42 @@ const handleRequest = async (req, res) => {
6816
6847
  });
6817
6848
  }
6818
6849
  if (method === "GET" && path === "/api/auth/pairing")
6819
- return json(res, 200, { pairings: sessions.openPairings(), publicUrl: PUBLIC_URL });
6850
+ return json(res, 200, { pairings: sessions.openPairings(), publicUrl: publicUrl() });
6851
+ // Admin-only via request-auth's default deny. Connecting a domain only
6852
+ // changes future pairing links; DNS, proxy setup, webhooks and all existing
6853
+ // sessions remain untouched. The tunnel/deployment URL is kept as fallback.
6854
+ if (path === "/api/settings/custom-domain") {
6855
+ res.setHeader("cache-control", "no-store");
6856
+ if (method === "GET")
6857
+ return json(res, 200, customDomainStatus());
6858
+ if (method === "POST" || method === "DELETE") {
6859
+ if (DESKTOP_MANAGED)
6860
+ return json(res, 409, { error: "Custom domains are configured on a self-hosted OpenMausBot server, not the desktop companion." });
6861
+ if (!/^application\/json\b/i.test(String(req.headers["content-type"] ?? ""))) {
6862
+ return json(res, 415, { error: "content-type must be application/json" });
6863
+ }
6864
+ if (method === "DELETE") {
6865
+ saveConfig({ customDomain: "" });
6866
+ cfg.customDomain = "";
6867
+ customDomainRevision++;
6868
+ return json(res, 200, customDomainStatus());
6869
+ }
6870
+ const body = await readBody(req, 4096);
6871
+ if (typeof body?.domain !== "string")
6872
+ return json(res, 400, { error: "Enter your domain name." });
6873
+ const revision = customDomainRevision;
6874
+ const verified = await customDomainVerifier.verify(body.domain);
6875
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
6876
+ return json(res, 401, { error: "Your session ended. Sign in again before connecting a domain." });
6877
+ }
6878
+ if (revision !== customDomainRevision)
6879
+ return json(res, 409, { error: "Domain settings changed during verification. Try again." });
6880
+ saveConfig({ customDomain: verified.origin });
6881
+ cfg.customDomain = verified.origin;
6882
+ customDomainRevision++;
6883
+ return json(res, 200, customDomainStatus());
6884
+ }
6885
+ }
6820
6886
  m = path.match(/^\/api\/auth\/pairing\/([\w-]+)$/);
6821
6887
  if (m && method === "DELETE") {
6822
6888
  const cancelled = sessions.cancelPairing(m[1]);
@@ -8019,7 +8085,12 @@ const handleRequest = async (req, res) => {
8019
8085
  }
8020
8086
  // ── events stream ──
8021
8087
  if (method === "GET" && path === "/api/events") {
8022
- const client = { res, admin: auth.scopes.includes("admin"), screens: url.searchParams.get("screens") !== "off" };
8088
+ const client = {
8089
+ res,
8090
+ admin: auth.scopes.includes("admin"),
8091
+ screens: url.searchParams.get("screens") !== "off",
8092
+ backpressured: false,
8093
+ };
8023
8094
  if (auth.kind === "session")
8024
8095
  client.sessionId = auth.session.id;
8025
8096
  res.writeHead(200, {
@@ -11170,6 +11241,12 @@ const handleRequest = async (req, res) => {
11170
11241
  resetPathCache();
11171
11242
  return json(res, 200, { instances: await registry.describe() });
11172
11243
  }
11244
+ const authStatus = /^\/api\/instances\/([\w.-]+)\/auth\/status$/.exec(path);
11245
+ if (method === "GET" && authStatus) {
11246
+ res.setHeader("cache-control", "no-store");
11247
+ const state = await providerAuthSessions.status(authStatus[1], auth.kind === "session" ? auth.session.id : "loopback", url.searchParams.get("flowId") ?? "");
11248
+ return json(res, 200, { auth: state });
11249
+ }
11173
11250
  const instanceAction = /^\/api\/instances\/([\w.-]+)\/(refresh-models|install|auth\/start|auth\/complete|auth\/cancel)$/.exec(path);
11174
11251
  if (method === "POST" && instanceAction) {
11175
11252
  if (!String(req.headers["content-type"] ?? "").toLowerCase().startsWith("application/json")) {
@@ -11177,6 +11254,9 @@ const handleRequest = async (req, res) => {
11177
11254
  }
11178
11255
  const instanceId = instanceAction[1];
11179
11256
  const action = instanceAction[2];
11257
+ const owner = auth.kind === "session" ? auth.session.id : "loopback";
11258
+ if (action.startsWith("auth/"))
11259
+ res.setHeader("cache-control", "no-store");
11180
11260
  try {
11181
11261
  if (action === "refresh-models") {
11182
11262
  if (!(await registry.refreshModels(instanceId)))
@@ -11189,10 +11269,16 @@ const handleRequest = async (req, res) => {
11189
11269
  return json(res, 200, { instances: await registry.describe() });
11190
11270
  }
11191
11271
  if (action === "auth/start") {
11192
- const auth = await registry.startAuthentication(instanceId);
11193
- if (!auth)
11194
- return json(res, 404, { error: "account setup is unavailable" });
11195
- return json(res, 200, { auth });
11272
+ const instance = registry.get(instanceId);
11273
+ if (!instance)
11274
+ return json(res, 404, { error: "unknown instance" });
11275
+ const started = await providerAuthSessions.start(instance, owner);
11276
+ // Revocation can arrive while the CLI is obtaining a device code.
11277
+ if (auth.kind === "session" && !sessions.isLive(auth.session.id)) {
11278
+ providerAuthSessions.revokeOwner(owner);
11279
+ return json(res, 401, { error: "Your session ended. Start a new sign-in." });
11280
+ }
11281
+ return json(res, 200, { auth: started });
11196
11282
  }
11197
11283
  if (action === "auth/complete") {
11198
11284
  const body = await readBody(req);
@@ -11200,20 +11286,16 @@ const handleRequest = async (req, res) => {
11200
11286
  const callbackUrl = typeof body?.callbackUrl === "string" ? body.callbackUrl : "";
11201
11287
  if (!flowId || !callbackUrl)
11202
11288
  return json(res, 400, { error: "flowId and callbackUrl are required" });
11203
- if (!(await registry.completeAuthentication(instanceId, flowId, callbackUrl))) {
11204
- return json(res, 404, { error: "account setup is unavailable" });
11205
- }
11289
+ await providerAuthSessions.complete(instanceId, owner, flowId, callbackUrl);
11206
11290
  return json(res, 200, { ok: true });
11207
11291
  }
11208
- if (!(await registry.cancelAuthentication(instanceId))) {
11209
- return json(res, 404, { error: "account setup is unavailable" });
11210
- }
11292
+ const body = await readBody(req, 4096);
11293
+ await providerAuthSessions.cancel(instanceId, owner, typeof body?.flowId === "string" ? body.flowId : "");
11211
11294
  return json(res, 200, { ok: true });
11212
11295
  }
11213
11296
  catch (error) {
11214
- const status = error && typeof error === "object" && error.status === 409
11215
- ? 409
11216
- : 500;
11297
+ const requestedStatus = error && typeof error === "object" ? error.status : undefined;
11298
+ const status = typeof requestedStatus === "number" && [400, 401, 404, 409, 413, 415].includes(requestedStatus) ? requestedStatus : 500;
11217
11299
  return json(res, status, { error: error instanceof Error ? error.message : String(error) });
11218
11300
  }
11219
11301
  }
@@ -0,0 +1,111 @@
1
+ const failure = (message, status) => Object.assign(new Error(message), { status });
2
+ export class ProviderAuthSessions {
3
+ flows = new Map();
4
+ async start(instance, owner) {
5
+ if (!instance.startAuthentication)
6
+ throw failure("Account setup is unavailable for this provider.", 404);
7
+ const existing = this.flows.get(instance.instanceId);
8
+ if (existing && (existing.busy || (existing.owner !== owner && existing.expiresAt > Date.now()))) {
9
+ throw failure("A sign-in is already in progress. Finish or cancel it in the browser that started it, or wait for it to expire.", 409);
10
+ }
11
+ // Reopening Settings may resume the same owner's flow. Keep its ownership
12
+ // if the driver's retry fails, rather than orphaning a still-running CLI.
13
+ const resuming = existing && existing.owner === owner && existing.expiresAt > Date.now() && !existing.revoked;
14
+ const flow = resuming ? existing : { instance, owner, flowId: null, expiresAt: Date.now() + 15 * 60_000, busy: false, starting: false, revoked: false };
15
+ flow.busy = true;
16
+ flow.starting = true;
17
+ this.flows.set(instance.instanceId, flow);
18
+ try {
19
+ const result = await instance.startAuthentication();
20
+ if (flow.revoked || this.flows.get(instance.instanceId) !== flow) {
21
+ await instance.cancelAuthentication?.();
22
+ throw failure("Sign-in cancelled because the session or provider changed.", 409);
23
+ }
24
+ flow.flowId = result.flowId;
25
+ const expiry = Date.parse(result.expiresAt ?? "");
26
+ if (Number.isFinite(expiry))
27
+ flow.expiresAt = expiry;
28
+ if (result.phase === "succeeded")
29
+ this.flows.delete(instance.instanceId);
30
+ return result;
31
+ }
32
+ catch (error) {
33
+ if ((!resuming || flow.revoked) && this.flows.get(instance.instanceId) === flow)
34
+ this.flows.delete(instance.instanceId);
35
+ throw error;
36
+ }
37
+ finally {
38
+ flow.busy = false;
39
+ flow.starting = false;
40
+ }
41
+ }
42
+ owned(instanceId, owner, flowId) {
43
+ const flow = this.flows.get(instanceId);
44
+ if (!flowId || !flow || flow.revoked || flow.owner !== owner || flow.flowId !== flowId) {
45
+ throw failure("This sign-in is no longer available in this browser. Start a new sign-in.", 404);
46
+ }
47
+ if (flow.busy)
48
+ throw failure("The sign-in is still being updated. Try again shortly.", 409);
49
+ return flow;
50
+ }
51
+ async status(instanceId, owner, flowId) {
52
+ const flow = this.owned(instanceId, owner, flowId);
53
+ if (!flow.instance.getAuthentication)
54
+ throw failure("Sign-in status is unavailable for this provider.", 404);
55
+ const status = await flow.instance.getAuthentication(flowId);
56
+ if (flow.revoked)
57
+ throw failure("This sign-in session ended.", 404);
58
+ // Keep the terminal result available to its owner, but release the slot so
59
+ // another admin need not wait fifteen minutes after success or cancellation.
60
+ if (status.phase !== "waiting")
61
+ flow.expiresAt = 0;
62
+ return status;
63
+ }
64
+ async complete(instanceId, owner, flowId, callbackUrl) {
65
+ const flow = this.owned(instanceId, owner, flowId);
66
+ if (!flow.instance.completeAuthentication)
67
+ throw failure("This provider does not use a callback URL.", 400);
68
+ flow.busy = true;
69
+ try {
70
+ await flow.instance.completeAuthentication(flowId, callbackUrl);
71
+ if (this.flows.get(instanceId) === flow)
72
+ this.flows.delete(instanceId);
73
+ }
74
+ finally {
75
+ flow.busy = false;
76
+ }
77
+ }
78
+ async cancel(instanceId, owner, flowId) {
79
+ const flow = this.owned(instanceId, owner, flowId);
80
+ flow.busy = true;
81
+ try {
82
+ await flow.instance.cancelAuthentication?.();
83
+ }
84
+ finally {
85
+ flow.busy = false;
86
+ flow.expiresAt = 0;
87
+ }
88
+ }
89
+ revokeOwner(owner) {
90
+ for (const [id, flow] of this.flows) {
91
+ if (flow.owner !== owner)
92
+ continue;
93
+ flow.revoked = true;
94
+ // A pending start owns its eventual cancellation. Keep the reservation
95
+ // until it finishes so another browser cannot race into this auth home.
96
+ if (flow.starting)
97
+ continue;
98
+ flow.busy = true;
99
+ void Promise.resolve().then(() => flow.instance.cancelAuthentication?.()).catch(() => { }).finally(() => {
100
+ if (this.flows.get(id) === flow)
101
+ this.flows.delete(id);
102
+ });
103
+ }
104
+ }
105
+ clear() {
106
+ for (const flow of this.flows.values())
107
+ flow.revoked = true;
108
+ // Called when disposing the provider fleet, which owns child teardown.
109
+ this.flows.clear();
110
+ }
111
+ }
@@ -0,0 +1,53 @@
1
+ // Per-client delivery decision for the SSE broadcast fan-out in index.ts.
2
+ // Pulled into its own file — not just its own function — because index.ts
3
+ // starts the real HTTP server as an import-time side effect (see the bottom
4
+ // of index.ts: `server.listen(...)` runs unconditionally on import), so it
5
+ // can never be imported from a plain unit test. This one function has no
6
+ // dependency on that module and can be driven directly with a fake response.
7
+ /** Past this many buffered bytes, the client isn't draining on any useful
8
+ * timeframe: cut it loose rather than let Node's per-connection write buffer
9
+ * grow without bound. Its own EventSource reconnects and replays via
10
+ * Last-Event-ID (server/index.ts's existing replayBuffer/cursorSeq). */
11
+ // ponytail: fixed byte bound, not adaptive. Retune from real writableLength
12
+ // telemetry (see the audit's validation step) if it trips too eager or late.
13
+ export const SSE_MAX_BUFFERED_BYTES = 4 * 1024 * 1024;
14
+ /** One client's fate for one frame.
15
+ *
16
+ * Screen captures are replaceable: once a client is backpressured, skip them
17
+ * until it drains, since the next capture supersedes whatever was dropped.
18
+ * Durable events (messages, bot/group state, config, ...) are never
19
+ * silently dropped here — they're written regardless of the backpressure
20
+ * flag. The only way a durable event goes missing is the bound below, which
21
+ * disconnects the client outright so its next reconnect replays what it
22
+ * missed instead of silently losing it. */
23
+ export function deliverSseFrame(client, kind, frame) {
24
+ if (client.res.writableLength > SSE_MAX_BUFFERED_BYTES) {
25
+ try {
26
+ client.res.end();
27
+ }
28
+ catch {
29
+ /* already gone */
30
+ }
31
+ return "disconnected";
32
+ }
33
+ if (client.backpressured && kind === "screen")
34
+ return "dropped";
35
+ try {
36
+ if (!client.res.write(frame) && !client.backpressured) {
37
+ // write() returns false as soon as buffered bytes pass the socket's
38
+ // 16 KB highWaterMark, which a single screen frame clears on its own —
39
+ // so this says "hasn't drained yet", not "is a slow client". Without
40
+ // the drain listener the flag latches on for the life of the
41
+ // connection and screen streaming never resumes. One listener per
42
+ // transition into backpressure, never one per frame.
43
+ client.backpressured = true;
44
+ client.res.once("drain", () => {
45
+ client.backpressured = false;
46
+ });
47
+ }
48
+ return "sent";
49
+ }
50
+ catch {
51
+ return "disconnected";
52
+ }
53
+ }
@@ -88,6 +88,20 @@ const DOCUMENT_MIMES = {
88
88
  odp: "application/vnd.oasis.opendocument.presentation",
89
89
  };
90
90
  const ACCEPTED_DOCUMENT_MIMES = new Set(Object.values(DOCUMENT_MIMES));
91
+ const AUDIO_MIMES_BY_EXTENSION = {
92
+ opus: "audio/opus",
93
+ ogg: "audio/ogg",
94
+ mp3: "audio/mpeg",
95
+ m4a: "audio/mp4",
96
+ aac: "audio/aac",
97
+ wav: "audio/wav",
98
+ flac: "audio/flac",
99
+ webm: "audio/webm",
100
+ };
101
+ const ACCEPTED_AUDIO_MIMES = new Set([
102
+ ...Object.values(AUDIO_MIMES_BY_EXTENSION),
103
+ "audio/x-m4a", "audio/x-wav", "audio/wave", "audio/x-flac",
104
+ ]);
91
105
  export function documentMime(file) {
92
106
  const declared = file.type.split(";", 1)[0].trim().toLowerCase();
93
107
  if (ACCEPTED_DOCUMENT_MIMES.has(declared))
@@ -257,11 +271,14 @@ export async function imageAttachmentFromFile(file, optimistic) {
257
271
  ...(optimistic?.previewUrl ? { previewUrl: optimistic.previewUrl } : {}),
258
272
  };
259
273
  }
260
- /** Copy a supported document into the private attachment store. The prompt
274
+ /** Copy a supported document or audio file into the private attachment store. The prompt
261
275
  * then carries the same durable path for the local agent and paired phones,
262
276
  * instead of exposing an arbitrary Finder path to the companion route. */
263
277
  export async function fileAttachmentFromFile(file) {
264
- const mime = documentMime(file);
278
+ const declared = file.type.split(";", 1)[0].trim().toLowerCase();
279
+ const extension = file.name.split(".").at(-1)?.toLowerCase() ?? "";
280
+ const mime = documentMime(file) ??
281
+ (ACCEPTED_AUDIO_MIMES.has(declared) ? declared : AUDIO_MIMES_BY_EXTENSION[extension]);
265
282
  if (!mime)
266
283
  return null;
267
284
  if (file.size > FILE_MAX_BYTES) {
@@ -666,7 +683,7 @@ export async function intakeFiles(_files, _opts) {
666
683
  const rejectedNames = results.flatMap((result) => result.rejectedNames);
667
684
  const uploadErrors = results.flatMap((result) => result.uploadError ? [result.uploadError] : []);
668
685
  const pathless = rejectedNames.length
669
- ? `${rejectedNames.join(", ")} — that file has no path on disk. Save it first, then attach it from Finder.`
686
+ ? `Unable to attach ${rejectedNames.join(", ")}. Choose a supported image, document, or audio file.`
670
687
  : null;
671
688
  const failed = uploadErrors.length ? uploadErrors.join("; ") : null;
672
689
  return {
@@ -15086,6 +15086,8 @@ var defaultModelSelectionSchema = external_exports.object({
15086
15086
  effort: external_exports.enum(EFFORT_LEVELS).optional()
15087
15087
  });
15088
15088
  var appConfigSchema = external_exports.object({
15089
+ /** Verified by the dedicated domain endpoint, never a generic config patch. */
15090
+ customDomain: external_exports.string().optional(),
15089
15091
  defaultModelSelection: defaultModelSelectionSchema.optional(),
15090
15092
  /** CLI-only launch preferences. Never enable remote access implicitly. */
15091
15093
  cliStartup: external_exports.object({
@@ -15129,7 +15131,7 @@ var appConfigSchema = external_exports.object({
15129
15131
  var storedAppConfigSchema = appConfigSchema.extend({
15130
15132
  browserProfiles: storedBrowserProfilesSchema.optional()
15131
15133
  });
15132
- var appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true });
15134
+ var appConfigPatchSchema = appConfigSchema.omit({ instances: true, mcpServers: true, cliStartup: true, customDomain: true });
15133
15135
  var jsonObjectSchema = external_exports.record(external_exports.string(), external_exports.json());
15134
15136
  var DATA_DIR = process.env.OMB_DATA_DIR ?? join2(homedir2(), ".openmausbot");
15135
15137
  var LEGACY_DATA_DIR = join2(homedir2(), ".opengrokbot");
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "openmausbot",
3
- "version": "0.1.63",
3
+ "version": "0.1.65",
4
4
  "description": "Run the OpenMausBot server anywhere and pair your devices to it",
5
5
  "license": "Apache-2.0",
6
6
  "type": "module",