Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 14 additions & 8 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -46,8 +46,9 @@ it keeps the idea (AI as a *messaging app*: a roster of bots you chat with, each
memory of its thread, model, computer, and apps) and rebuilds it open, local-first, and on the agents you
already have:

- **Bring your own agents.** Bots run on the `claude`, `codex`, and `grok` CLIs installed on your own machine
— your existing logins and subscriptions, no new accounts, no proxy in the middle.
- **Bring your own agents and models.** Bots can run on the `claude`, `codex`, and `grok` CLIs installed on
your own machine, OpenRouter or Ollama Cloud, or any HTTP endpoint that implements the OpenAI chat
completions API (including local Ollama and vLLM servers).
- **Local first.** One small harness server on `127.0.0.1` owns every agent process. Transcripts, keys, and
events live in `~/.openmausbot`, not a cloud.
- **Agents with hands.** Each bot can get a real computer — a cloud Linux desktop it drives while you watch
Expand All @@ -62,7 +63,8 @@ already have:
### 🧠 Pick a brain per bot

A model picker with a provider rail — Claude and Codex models side by side, defaults marked, unavailable
providers dimmed with the reason. Switch a bot's model mid-conversation.
providers dimmed with the reason. Switch a bot's model mid-conversation. OpenRouter, Ollama Cloud, and
custom OpenAI-compatible endpoints can be configured during onboarding or later in App Settings.

<img src="docs/screenshots/model-picker.png" alt="Model picker with provider rail" width="100%">

Expand Down Expand Up @@ -198,9 +200,9 @@ pnpm dev # app → http://127.0.0.1:5199
pnpm dev:desktop # Electron shell; keep the two commands above running
```

Requirements: **macOS, Windows, or Ubuntu 24.04 x64**, **Node 24+**, **pnpm**, and at least one agent CLI — [`claude`](https://claude.com/claude-code),
[`codex`](https://github.com/openai/codex), or [`grok`](https://x.ai/cli) — installed and logged in. They appear
in the model picker automatically.
Requirements: **macOS, Windows, or Ubuntu 24.04 x64**, **Node 24+**, **pnpm**, and either an agent CLI — [`claude`](https://claude.com/claude-code),
[`codex`](https://github.com/openai/codex), or [`grok`](https://x.ai/cli) — installed and logged in, or one of
the model endpoints below. Available models appear in the model picker automatically.

Package the desktop application:

Expand Down Expand Up @@ -228,13 +230,17 @@ in the sidebar footer) when you want to enable its integration:

| Credential | What it enables | Where to get it |
|---|---|---|
| OpenRouter API key | Chat through OpenRouter's OpenAI-compatible model catalog | [OpenRouter keys](https://openrouter.ai/settings/keys) |
| Ollama Cloud API key | Chat with models hosted by Ollama Cloud | [Ollama Cloud](https://ollama.com/) |
| OpenAI-compatible API key (optional) | Authenticate to a custom OpenAI-compatible endpoint; local Ollama and vLLM usually do not require one | Your endpoint operator |
| Composio Connect key (`ck_…`) | Connect Gmail, GitHub, Slack, Notion, and other apps to your bots | [Composio Connect setup guide](https://docs.composio.dev/docs/composio-connect) |
| Composio API key (`ak_…`) | Browse the full app catalog with official names and logos | [Composio project API key guide](https://docs.composio.dev/reference/authenticating-to-composio/project-api-key-permissions) |
| Box API key | Give bots an isolated remote Linux computer with a desktop and terminal | [Box API key guide](https://docs.ascii.dev/box/api-keys) |
| ElevenLabs key | Read replies aloud, and call your bots | [ElevenLabs API keys](https://elevenlabs.io/app/settings/api-keys) |

Composio and Box are third-party services with their own accounts and terms. Box is a paid service after
its trial, and using a cloud computer may incur charges.
OpenRouter, Ollama Cloud, Composio, and Box are third-party services with their own accounts and terms.
Box is a paid service after its trial, and using a hosted model or cloud computer may incur charges. Custom
OpenAI-compatible endpoint URLs and default model IDs are configured under **App Settings → Models**.

```sh
pnpm typecheck # app + server
Expand Down
11 changes: 9 additions & 2 deletions electron/main.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { startCua, stopCua, registerCuaIpc } from "./cua.mjs";
import { startSpeech, stopSpeech } from "./speech.mjs";
import { startUpdater, registerUpdaterIpc } from "./updater.mjs";
import capabilitiesModule from "./capabilities.cjs";
import { isAllowedSubframeNavigation, isTrustedExternalOpen } from "./navigation-policy.mjs";

const { desktopCapabilities } = capabilitiesModule;

Expand Down Expand Up @@ -147,10 +148,16 @@ function createWindow() {
},
});

win.webContents.setWindowOpenHandler(({ url }) => {
shell.openExternal(url);
win.webContents.setWindowOpenHandler(({ url, referrer }) => {
if (isTrustedExternalOpen(url, referrer.url, win.webContents.getURL())) void shell.openExternal(url);
return { action: "deny" };
});
// Artifact previews run in sandboxed srcdoc frames. Their CSP blocks
// subresources; this main-process guard also prevents model-authored
// scripts or links from navigating the frame to an outbound URL.
win.webContents.on("will-frame-navigate", (event, details) => {
if (!details.isMainFrame && !isAllowedSubframeNavigation(details.url)) event.preventDefault();
});

// Packaged CI smoke hook. It validates the real renderer/preload bridge and
// same-origin embedded server, then follows the normal window-close path.
Expand Down
20 changes: 20 additions & 0 deletions electron/navigation-policy.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
const LOCAL_DOCUMENT_SCHEMES = new Set(["about:", "blob:", "data:"]);

export function isAllowedSubframeNavigation(value) {
try {
return LOCAL_DOCUMENT_SCHEMES.has(new URL(value).protocol);
} catch {
return false;
}
}

export function isTrustedExternalOpen(targetValue, referrerValue, appValue) {
try {
const target = new URL(targetValue);
const referrer = new URL(referrerValue);
const app = new URL(appValue);
return (target.protocol === "http:" || target.protocol === "https:") && referrer.origin === app.origin;
} catch {
return false;
}
}
40 changes: 40 additions & 0 deletions electron/navigation-policy.test.mjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
import { describe, expect, it } from "vitest";

import { isAllowedSubframeNavigation, isTrustedExternalOpen } from "./navigation-policy.mjs";

describe("artifact subframe navigation policy", () => {
it("allows only local opaque-document schemes", () => {
expect(isAllowedSubframeNavigation("about:srcdoc")).toBe(true);
expect(isAllowedSubframeNavigation("data:text/html,ok")).toBe(true);
expect(isAllowedSubframeNavigation("blob:http://127.0.0.1/id")).toBe(true);
});

it.each([
"https://attacker.example/collect",
"http://127.0.0.1:11434/admin",
"file:///etc/passwd",
"javascript:location='https://attacker.example'",
])("blocks model-authored subframe navigation to %s", (url) => {
expect(isAllowedSubframeNavigation(url)).toBe(false);
});
});

describe("external window policy", () => {
it("opens web links only when the app's top document supplied the referrer", () => {
expect(isTrustedExternalOpen(
"https://docs.example/page",
"http://127.0.0.1:8799/chat",
"http://127.0.0.1:8799/",
)).toBe(true);
expect(isTrustedExternalOpen(
"https://attacker.example/collect?secret=x",
"",
"http://127.0.0.1:8799/",
)).toBe(false);
expect(isTrustedExternalOpen(
"file:///etc/passwd",
"http://127.0.0.1:8799/chat",
"http://127.0.0.1:8799/",
)).toBe(false);
});
});
84 changes: 84 additions & 0 deletions server/config.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,84 @@
import { describe, expect, it } from "vitest";

import { instanceConfigs } from "./config.ts";

describe("model provider instance configuration", () => {
it("adds the API-backed providers to the default fleet", () => {
const configs = instanceConfigs({
openrouter: { key: "router-key" },
ollamaCloud: { key: "ollama-key" },
openaiCompatible: {
key: "endpoint-key",
url: "http://10.0.0.42:8000/v1",
model: "org/open-model",
modelTasks: { "org/image-model": "image", "org/video-model": "video" },
imagePath: "/images/generations",
videoPath: "/videos",
},
});

expect(configs.openrouter).toMatchObject({
driver: "openrouter",
config: { url: "https://openrouter.ai/api/v1" },
environment: { OPENROUTER_API_KEY: "router-key" },
});
expect(configs["ollama-cloud"]).toMatchObject({
driver: "ollamaCloud",
config: { url: "https://ollama.com/v1" },
environment: { OLLAMA_API_KEY: "ollama-key" },
});
expect(configs["openai-compatible"]).toMatchObject({
driver: "openaiCompatible",
config: {
url: "http://10.0.0.42:8000/v1",
model: "org/open-model",
modelTasks: { "org/image-model": "image", "org/video-model": "video" },
imagePath: "/images/generations",
videoPath: "/videos",
},
environment: { OPENAI_COMPATIBLE_API_KEY: "endpoint-key" },
});
});

it("injects each provider credential only into its matching driver", () => {
const configs = instanceConfigs({
xai: { key: "xai-key" },
openrouter: { key: "router-key" },
ollamaCloud: { key: "ollama-key" },
openaiCompatible: { key: "endpoint-key" },
box: { token: "box-token" },
instances: {
grokApi: { driver: "grok" },
router: { driver: "openrouter" },
ollama: { driver: "ollamaCloud" },
endpoint: { driver: "openaiCompatible" },
computer: { driver: "boxAgent" },
unrelated: { driver: "claudeAgent" },
},
});

expect(configs.grokApi.environment).toEqual({ XAI_API_KEY: "xai-key" });
expect(configs.router.environment).toEqual({ OPENROUTER_API_KEY: "router-key" });
expect(configs.ollama.environment).toEqual({ OLLAMA_API_KEY: "ollama-key" });
expect(configs.endpoint.environment).toEqual({ OPENAI_COMPATIBLE_API_KEY: "endpoint-key" });
expect(configs.computer.environment).toEqual({ BOX_TOKEN: "box-token" });
expect(configs.unrelated.environment).toEqual({});
});

it("preserves explicit per-instance credential overrides", () => {
const configs = instanceConfigs({
openrouter: { key: "global-router-key" },
instances: {
router: {
driver: "openrouter",
environment: { OPENROUTER_API_KEY: "instance-router-key", EXISTING: "kept" },
},
},
});

expect(configs.router.environment).toEqual({
OPENROUTER_API_KEY: "instance-router-key",
EXISTING: "kept",
});
});
});
68 changes: 64 additions & 4 deletions server/config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,22 @@ import { homedir } from "node:os";
import { join } from "node:path";

import { writeFileAtomic } from "./atomic.ts";
import type { InstanceConfigMap } from "./contracts.ts";
import type { InstanceConfigMap, ModelTask } from "./contracts.ts";

export interface OpenAICompatibleConfig {
key?: string;
url?: string;
model?: string;
modelTasks?: Record<string, ModelTask>;
imagePath?: string;
videoPath?: string;
}

export interface AppConfig {
xai?: { key?: string; url?: string };
openrouter?: { key?: string; url?: string; model?: string };
ollamaCloud?: { key?: string; url?: string; model?: string };
openaiCompatible?: OpenAICompatibleConfig;
/** key = ck_… Connect consumer key (connections + agent tools);
* apiKey = ak_… project API key — optional, unlocks the full toolkit
* catalog with official logos in the plugins marketplace. */
Expand Down Expand Up @@ -51,6 +63,12 @@ export function loadConfig(): AppConfig {
/* first run — env fallbacks below */
}
cfg.xai = { key: process.env.XAI_API_KEY, ...cfg.xai };
cfg.openrouter = { key: process.env.OPENROUTER_API_KEY, ...cfg.openrouter };
cfg.ollamaCloud = { key: process.env.OLLAMA_API_KEY, ...cfg.ollamaCloud };
cfg.openaiCompatible = {
key: process.env.OPENAI_COMPATIBLE_API_KEY,
...cfg.openaiCompatible,
};
cfg.composio = { key: process.env.COMPOSIO_KEY, ...cfg.composio };
cfg.box = { token: process.env.BOX_TOKEN, ...cfg.box };
cfg.tts = { key: process.env.OMB_TTS_KEY, ...cfg.tts };
Expand All @@ -67,7 +85,16 @@ export function saveConfig(patch: Partial<AppConfig>): void {
} catch {
/* first write */
}
for (const key of ["xai", "composio", "box", "tts", "profile"] as const) {
for (const key of [
"xai",
"openrouter",
"ollamaCloud",
"openaiCompatible",
"composio",
"box",
"tts",
"profile",
] as const) {
if (patch[key] && typeof patch[key] === "object") {
disk[key] = { ...(disk[key] as object), ...patch[key] };
}
Expand Down Expand Up @@ -101,13 +128,46 @@ export function instanceConfigs(cfg: AppConfig): InstanceConfigMap {
grok: { driver: "grokAgent" },
claude: { driver: "claudeAgent" },
codex: { driver: "codex" },
openrouter: {
driver: "openrouter",
config: {
url: cfg.openrouter?.url ?? "https://openrouter.ai/api/v1",
model: cfg.openrouter?.model,
},
},
"ollama-cloud": {
driver: "ollamaCloud",
config: {
url: cfg.ollamaCloud?.url ?? "https://ollama.com/v1",
model: cfg.ollamaCloud?.model,
},
},
"openai-compatible": {
driver: "openaiCompatible",
config: {
url: cfg.openaiCompatible?.url,
model: cfg.openaiCompatible?.model,
modelTasks: cfg.openaiCompatible?.modelTasks,
imagePath: cfg.openaiCompatible?.imagePath,
videoPath: cfg.openaiCompatible?.videoPath,
},
},
antigravity: { driver: "antigravityAgent" },
computer: { driver: "boxAgent" },
};
for (const entry of Object.values(map)) {
entry.environment = {
...(cfg.xai?.key ? { XAI_API_KEY: cfg.xai.key } : {}),
...(cfg.box?.token ? { BOX_TOKEN: cfg.box.token } : {}),
...(entry.driver === "grok" && cfg.xai?.key ? { XAI_API_KEY: cfg.xai.key } : {}),
...(entry.driver === "openrouter" && cfg.openrouter?.key
? { OPENROUTER_API_KEY: cfg.openrouter.key }
: {}),
...(entry.driver === "ollamaCloud" && cfg.ollamaCloud?.key
? { OLLAMA_API_KEY: cfg.ollamaCloud.key }
: {}),
...(entry.driver === "openaiCompatible" && cfg.openaiCompatible?.key
? { OPENAI_COMPATIBLE_API_KEY: cfg.openaiCompatible.key }
: {}),
...(entry.driver === "boxAgent" && cfg.box?.token ? { BOX_TOKEN: cfg.box.token } : {}),
...entry.environment,
};
}
Expand Down
Loading